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
+161 -45
View File
@@ -2,21 +2,22 @@ import Foundation
import Testing
@testable import Workouts
/// Locks the Swift motion solver to the Exercise Library's reference implementation
/// (`Exercise Library/render.py`): the bundled rig resources must decode, and the
/// FK / IK / tween math must reproduce values computed by the Python for the same
/// data the two renderers are meant to stay in lockstep.
/// Locks the Swift motion solver to the Exercise Library's anatomical 3D reference
/// (`Exercise Library/kinematics.py` + `render.py`): the bundled rig resources must
/// decode, and the FK / IK / frame-geometry / tween math must reproduce the projected
/// geometry the Python computes captured in `Fixtures/figure-fixtures.json`. The two
/// renderers are meant to stay in lockstep.
struct ExerciseMotionTests {
@Test func bundledBirdDogResourcesDecode() throws {
let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog"))
#expect(resources.motion.name == "Bird Dog")
// Bird Dog alternates sides, so all four limbs are in the working set and
// the loop is four key frames (lift right/left pair, then the opposite pair).
// Bird Dog alternates sides, so all four limbs are in the working set and the
// loop is four key frames (support both hands, lift one arm/leg pair, and back).
#expect(resources.motion.frames.count == 4)
#expect(resources.motion.working == ["arm_r", "leg_l", "arm_l", "leg_r"])
#expect(resources.body.upperArm == 30)
#expect(MotionTimeline(motion: resources.motion, body: resources.body) != nil)
#expect(resources.motion.working == ["arm_l", "leg_r", "arm_r", "leg_l"])
#expect(resources.profile.upperArm == 30)
#expect(MotionTimeline(motion: resources.motion, profile: resources.profile) != nil)
}
@Test func exerciseWithoutBundledMotionLoadsNothing() {
@@ -24,53 +25,53 @@ struct ExerciseMotionTests {
#expect(FigureAnimation(exerciseName: "Bench Press") == nil)
}
/// Shortest-path angular interpolation (reference: `lerp_angle`).
@Test func angleLerpTakesShortestPath() {
#expect(MotionSolver.lerpAngle(170, -170, 0.5) == 180)
#expect(MotionSolver.lerpAngle(-170, 170, 0.25) == -175)
#expect(MotionSolver.lerpAngle(0, 90, 0.5) == 45)
}
/// Normalizing Bird Dog's first key frame must reproduce the reference IK
/// solution for the pinned support arm, and the pinned hand must land exactly
/// on its target.
@Test func normalizeMatchesReferenceIK() throws {
/// Resolving Bird Dog's first key frame must reproduce the reference IK: both
/// pinned hands land exactly on their canvas targets, the head projects to the
/// reference point, and the far right arm's solved anatomical angles match.
@Test func birdDogFrameZeroMatchesReference() throws {
let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog"))
let pose = MotionSolver.normalize(resources.motion.frames[0], body: resources.body)
let cam = resources.motion.camera?.yaw ?? 0
let frame = MotionSolver.normalize(resources.motion.frames[0])
let (resolved, geo) = MotionSolver.frameGeometry(frame, prof: resources.profile, cam: cam)
let armR = try #require(pose.limbs[.armR])
#expect(abs(armR[0] - (-82.28919)) < 1e-4)
#expect(abs(armR[1] - (-99.732948)) < 1e-4)
let geo = MotionSolver.geometry(of: pose, body: resources.body, hide: [])
let hand = try #require(geo.limbs[.armR]?.last)
#expect(abs(hand.x - 105) < 1e-6)
#expect(abs(hand.y - 152) < 1e-6)
let handR = try #require(geo.limbs[.armR]?.last)
#expect(abs(handR.x - 111) < 1e-3)
#expect(abs(handR.y - 154) < 1e-3)
let handL = try #require(geo.limbs[.armL]?.last)
#expect(abs(handL.x - 105) < 1e-3)
#expect(abs(handL.y - 152) < 1e-3)
#expect(abs(geo.headCenter.x - 86.195568) < 1e-4)
#expect(abs(geo.headCenter.y - 95.140457) < 1e-4)
// The right arm is the far member here, so its angles solve against the
// offset target; the drawn hand still lands on the authored pin above.
#expect(geo.shade[.armR] == .far)
#expect(geo.shade[.armL] == .near)
#expect(abs(resolved.shoulderR.flexion - 73.289190) < 1e-4)
#expect(abs(resolved.elbowR.flexion - 17.443758) < 1e-4)
#expect(geo.order == ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"])
}
/// Mid-tween (t = ease(0.5)): the hand pinned in BOTH key frames stays planted
/// exactly; the pin present only in frame 1 releases; the lifting arm's angles
/// match the reference interpolation.
/// Mid-tween of resolved frames 12: a hand pinned in BOTH frames stays planted
/// exactly; a pin present in only one frame releases and swings off its target.
@Test func tweenKeepsSharedPinsAndReleasesOthers() throws {
let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog"))
let a = MotionSolver.normalize(resources.motion.frames[0], body: resources.body)
let b = MotionSolver.normalize(resources.motion.frames[1], body: resources.body)
let mid = MotionSolver.lerp(a, b, MotionSolver.ease(0.5))
let cam = resources.motion.camera?.yaw ?? 0
let r0 = MotionSolver.frameGeometry(MotionSolver.normalize(resources.motion.frames[0]), prof: resources.profile, cam: cam).0
let r1 = MotionSolver.frameGeometry(MotionSolver.normalize(resources.motion.frames[1]), prof: resources.profile, cam: cam).0
let mid = MotionSolver.lerpFrames(r0, r1, MotionSolver.ease(0.5))
#expect(mid.pins["hand_l"] != nil)
#expect(mid.pins["hand_r"] == nil)
#expect(mid.pins["hand_r"] != nil)
#expect(mid.pins["hand_l"] == nil)
let geo = MotionSolver.geometry(of: mid, body: resources.body, hide: [])
let plantedHand = try #require(geo.limbs[.armL]?.last)
#expect(abs(plantedHand.x - 111) < 1e-6)
#expect(abs(plantedHand.y - 154) < 1e-6)
let (_, geo) = MotionSolver.frameGeometry(mid, prof: resources.profile, cam: cam)
let plantedHand = try #require(geo.limbs[.armR]?.last)
#expect(abs(plantedHand.x - 111) < 1e-3)
#expect(abs(plantedHand.y - 154) < 1e-3)
let liftingArm = try #require(mid.limbs[.armR])
#expect(abs(liftingArm[0] - (-140.644595)) < 1e-4)
#expect(abs(liftingArm[1] - (-149.366474)) < 1e-4)
let releasedHand = try #require(geo.limbs[.armL]?.last)
#expect(hypot(releasedHand.x - 105, releasedHand.y - 152) > 1)
}
/// Every exported motion in the bundle decodes and builds a playable timeline.
@@ -81,8 +82,123 @@ struct ExerciseMotionTests {
for url in motionURLs {
let name = url.lastPathComponent.replacingOccurrences(of: ".motion.json", with: "")
let resources = try #require(ExerciseMotionLibrary.resources(for: name))
let timeline = try #require(MotionTimeline(motion: resources.motion, body: resources.body))
let timeline = try #require(MotionTimeline(motion: resources.motion, profile: resources.profile))
#expect(timeline.duration > 0)
}
}
/// The projected-geometry ground truth: for every exercise and key frame, the draw
/// order and near/far shading must match exactly and every point land within 0.5 px
/// of the reference, plus the mid-tween sample and (Bird Dog) two orbit-camera views.
@Test func figureFixturesMatchReference() throws {
let bundle = Bundle(for: FigureFixtureMarker.self)
let url = try #require(
bundle.url(forResource: "figure-fixtures", withExtension: "json"),
"figure-fixtures.json must be bundled as a WorkoutsTests resource (see project.yml)")
let fixtures = try JSONDecoder().decode(FigureFixtures.self, from: Data(contentsOf: url))
#expect(fixtures.exercises.count == 22)
for exercise in fixtures.exercises {
let resources = try #require(ExerciseMotionLibrary.resources(for: exercise.name),
"no bundled motion for \(exercise.name)")
let cam = resources.motion.camera?.yaw ?? 0
let profile = resources.profile
let norms = resources.motion.frames.map { MotionSolver.normalize($0) }
var resolved: [NormalizedFrame] = []
for (index, frame) in norms.enumerated() {
let (resolvedFrame, geo) = MotionSolver.frameGeometry(frame, prof: profile, cam: cam)
resolved.append(resolvedFrame)
if index < exercise.frames.count {
expectMatch(geo, exercise.frames[index], "\(exercise.name) frame \(index)")
}
}
let mid = MotionSolver.lerpFrames(resolved[0], resolved[1], MotionSolver.ease(exercise.tween.t))
expectMatch(MotionSolver.frameGeometry(mid, prof: profile, cam: cam).1,
exercise.tween.sample, "\(exercise.name) tween")
for orbit in exercise.orbit ?? [] {
expectMatch(MotionSolver.frameGeometry(norms[0], prof: profile, cam: orbit.yaw).1,
orbit.sample, "\(exercise.name) orbit \(orbit.yaw)")
}
}
}
}
/// Marker for locating the test bundle that carries `figure-fixtures.json`.
private final class FigureFixtureMarker {}
// MARK: - Fixture decoding + comparison
private struct FigureFixtures: Decodable {
let profile: String
let exercises: [FixtureExercise]
}
private struct FixtureExercise: Decodable {
let name: String
let camera: Double
let frames: [FixtureSample]
let tween: FixtureTween
let orbit: [FixtureOrbit]?
}
private struct FixtureTween: Decodable { let t: Double; let sample: FixtureSample }
private struct FixtureOrbit: Decodable { let yaw: Double; let sample: FixtureSample }
private struct FixtureSample: Decodable {
let order: [String]
let shade: [String: String]
let spine: [[Double]]
let head: [Double]
let nose: [[Double]]?
let armR: [[Double]]
let armL: [[Double]]
let legR: [[Double]]
let legL: [[Double]]
enum CodingKeys: String, CodingKey {
case order, shade, spine, head, nose
case armR = "arm_r", armL = "arm_l", legR = "leg_r", legL = "leg_l"
}
}
private func expectClose(_ point: CGPoint, _ expected: [Double], _ label: String) {
#expect(abs(Double(point.x) - expected[0]) < 0.5 && abs(Double(point.y) - expected[1]) < 0.5,
"\(label): got (\(point.x), \(point.y)) expected \(expected)")
}
private func expectChain(_ points: [CGPoint]?, _ expected: [[Double]], _ label: String) {
guard let points, points.count == expected.count else {
Issue.record("\(label): chain length mismatch")
return
}
for (index, (point, target)) in zip(points, expected).enumerated() {
expectClose(point, target, "\(label)[\(index)]")
}
}
private func expectMatch(_ geo: FigureGeometry, _ fixture: FixtureSample, _ label: String) {
#expect(geo.order == fixture.order, "\(label) order")
let shade = Dictionary(uniqueKeysWithValues: geo.shade.map { ($0.key.rawValue, $0.value == .near ? "near" : "far") })
#expect(shade == fixture.shade, "\(label) shade")
expectChain([geo.spineStart, geo.spineControl, geo.spineEnd], fixture.spine, "\(label).spine")
expectClose(geo.headCenter, fixture.head, "\(label).head")
if let nose = fixture.nose {
#expect(geo.noseStart != nil && geo.noseEnd != nil, "\(label): expected a nose tick")
if let start = geo.noseStart, let end = geo.noseEnd {
expectClose(start, nose[0], "\(label).nose.start")
expectClose(end, nose[1], "\(label).nose.end")
}
} else {
#expect(geo.noseStart == nil, "\(label): expected no nose tick")
}
expectChain(geo.limbs[.armR], fixture.armR, "\(label).arm_r")
expectChain(geo.limbs[.armL], fixture.armL, "\(label).arm_l")
expectChain(geo.limbs[.legR], fixture.legR, "\(label).leg_r")
expectChain(geo.limbs[.legL], fixture.legL, "\(label).leg_l")
}