Files
workouts/WorkoutsTests/ExerciseMotionTests.swift
T
rzen 81186c51b1 Give machine props world-space 3D form that rotates with the camera
Scene shapes, cable anchors, bar angles, pad perpendiculars, and roller
offsets all resolve in the authored view exactly as before, then rotate
about the world-vertical axis through the root anchor - the same
resolve-then-rotate pattern as the figure's pins and the mat - so at the
authored yaw every exercise renders bit-identically to today, and under
an orbiting camera the equipment turns with the figure while staying
welded to its hands and feet. Scene lines gain an optional depth plane
(z) and slab extrusion (depth) so seats, backrests, and platforms keep
form edge-on; the rect shape is retired (re-authored as slab lines).
All 14 machines' props re-authored with depths and verified at eight
orbit angles. The fixture snapshots move into the pipeline as
render.py --fixtures and now cover orbit-presentation samples with
resolved prop primitives for a spread of prop flavors; the in-app
renderer resolves props in MotionSolver (lockstep with resolve_props)
and the view just draws primitives.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
2026-07-06 22:15:45 -04:00

295 lines
14 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)
// 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)
}
/// Prop-free bodyweight motions slowly orbit the camera while looping; motions with
/// equipment (view-locked scene shapes) or a hide list keep their authored view.
@Test func orbitAppliesOnlyToPropFreeMotions() throws {
let birdDog = try #require(FigureAnimation(exerciseName: "Bird Dog"))
#expect(birdDog.orbits)
// Same loop phase seconds apart: the orbit yaw differs, so the projected head
// moves even though the pose is identical.
let duration = birdDog.timeline.duration
let head0 = birdDog.geometry(at: 0).headCenter
let head1 = birdDog.geometry(at: duration * 2).headCenter
#expect(hypot(head0.x - head1.x, head0.y - head1.y) > 1)
let legPress = try #require(FigureAnimation(exerciseName: "Leg Press"))
#expect(!legPress.orbits)
let fixed0 = legPress.geometry(at: 0).headCenter
let fixed1 = legPress.geometry(at: legPress.timeline.duration * 2).headCenter
#expect(abs(fixed0.x - fixed1.x) < 1e-9 && abs(fixed0.y - fixed1.y) < 1e-9)
}
/// 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))
#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")
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)
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")
}