Add Warm-Up and Stretching activity types
Adds WorkoutActivityType.warmUp (HealthKit .preparationAndRecovery) and .stretching (.flexibility), and retags the six starter splits that were all mislabeled as Functional Strength: - Warm-Up: Upper Body Warm-Up, Lower Body Warm-Up, Morning Wake-Up - Stretching: Morning Mobility, Full Body Stretch, Evening Stretch The split editor's activity picker surfaces them automatically (CaseIterable). Older app versions decode the new raw values as the default type — additive and not schema-gated, so no quarantine.
This commit is contained in:
@@ -190,6 +190,12 @@ struct FigureGeometry {
|
||||
/// Parts far-to-near, `"head"` always last (`"spine"`, `"arm_r"`, … then `"head"`).
|
||||
var order: [String]
|
||||
var shade: [FigureLimb: Shade]
|
||||
/// Per-vertex nearness in [0, 1] (1 = near/dark side), one value per joint of each
|
||||
/// limb chain, driving the shading gradient: each joint is toned by its own camera
|
||||
/// depth so the ink flows along a limb that reaches in depth. Unlike `shade` (which
|
||||
/// stays binary for geometry and draw order) this fades to a shared mid-tone as a
|
||||
/// pair crosses, so the ink never snaps. See `frameGeometry`.
|
||||
var nearness: [FigureLimb: [Double]]
|
||||
/// The equipment layer resolved for this frame: scene shapes and cables drawn
|
||||
/// behind the figure, joint-attached items drawn over the limbs (before the head).
|
||||
var propsBackground: [PropPrimitive] = []
|
||||
@@ -216,6 +222,12 @@ struct FigurePose {
|
||||
enum MotionSolver {
|
||||
private static let depthBucket = 3.0
|
||||
private static let pairs: [(FigureLimb, FigureLimb)] = [(.armR, .armL), (.legR, .legL)]
|
||||
/// Depth shading is a smooth per-vertex gradient: a joint reaches full near/far
|
||||
/// contrast once it sits this fraction of the shoulder/pelvis half-width in front of
|
||||
/// (or behind) its pair's central depth plane, so a straight limb in profile is still
|
||||
/// fully dark/light. See `frameGeometry` and `render.py`'s `SHADE_SPAN_FRAC` — the
|
||||
/// two must stay in sync.
|
||||
private static let shadeSpanFrac = 0.6
|
||||
/// Fixed draw rank breaking depth-bucket ties (far parts first, `spine` mid-stack).
|
||||
private static let fixedRank: [String: Int] = ["arm_l": 0, "leg_l": 1, "spine": 2, "arm_r": 3, "leg_r": 4]
|
||||
|
||||
@@ -354,17 +366,44 @@ enum MotionSolver {
|
||||
let perp = normal.cross(dirTarget)
|
||||
let along = (a * a + d * d - b * b) / (2 * d)
|
||||
let h = max(a * a - along * along, 0).squareRoot()
|
||||
var best: (distance: Double, mid: Vec3)?
|
||||
for sign in [1.0, -1.0] {
|
||||
let mid = attach + (dirTarget.scaled(along) + perp.scaled(sign * h))
|
||||
let distance = (mid - guessMid).length
|
||||
if best == nil || distance < best!.distance { best = (distance, mid) }
|
||||
let mid: Vec3
|
||||
if limb.isArm {
|
||||
var best: (distance: Double, mid: Vec3)?
|
||||
for sign in [1.0, -1.0] {
|
||||
let m = attach + (dirTarget.scaled(along) + perp.scaled(sign * h))
|
||||
let distance = (m - guessMid).length
|
||||
if best == nil || distance < best!.distance { best = (distance, m) }
|
||||
}
|
||||
mid = best!.mid
|
||||
} else {
|
||||
// A knee bends one way only. Near full extension the two knee solutions
|
||||
// straddle the hip→ankle line and the authored guess (also near it) can't
|
||||
// reliably pick a side — the bend plane's normal is a cross of two
|
||||
// near-parallel vectors, so its sign is noise and the knee can flip behind
|
||||
// the leg, swinging the thigh backward. When the authored knee sits within
|
||||
// `kneeStraightFrac` of the line, treat the leg as straight and bend the
|
||||
// knee anatomically forward (anterior); otherwise honor the authored side.
|
||||
let gm = guessMid - attach
|
||||
let gmPerp = gm - dirTarget.scaled(gm.dot(dirTarget))
|
||||
let ref = gmPerp.length < Self.kneeStraightFrac * a ? parent.apply(Vec3(1, 0, 0)) : gmPerp
|
||||
let sign = perp.dot(ref) >= 0 ? 1.0 : -1.0
|
||||
mid = attach + (dirTarget.scaled(along) + perp.scaled(sign * h))
|
||||
}
|
||||
let mid = best!.mid
|
||||
let end = mid + (target - mid).normalized.scaled(b)
|
||||
return invertLimb(limb, attach: attach, mid: mid, end: end, parent: parent)
|
||||
}
|
||||
|
||||
/// Axial rotation of a two-bone limb is recoverable only from the lower bone's
|
||||
/// lateral tip; below this magnitude the limb is effectively in-plane and its
|
||||
/// rotation is left at 0 (see `invertLimb`).
|
||||
private static let rotMinLateral = 0.08
|
||||
|
||||
/// When a leg's authored knee sits within this fraction of the thigh length off
|
||||
/// the hip→ankle line (~sin 8.6°), the leg is treated as straight and its IK knee
|
||||
/// is bent anatomically forward rather than trusting the (unreliable) authored
|
||||
/// side (see `solveLimb`).
|
||||
private static let kneeStraightFrac = 0.15
|
||||
|
||||
/// Recover anatomical angles from limb joint positions (the inverse of `fkLimb`,
|
||||
/// ignoring the foot). Assumes |abduction| < 90; the leg's rotation sign flips
|
||||
/// because knees hinge backward.
|
||||
@@ -377,11 +416,18 @@ enum MotionSolver {
|
||||
let peel = chain(Mat3.rotZ(flexion), Mat3.rotX(-sigma * abduction)).transposed
|
||||
let w = peel.apply(parentT.apply(end - mid)).normalized
|
||||
let bend = acos(clampUnit(-w.y)) * 180 / .pi
|
||||
// Axial rotation is observable only through the lower bone's lateral tip
|
||||
// (w.z); a near-sagittal limb carries no recoverable rotation. `bend`
|
||||
// alone is too weak a guard: a limb can straighten through this degeneracy
|
||||
// while still visibly bent, and atan2 then snaps to ±180 on the sign of a
|
||||
// near-zero anterior component — twisting the limb a half-turn and
|
||||
// flipping a pinned hand/foot backward.
|
||||
let twist = bend > 0.5 && abs(w.z) > Self.rotMinLateral
|
||||
let rotation: Double
|
||||
if limb.isArm {
|
||||
rotation = bend > 0.5 ? sigma * atan2(w.z, w.x) * 180 / .pi : 0
|
||||
rotation = twist ? sigma * atan2(w.z, w.x) * 180 / .pi : 0
|
||||
} else {
|
||||
rotation = bend > 0.5 ? sigma * atan2(-w.z, -w.x) * 180 / .pi : 0
|
||||
rotation = twist ? sigma * atan2(-w.z, -w.x) * 180 / .pi : 0
|
||||
}
|
||||
return (BallJoint(flexion: flexion, abduction: abduction, rotation: rotation), Hinge(flexion: bend))
|
||||
}
|
||||
@@ -485,6 +531,24 @@ enum MotionSolver {
|
||||
depths[limb.rawValue] = chainDepth(pts)
|
||||
}
|
||||
|
||||
// Per-vertex depth shading: tone each drawn joint by its own camera depth
|
||||
// relative to its pair's central plane, normalized by the profile half-width
|
||||
// (× shadeSpanFrac), so the ink flows along a limb that reaches toward or away
|
||||
// from the camera instead of the bone snapping to one flat tone. The per-vertex
|
||||
// average reproduces the old flat per-limb tone. Kept 1:1 with render.py.
|
||||
var nearness: [FigureLimb: [Double]] = [:]
|
||||
for (right, left) in pairs {
|
||||
guard let rp = p.limbs[right], let lp = p.limbs[left] else { continue }
|
||||
let ref = (chainDepth(rp) + chainDepth(lp)) / 2
|
||||
let half = (right == .armR ? prof.shoulderHalf : prof.hipHalf) * p.k
|
||||
let span = half * shadeSpanFrac
|
||||
func tone(_ z: Double) -> Double {
|
||||
span != 0 ? 0.5 + 0.5 * max(-1, min(1, (z - ref) / span)) : 0.5
|
||||
}
|
||||
nearness[right] = rp.map { tone($0.z) }
|
||||
nearness[left] = lp.map { tone($0.z) }
|
||||
}
|
||||
|
||||
let head = scr(p.head)
|
||||
var noseStart: CGPoint?, noseEnd: CGPoint?
|
||||
let nose = p.noseDir
|
||||
@@ -520,7 +584,7 @@ enum MotionSolver {
|
||||
spineStart: pelvis, spineControl: control, spineEnd: neckB,
|
||||
girdle: [attachPoint(p.shoulderL, .armL), neckB, attachPoint(p.shoulderR, .armR)],
|
||||
pelvisBar: [attachPoint(p.hipL, .legL), pelvis, attachPoint(p.hipR, .legR)],
|
||||
floor: floor, limbs: limbs, order: order, shade: shade)
|
||||
floor: floor, limbs: limbs, order: order, shade: shade, nearness: nearness)
|
||||
return (resolved, geo)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user