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
205 lines
9.5 KiB
Swift
205 lines
9.5 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Workouts
|
|
|
|
/// 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 (support both hands, lift one arm/leg pair, and back).
|
|
#expect(resources.motion.frames.count == 4)
|
|
#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() {
|
|
#expect(ExerciseMotionLibrary.resources(for: "Bench Press") == nil)
|
|
#expect(FigureAnimation(exerciseName: "Bench Press") == nil)
|
|
}
|
|
|
|
/// 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 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 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 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 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_r"] != nil)
|
|
#expect(mid.pins["hand_l"] == nil)
|
|
|
|
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 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.
|
|
@Test func allBundledMotionsBuildTimelines() throws {
|
|
let urls = Bundle.main.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? []
|
|
let motionURLs = urls.filter { $0.lastPathComponent.hasSuffix(".motion.json") }
|
|
#expect(!motionURLs.isEmpty)
|
|
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, 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")
|
|
}
|