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
@@ -12,10 +12,12 @@ import SwiftUI
/// `Exercise Library/SYSTEM.md` for the visual language).
///
/// The solver hands back a depth-sorted draw order per frame (far parts first, the head
/// always last so overhead arms pass behind the face) and a near/far tag per limb: the
/// member nearer the camera draws in the dark ink at the heavier width, the far member
/// in the light ink one step thinner and nudged by the readability offset. Working parts
/// swap the same near/far inks for the accent teals; the spine is always dark.
/// always last so overhead arms pass behind the face) and a per-vertex nearness for each
/// limb: every joint is toned by its own camera depth, so each bone is stroked with a
/// gradient between its joints' tones the ink flows along a limb reaching in depth
/// instead of snapping, and the far member sits lighter, thinner, and nudged by the
/// readability offset. Working parts swap the near/far inks for the accent teals; the
/// spine is always dark.
/// Everything the renderer needs for one exercise, resolved once from the bundle.
struct FigureAnimation {
@@ -28,9 +30,9 @@ struct FigureAnimation {
/// full-size anatomy taller than the canvas and zoom out to fit (see SYSTEM.md).
let zoom: Double
init?(exerciseName: String) {
init?(exerciseName: String, profile: FigureProfile = .neutral) {
guard
let resources = ExerciseMotionLibrary.resources(for: exerciseName),
let resources = ExerciseMotionLibrary.resources(for: exerciseName, profile: profile),
let timeline = MotionTimeline(motion: resources.motion, profile: resources.profile)
else { return nil }
self.timeline = timeline
@@ -56,6 +58,7 @@ struct FigureAnimation {
struct ExerciseFigureSlot: View {
let exerciseName: String
@AppStorage("figureProfile") private var figureProfile: FigureProfile = .neutral
@State private var figure: FigureAnimation?
var body: some View {
@@ -67,8 +70,9 @@ struct ExerciseFigureSlot: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task(id: exerciseName) {
figure = FigureAnimation(exerciseName: exerciseName)
// Rebuild when either the exercise or the chosen body type changes.
.task(id: [exerciseName, figureProfile.rawValue]) {
figure = FigureAnimation(exerciseName: exerciseName, profile: figureProfile)
}
}
}
@@ -136,8 +140,8 @@ struct ExerciseFigureView: View {
drawSpine(&ctx, geo)
default:
guard let limb = FigureLimb(rawValue: part), let points = geo.limbs[limb] else { continue }
let shade = geo.shade[limb] ?? .near
stroke(&ctx, points, color: ink(part, shade: shade), width: shade == .near ? 6 : 5)
let tones = geo.nearness[limb] ?? Array(repeating: 1, count: points.count)
strokeLimb(&ctx, points, tones: tones, part: part)
}
}
}
@@ -154,7 +158,7 @@ struct ExerciseFigureView: View {
}
private func drawSpine(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
let color = ink("spine", shade: .near)
let color = ink("spine", nearness: 1)
stroke(&ctx, geo.girdle, color: color, width: 5)
stroke(&ctx, geo.pelvisBar, color: color, width: 5)
var path = Path()
@@ -201,13 +205,35 @@ struct ExerciseFigureView: View {
ink == .prop ? .figureProp : .figureEquipment
}
/// Near parts take the dark ink, far parts the light one; working parts swap for the
/// accent teals. The spine (passed `.near`) is always dark unless it's the working part.
private func ink(_ part: String, shade: Shade) -> Color {
if figure.working.contains(part) {
return shade == .near ? .figureNearWorking : .figureFarWorking
/// A limb's ink at `nearness` (1 = near/dark side, 0 = far/light side): the near
/// and far grays, or the accent teals for a working part. The spine (passed
/// nearness 1) is the near ink unless it's working.
private func ink(_ part: String, nearness t: Double) -> Color {
.figureShaded(working: figure.working.contains(part), nearness: t)
}
/// Strokes a limb chain with a per-vertex depth gradient: each bone segment is a
/// round-capped line filled with a linear gradient between its two joints' depth
/// tones, so the ink flows smoothly along a limb that reaches toward or away from
/// the camera instead of the bone snapping to one flat tone. Adjacent segments share
/// a joint tone, so their round caps meet seamlessly. Mirrors `render.py`.
private func strokeLimb(_ ctx: inout GraphicsContext, _ points: [CGPoint],
tones: [Double], part: String) {
guard points.count >= 2, tones.count == points.count else { return }
for i in 0..<(points.count - 1) {
let a = points[i], b = points[i + 1]
let t0 = tones[i], t1 = tones[i + 1]
let style = StrokeStyle(lineWidth: 5 + (t0 + t1) / 2, lineCap: .round)
var path = Path()
path.move(to: a)
path.addLine(to: b)
if t0 == t1 || (abs(a.x - b.x) < 0.01 && abs(a.y - b.y) < 0.01) {
ctx.stroke(path, with: .color(ink(part, nearness: t0)), style: style)
} else {
let gradient = Gradient(colors: [ink(part, nearness: t0), ink(part, nearness: t1)])
ctx.stroke(path, with: .linearGradient(gradient, startPoint: a, endPoint: b), style: style)
}
}
return shade == .near ? .figureNear : .figureFar
}
private func stroke(_ ctx: inout GraphicsContext, _ points: [CGPoint], color: Color, width: CGFloat) {
@@ -238,18 +264,28 @@ private extension Color {
#endif
}
/// Near-side limbs, spine, head, nose the prominent stroke (`#3a3f4b`).
static let figureNear = figure(light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83))
/// The gradient endpoints as `(lightMode, darkMode)` RGB pairs the near (dark,
/// prominent) and far (light, recessive) inks, plain and working. `figureShaded`
/// blends between the far and near endpoint of a pair by a limb's nearness.
private static let nearInk = (light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83)) // #3a3f4b
private static let farInk = (light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43)) // #a9afba
private static let nearWorkingInk = (light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75)) // #0d9488
private static let farWorkingInk = (light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52)) // #86cfc5
/// Far-side limbs, drawn behind the recessive stroke (`#a9afba`).
static let figureFar = figure(light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43))
static func lerp3(_ a: (Double, Double, Double), _ b: (Double, Double, Double), _ t: Double) -> (Double, Double, Double) {
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, a.2 + (b.2 - a.2) * t)
}
/// Working near-side parts teal accent (`#0d9488`).
static let figureNearWorking = figure(light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75))
/// A limb's ink blended by nearness (1 = near/dark side, 0 = far/light side),
/// interpolating both the light- and dark-mode endpoints so it stays adaptive.
static func figureShaded(working: Bool, nearness t: Double) -> Color {
let far = working ? farWorkingInk : farInk
let near = working ? nearWorkingInk : nearInk
return figure(light: lerp3(far.light, near.light, t), dark: lerp3(far.dark, near.dark, t))
}
/// Working far-side parts light teal (`#86cfc5`), kept more muted than the near
/// so the near/far hierarchy holds in both modes.
static let figureFarWorking = figure(light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52))
/// Near-side ink for the head, nose, and non-gradient strokes (`#3a3f4b`).
static let figureNear = figure(light: nearInk.light, dark: nearInk.dark)
/// Scene equipment seats, benches, frames, cables (`#c5cad4`); sits behind
/// the figure, one step lighter than the ground line.
+25 -6
View File
@@ -19,7 +19,26 @@ import Foundation
/// exported by `render.py --export` into `Resources/ExerciseMotions/` `skeleton.json`
/// plus one `<Exercise Name>.motion.json` per library entry.
/// Bone lengths for one figure profile (`neutral` is the only one the app renders).
/// The figure build the exercise animation renders. A presentation-only choice of
/// skeleton profile (bone proportions) the motion scripts are identical across builds,
/// so switching never touches an exercise's animation, only its dimensions. The raw value
/// is the `skeleton.json` profile key; persisted via `@AppStorage("figureProfile")`.
enum FigureProfile: String, CaseIterable {
case neutral
case feminine = "female"
case masculine = "male"
var displayName: String {
switch self {
case .neutral: "Neutral"
case .feminine: "Feminine"
case .masculine: "Masculine"
}
}
}
/// Bone lengths for one figure profile (`neutral`, `female`, or `male`; the app picks
/// one per the `figureProfile` setting see `FigureProfile`).
struct SkeletonProfile: Codable {
let headR: Double
let neck: Double
@@ -270,10 +289,10 @@ enum ExerciseMotionLibrary {
.sorted()
}()
/// The motion script plus the neutral skeleton profile for `exerciseName`, or `nil`
/// when no bundled motion matches (most exercises have none the caller keeps
/// its space empty).
static func resources(for exerciseName: String) -> Resources? {
/// The motion script plus the requested skeleton `profile` for `exerciseName`, or
/// `nil` when no bundled motion matches (most exercises have none the caller keeps
/// its space empty). An unknown profile key falls back to `neutral`.
static func resources(for exerciseName: String, profile: FigureProfile = .neutral) -> Resources? {
guard
let motionURL = Bundle.main.url(forResource: exerciseName, withExtension: "motion.json"),
let skeletonURL = Bundle.main.url(forResource: "skeleton", withExtension: "json"),
@@ -281,7 +300,7 @@ enum ExerciseMotionLibrary {
let skeletonData = try? Data(contentsOf: skeletonURL),
let motion = try? JSONDecoder().decode(ExerciseMotion.self, from: motionData),
let skeleton = try? JSONDecoder().decode(Skeleton.self, from: skeletonData),
let profile = skeleton.profiles["neutral"]
let profile = skeleton.profiles[profile.rawValue] ?? skeleton.profiles[FigureProfile.neutral.rawValue]
else { return nil }
return Resources(motion: motion, profile: profile)
}
+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)
}
@@ -1,5 +1,5 @@
{
"activityType" : 1,
"activityType" : 7,
"color" : "indigo",
"createdAt" : "2020-01-01T00:00:00Z",
"exercises" : [
@@ -1,5 +1,5 @@
{
"activityType" : 1,
"activityType" : 7,
"color" : "brown",
"createdAt" : "2020-01-01T00:00:00Z",
"exercises" : [
@@ -1,5 +1,5 @@
{
"activityType" : 1,
"activityType" : 6,
"color" : "mint",
"createdAt" : "2020-01-01T00:00:00Z",
"exercises" : [
@@ -1,5 +1,5 @@
{
"activityType" : 1,
"activityType" : 7,
"color" : "pink",
"createdAt" : "2020-01-01T00:00:00Z",
"exercises" : [
@@ -1,5 +1,5 @@
{
"activityType" : 1,
"activityType" : 6,
"autoAdvance" : true,
"color" : "yellow",
"createdAt" : "2020-01-01T00:00:00Z",
@@ -1,5 +1,5 @@
{
"activityType" : 1,
"activityType" : 6,
"color" : "cyan",
"createdAt" : "2020-01-01T00:00:00Z",
"exercises" : [