Three solver defects made limbs teleport, twist, or windmill: write-back angles wrapped at ±180 and lerped the long way around; branch flips landed on configurations the anatomical write-back cannot represent, silently pulling pinned extremities off their pins; and the degenerate straight-limb bend plane fell back to the camera axis instead of the anatomical anterior. solve_limb now verifies each branch reproduces the solved end before accepting it, resolve unwraps written-back angles toward the pose they replace, and the degenerate plane comes from the parent's anterior axis. render.py --check replays every exercise's full tween loop and fails hard on six invariants (pin fidelity, continuity, wraps, authored-vs-resolved drift, ground penetration, resolved ROM); --export refuses to ship a failing exercise. All 66 motions re-authored or retouched to pass: honest authored angles where pins used to override them silently, grounded feet on the seated machines, a vertical bench-press bar path, straight-armed child's pose, a butterfly stretch seated on the mat, and FK arms where pins forced impossible reaches. MotionSolver.swift mirrors the solver changes line for line, held by regenerated fixtures. Claude-Session: https://claude.ai/code/session_01PKptrgbx74peTwHGRxBojv
306 lines
15 KiB
Swift
306 lines
15 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() {
|
|
// Cardio equipment is a permanent library exclusion (see COVERAGE.md).
|
|
#expect(ExerciseMotionLibrary.resources(for: "Treadmill") == nil)
|
|
#expect(FigureAnimation(exerciseName: "Treadmill") == 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)
|
|
|
|
// Pins solve in the flat authored view, then the posed body tilts through the
|
|
// default camera pitch — so drawn hands straddle the floor plane around their
|
|
// authored canvas targets (x exact, y shifted by each hand's depth).
|
|
let handR = try #require(geo.limbs[.armR]?.last)
|
|
#expect(abs(handR.x - 111) < 1e-2)
|
|
#expect(abs(handR.y - 151.391) < 1e-2)
|
|
let handL = try #require(geo.limbs[.armL]?.last)
|
|
#expect(abs(handL.x - 105) < 1e-2)
|
|
#expect(abs(handL.y - 153.211) < 1e-2)
|
|
|
|
#expect(abs(geo.headCenter.x - 86.195568) < 1e-4)
|
|
#expect(abs(geo.headCenter.y - 95.305438) < 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 (flat view).
|
|
#expect(geo.shade[.armR] == .far)
|
|
#expect(geo.shade[.armL] == .near)
|
|
#expect(abs(resolved.shoulderR.flexion - 73.384213) < 1e-4)
|
|
#expect(abs(resolved.elbowR.flexion - 17.076663) < 1e-4)
|
|
// Depth-sorted under the elevated camera (arm attach points are the widest).
|
|
#expect(geo.order == ["arm_r", "leg_r", "leg_l", "spine", "arm_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 (_, geo0) = MotionSolver.frameGeometry(r0, prof: resources.profile, cam: cam)
|
|
let plantedHand = try #require(geo.limbs[.armR]?.last)
|
|
let plantedHand0 = try #require(geo0.limbs[.armR]?.last)
|
|
#expect(hypot(plantedHand.x - plantedHand0.x, plantedHand.y - plantedHand0.y) < 1e-3)
|
|
|
|
let releasedHand = try #require(geo.limbs[.armL]?.last)
|
|
let releasedHand0 = try #require(geo0.limbs[.armL]?.last)
|
|
#expect(hypot(releasedHand.x - releasedHand0.x, releasedHand.y - releasedHand0.y) > 1)
|
|
}
|
|
|
|
/// Every motion slowly orbits the camera while looping — bodyweight moves and
|
|
/// machines alike, since the equipment layer rotates with the figure.
|
|
@Test func everyMotionOrbits() throws {
|
|
// Same loop phase seconds apart: the orbit yaw differs, so the projected head
|
|
// moves even though the pose is identical.
|
|
let birdDog = try #require(FigureAnimation(exerciseName: "Bird Dog"))
|
|
let head0 = birdDog.geometry(at: 0).headCenter
|
|
let head1 = birdDog.geometry(at: birdDog.timeline.duration * 2).headCenter
|
|
#expect(hypot(head0.x - head1.x, head0.y - head1.y) > 1)
|
|
|
|
// A machine orbits too, and its equipment turns with the figure: the seat
|
|
// slab's projected points move between the same two loop phases.
|
|
let legPress = try #require(FigureAnimation(exerciseName: "Leg Press"))
|
|
let geo0 = legPress.geometry(at: 0)
|
|
let geo1 = legPress.geometry(at: legPress.timeline.duration * 2)
|
|
#expect(hypot(geo0.headCenter.x - geo1.headCenter.x,
|
|
geo0.headCenter.y - geo1.headCenter.y) > 1)
|
|
guard case .poly(let seat0, _, _) = geo0.propsBackground.first,
|
|
case .poly(let seat1, _, _) = geo1.propsBackground.first,
|
|
let s0 = seat0.first, let s1 = seat1.first else {
|
|
Issue.record("expected the Leg Press backrest slab as the first background prop")
|
|
return
|
|
}
|
|
#expect(hypot(s0.x - s1.x, s0.y - s1.y) > 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 orbit-camera views taken through
|
|
/// the presentation path — resolve at the authored camera, clear the pins, rotate
|
|
/// the posed body — with the equipment layer resolved into rotated primitives.
|
|
@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))
|
|
// One fixture entry per bundled motion: the loop below proves fixtures ⊆ bundle,
|
|
// and matching counts close the other direction (a bundled motion the reference
|
|
// pipeline never snapshotted would otherwise slip through unverified).
|
|
#expect(fixtures.exercises.count == ExerciseMotionLibrary.exerciseNames.count)
|
|
|
|
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")
|
|
|
|
guard let orbits = exercise.orbit, !orbits.isEmpty else { continue }
|
|
let (posedFrame, authored) = MotionSolver.frameGeometry(norms[0], prof: profile, cam: cam)
|
|
var posed = posedFrame
|
|
posed.pins = [:]
|
|
for orbit in orbits {
|
|
let geo = MotionSolver.frameGeometry(posed, prof: profile, cam: orbit.yaw).1
|
|
expectMatch(geo, orbit.sample, "\(exercise.name) orbit \(orbit.yaw)")
|
|
guard let propFixture = orbit.sample.props else { continue }
|
|
let rotation = MotionSolver.propRotation(pitch: fixtures.pitch, yawOffset: orbit.yaw - cam)
|
|
let (bg, fg) = MotionSolver.resolveProps(
|
|
resources.motion.props ?? [], geo: geo, authored: authored,
|
|
anchor: norms[0].rootPos, rotation: rotation, pitch: fixtures.pitch)
|
|
expectPrims(bg, propFixture.bg, "\(exercise.name) orbit \(orbit.yaw) bg")
|
|
expectPrims(fg, propFixture.fg, "\(exercise.name) orbit \(orbit.yaw) fg")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 pitch: Double
|
|
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]]
|
|
let props: FixtureProps?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case order, shade, spine, head, nose, props
|
|
case armR = "arm_r", armL = "arm_l", legR = "leg_r", legL = "leg_l"
|
|
}
|
|
}
|
|
|
|
private struct FixtureProps: Decodable {
|
|
let bg: [FixturePrim]
|
|
let fg: [FixturePrim]
|
|
}
|
|
|
|
/// One reference prop primitive: a line/poly (`pts`) or a circle (`c` + `r`).
|
|
private struct FixturePrim: Decodable {
|
|
let kind: String
|
|
let pts: [[Double]]?
|
|
let c: [Double]?
|
|
let r: Double?
|
|
let w: Double?
|
|
let fill: Bool?
|
|
let color: String
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
/// One resolved equipment layer against its reference: same primitive kinds and inks
|
|
/// in the same order, every point within 0.5 px, radii and widths matching.
|
|
private func expectPrims(_ prims: [PropPrimitive], _ fixtures: [FixturePrim], _ label: String) {
|
|
guard prims.count == fixtures.count else {
|
|
Issue.record("\(label): \(prims.count) primitives, expected \(fixtures.count)")
|
|
return
|
|
}
|
|
for (index, (prim, fixture)) in zip(prims, fixtures).enumerated() {
|
|
let tag = "\(label)[\(index)]"
|
|
switch prim {
|
|
case .line(let points, let width, let ink):
|
|
#expect(fixture.kind == "line", "\(tag) kind")
|
|
expectChain(points, fixture.pts ?? [], tag)
|
|
#expect(width == (fixture.w ?? 4), "\(tag) width")
|
|
expectInk(ink, fixture.color, tag)
|
|
case .poly(let points, let width, let ink):
|
|
#expect(fixture.kind == "poly", "\(tag) kind")
|
|
expectChain(points, fixture.pts ?? [], tag)
|
|
#expect(width == (fixture.w ?? 4), "\(tag) width")
|
|
expectInk(ink, fixture.color, tag)
|
|
case .circle(let center, let radius, _, let fill, let ink):
|
|
#expect(fixture.kind == "circle", "\(tag) kind")
|
|
expectClose(center, fixture.c ?? [], tag)
|
|
#expect(radius == fixture.r, "\(tag) radius")
|
|
#expect(fill == (fixture.fill ?? false), "\(tag) fill")
|
|
expectInk(ink, fixture.color, tag)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func expectInk(_ ink: PropInk, _ expected: String, _ label: String) {
|
|
#expect((ink == .prop ? "prop" : "equipment") == expected, "\(label) ink")
|
|
}
|