Rebuild the exercise figure on an anatomical 3D skeleton

The library's planar world-angle rig becomes a genuine 3D anatomical
model: skeleton.json holds bone-length profiles (real shoulder/pelvis
widths, feet, neutral/female/male) and per-joint ROM; motions pose
joints with anatomical angles (flexion/abduction/rotation from neutral
standing) under a per-exercise orthographic camera, resolved by
kinematics.py (3D FK, analytic two-bone IK with anatomical write-back)
and validated against physiological ranges. All 20 sagittal motions
were migrated by planar decomposition with 0.00 px golden parity against
the old renderer — relabeled to true anatomy, since shading is now
near-dark/far-light by camera depth rather than by limb suffix — and
the face-on machines are re-authored honestly: Abductor/Adductor with
real hip abduction (the foreshortened "frontal" profile is retired) and
Rotary with genuine spine axial rotation. Figures gain articulated
feet; profiles swap without touching a single motion script; --orbit
sweeps the camera 360° while a motion loops.

The in-app SwiftUI renderer (iOS + watch) is ported to the same model
and consumes the exported motions verbatim; figure-fixtures.json pins
its geometry to the Python pipeline within 0.5 px across every
exercise, key frame, tween, and orbit sample. Also makes the watch
bridge logger nonisolated for the newer SDK's stricter isolation
checking.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
2026-07-06 20:10:50 -04:00
parent 6521de8f17
commit 3c7a790e9d
153 changed files with 2827 additions and 1686 deletions
@@ -8,12 +8,14 @@
import SwiftUI
/// The looping animated stick-figure for the run screen's bottom half, rendered with
/// `Canvas` + `TimelineView(.animation)` from the bundled rig data (see
/// `Canvas` + `TimelineView(.animation)` from the bundled anatomical rig data (see
/// `Exercise Library/SYSTEM.md` for the visual language).
///
/// Draw order matters: left limbs behind, spine, right limbs, then the head filled
/// opaque with the background color so overhead arms pass behind the face plus the
/// nose tick (gaze).
/// 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.
/// Everything the renderer needs for one exercise, resolved once from the bundle.
struct FigureAnimation {
@@ -24,22 +26,26 @@ struct FigureAnimation {
let hide: Set<String>
/// Equipment layer (see SYSTEM.md "The props layer").
let props: [MotionProp]
let bodyProfile: ExerciseBodyProfile
init?(exerciseName: String) {
guard
let resources = ExerciseMotionLibrary.resources(for: exerciseName),
let timeline = MotionTimeline(motion: resources.motion, body: resources.body)
let timeline = MotionTimeline(motion: resources.motion, profile: resources.profile)
else { return nil }
self.timeline = timeline
self.working = Set(resources.motion.working ?? [])
self.hide = Set(resources.motion.hide ?? [])
self.props = resources.motion.props ?? []
self.bodyProfile = resources.body
}
/// Drawable geometry at `time`, with limbs listed in `hide` dropped so neither they
/// nor any prop riding them are drawn.
func geometry(at time: Double) -> FigureGeometry {
MotionSolver.geometry(of: timeline.pose(at: time), body: bodyProfile, hide: hide)
var geo = timeline.geometry(at: time)
for name in hide {
if let limb = FigureLimb(rawValue: name) { geo.limbs[limb] = nil }
}
return geo
}
}
@@ -102,39 +108,39 @@ struct ExerciseFigureView: View {
// Equipment behind the figure: scene shapes and cables.
drawBackgroundProps(&ctx, geo)
// Left limbs (behind), spine, right limbs.
drawLimb(&ctx, geo, .armL)
drawLimb(&ctx, geo, .legL)
drawSpine(&ctx, geo)
drawLimb(&ctx, geo, .armR)
drawLimb(&ctx, geo, .legR)
// Joint-attached equipment over the limbs (bars, dumbbells, pads).
drawAttachedProps(&ctx, geo)
// Head last, filled with the background color so limbs pass behind the face.
let r = geo.headRadius
let headRect = CGRect(x: geo.headCenter.x - r, y: geo.headCenter.y - r,
width: 2 * r, height: 2 * r)
let headPath = Path(ellipseIn: headRect)
ctx.fill(headPath, with: .color(.figureHeadFill))
ctx.stroke(headPath, with: .color(.figureRight), lineWidth: 6)
if let noseStart = geo.noseStart, let noseEnd = geo.noseEnd {
stroke(&ctx, [noseStart, noseEnd], color: .figureRight, width: 4)
// Parts far-to-near. The head paints last (opaque), preceded by the joint-attached
// equipment so bars/pads sit over the limbs but behind the face.
for part in geo.order {
switch part {
case "head":
drawAttachedProps(&ctx, geo)
drawHead(&ctx, geo)
case "spine":
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)
}
}
}
private func drawLimb(_ ctx: inout GraphicsContext, _ geo: FigureGeometry, _ limb: FigureLimb) {
guard let points = geo.limbs[limb] else { return }
stroke(&ctx, points, color: color(for: limb.rawValue, isLeft: limb.isLeft),
width: limb.isLeft ? 5 : 6)
private func drawHead(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
let r = geo.headRadius
let headRect = CGRect(x: geo.headCenter.x - r, y: geo.headCenter.y - r, width: 2 * r, height: 2 * r)
let headPath = Path(ellipseIn: headRect)
ctx.fill(headPath, with: .color(.figureHeadFill))
ctx.stroke(headPath, with: .color(.figureNear), lineWidth: 6)
if let noseStart = geo.noseStart, let noseEnd = geo.noseEnd {
stroke(&ctx, [noseStart, noseEnd], color: .figureNear, width: 4)
}
}
private func drawSpine(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
var path = Path()
path.move(to: geo.spineStart)
path.addQuadCurve(to: geo.spineEnd, control: geo.spineControl)
ctx.stroke(path, with: .color(color(for: "spine", isLeft: false)),
ctx.stroke(path, with: .color(ink("spine", shade: .near)),
style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round))
}
@@ -142,7 +148,8 @@ struct ExerciseFigureView: View {
/// Prop joint refs (limb, chain index): extremities are index 2, mid joints
/// (elbows/knees) index 1, so equipment can ride either joint. Kept 1:1 with
/// the reference renderer's `JOINT_LIMB`.
/// the reference renderer's `JOINT_LIMB` (legs now end in a foot bone, so a foot
/// prop rides the ankle at index 2).
private static let propJoints: [String: (limb: FigureLimb, index: Int)] = [
"hand_r": (.armR, 2), "elbow_r": (.armR, 1),
"hand_l": (.armL, 2), "elbow_l": (.armL, 1),
@@ -248,11 +255,13 @@ struct ExerciseFigureView: View {
}
}
private func color(for part: String, isLeft: Bool) -> Color {
/// 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 isLeft ? .figureLeftWorking : .figureRightWorking
return shade == .near ? .figureNearWorking : .figureFarWorking
}
return isLeft ? .figureLeft : .figureRight
return shade == .near ? .figureNear : .figureFar
}
private func stroke(_ ctx: inout GraphicsContext, _ points: [CGPoint], color: Color, width: CGFloat) {
@@ -265,8 +274,8 @@ struct ExerciseFigureView: View {
// MARK: - Figure Palette
/// The reference palette (`render.py`), made dark-mode adaptive: the prominent right
/// side stays strong (near-black light gray), the recessive left side stays muted,
/// The reference palette (`render.py`), made dark-mode adaptive: the prominent near
/// side stays strong (near-black light gray), the recessive far side stays muted,
/// and the working teals brighten/desaturate so they read on black. watchOS has no
/// dynamic-provider `UIColor` and renders on black, so it takes the dark variant
/// verbatim.
@@ -283,18 +292,18 @@ private extension Color {
#endif
}
/// Right-side limbs, head, nose the prominent stroke (`#3a3f4b`).
static let figureRight = figure(light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83))
/// 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))
/// Left-side limbs, drawn behind the recessive stroke (`#a9afba`).
static let figureLeft = figure(light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43))
/// 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))
/// Working right-side parts teal accent (`#0d9488`).
static let figureRightWorking = figure(light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75))
/// 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))
/// Working left-side parts light teal (`#86cfc5`), kept more muted than the
/// right so the R/L hierarchy holds in both modes.
static let figureLeftWorking = figure(light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52))
/// 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))
/// Scene equipment seats, benches, frames, cables (`#c5cad4`); sits behind
/// the figure, one step lighter than the ground line.