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:
2026-07-09 15:45:31 -04:00
parent f01e149269
commit e12fe31152
308 changed files with 5594 additions and 990 deletions
+73 -9
View File
@@ -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 hipankle 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 hipankle 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)
}