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:
@@ -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 1→2: 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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user