// // MotionSolver.swift // Workouts // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import CoreGraphics import Foundation /// Swift port of the Exercise Library's anatomical 3D solver (`Exercise Library/ /// kinematics.py` + `render.py`'s `frame_geometry`). It poses one shared skeleton by /// anatomical joint angles, runs forward kinematics in ISB model space (X anterior, /// Y up, Z anatomical right), projects orthographically through a per-exercise camera /// yaw, and resolves each frame into drawable canvas geometry — near/far shading, the /// readability offset, draw order, spine curve, and the gaze nose tick. The math is /// kept 1:1 with the Python so both renderers produce the same figure; it is held to /// the reference by `WorkoutsTests/Fixtures/figure-fixtures.json`. Change them in /// lockstep. /// /// Rotation conventions (all degrees): root = Ry(yaw)·Rz(−pitch)·Rx(roll) after the /// camera Ry(−camYaw); ball joints (shoulder/hip) = Rz(flexion)·Rx(−σ·abduction)· /// Ry(−σ·rotation) with σ = +1 right / −1 left; the knee hinges backward via Rz(−flexion); /// spine segments = Rz(−flexion)·Rx(lateral)·Ry(−rotation); the neck = Rz(−flexion)· /// Ry(−rotation). Canvas maps view x → x, view y → −y (origin at the root anchor). // MARK: - Linear algebra /// A 3-vector in model / view space. struct Vec3 { var x, y, z: Double init(_ x: Double, _ y: Double, _ z: Double) { self.x = x; self.y = y; self.z = z } static func + (a: Vec3, b: Vec3) -> Vec3 { Vec3(a.x + b.x, a.y + b.y, a.z + b.z) } static func - (a: Vec3, b: Vec3) -> Vec3 { Vec3(a.x - b.x, a.y - b.y, a.z - b.z) } func scaled(_ s: Double) -> Vec3 { Vec3(x * s, y * s, z * s) } func dot(_ b: Vec3) -> Double { x * b.x + y * b.y + z * b.z } func cross(_ b: Vec3) -> Vec3 { Vec3(y * b.z - z * b.y, z * b.x - x * b.z, x * b.y - y * b.x) } var length: Double { dot(self).squareRoot() } var normalized: Vec3 { let d = length; return d > 1e-9 ? scaled(1 / d) : Vec3(0, 0, 0) } } /// A row-major 3×3 rotation matrix (rows r0/r1/r2), matching the reference's /// tuple-of-rows so multiplication and transpose stay identical. struct Mat3 { var r0, r1, r2: Vec3 static func rotX(_ deg: Double) -> Mat3 { let r = deg * .pi / 180, c = cos(r), s = sin(r) return Mat3(r0: Vec3(1, 0, 0), r1: Vec3(0, c, -s), r2: Vec3(0, s, c)) } static func rotY(_ deg: Double) -> Mat3 { let r = deg * .pi / 180, c = cos(r), s = sin(r) return Mat3(r0: Vec3(c, 0, s), r1: Vec3(0, 1, 0), r2: Vec3(-s, 0, c)) } static func rotZ(_ deg: Double) -> Mat3 { let r = deg * .pi / 180, c = cos(r), s = sin(r) return Mat3(r0: Vec3(c, -s, 0), r1: Vec3(s, c, 0), r2: Vec3(0, 0, 1)) } /// `self · v`. func apply(_ v: Vec3) -> Vec3 { Vec3(r0.dot(v), r1.dot(v), r2.dot(v)) } /// `self · b`. func times(_ b: Mat3) -> Mat3 { let c0 = Vec3(b.r0.x, b.r1.x, b.r2.x) let c1 = Vec3(b.r0.y, b.r1.y, b.r2.y) let c2 = Vec3(b.r0.z, b.r1.z, b.r2.z) return Mat3(r0: Vec3(r0.dot(c0), r0.dot(c1), r0.dot(c2)), r1: Vec3(r1.dot(c0), r1.dot(c1), r1.dot(c2)), r2: Vec3(r2.dot(c0), r2.dot(c1), r2.dot(c2))) } var transposed: Mat3 { Mat3(r0: Vec3(r0.x, r1.x, r2.x), r1: Vec3(r0.y, r1.y, r2.y), r2: Vec3(r0.z, r1.z, r2.z)) } } /// Left-to-right matrix product (`chain(a, b, c) == a·b·c`). private func chain(_ mats: Mat3...) -> Mat3 { var m = mats[0] for n in mats.dropFirst() { m = m.times(n) } return m } private func clampUnit(_ x: Double, _ lo: Double = -1, _ hi: Double = 1) -> Double { max(lo, min(hi, x)) } // MARK: - Frame model /// The four two-bone limbs, keyed by their motion-script names. enum FigureLimb: String, CaseIterable { case armR = "arm_r" case armL = "arm_l" case legR = "leg_r" case legL = "leg_l" var isArm: Bool { self == .armR || self == .armL } /// Side sign for a ball joint's abduction/rotation (+1 right, −1 left). var sigma: Double { (self == .armR || self == .legR) ? 1 : -1 } /// The key a planted extremity uses in a key frame's `pins`. var pinKey: String { switch self { case .armR: "hand_r" case .armL: "hand_l" case .legR: "foot_r" case .legL: "foot_l" } } } /// Shoulder / hip: forward flexion, abduction away from the midline, external rotation. struct BallJoint { var flexion, abduction, rotation: Double } /// One spine segment: forward curl, right side-bend, turn-right rotation. struct SpineSeg { var flexion, lateral, rotation: Double } /// Neck: forward flexion plus turn-right rotation. struct NeckJoint { var flexion, rotation: Double } /// Elbow / knee / ankle: a single flexion angle (knees hinge backward automatically). struct Hinge { var flexion: Double } /// A key frame expanded to full anatomical dicts (defaults filled in), the shape FK /// and tweening operate on. Pinned limbs' `Ball`/`Hinge` values are overwritten by the /// IK solution during `resolve`, so poses always interpolate in anatomical space. struct NormalizedFrame { var rootPos: CGPoint var yaw, pitch, roll: Double var spine: [SpineSeg] // two chained segments var neck: NeckJoint var head: Double // extra gaze pitch (flexion) var shoulderR, shoulderL, hipR, hipL: BallJoint var elbowR, elbowL, kneeR, kneeL, ankleR, ankleL: Hinge var pins: [String: CGPoint] var hold, tween: Double func ball(for limb: FigureLimb) -> BallJoint { switch limb { case .armR: shoulderR; case .armL: shoulderL; case .legR: hipR; case .legL: hipL } } func lower(for limb: FigureLimb) -> Hinge { switch limb { case .armR: elbowR; case .armL: elbowL; case .legR: kneeR; case .legL: kneeL } } func ankle(for limb: FigureLimb) -> Hinge { limb == .legR ? ankleR : ankleL } mutating func setUpper(_ b: BallJoint, for limb: FigureLimb) { switch limb { case .armR: shoulderR = b; case .armL: shoulderL = b; case .legR: hipR = b; case .legL: hipL = b } } mutating func setLower(_ h: Hinge, for limb: FigureLimb) { switch limb { case .armR: elbowR = h; case .armL: elbowL = h; case .legR: kneeR = h; case .legL: kneeL = h } } } /// Within a limb pair, the member nearer the camera (`near`) draws dark and in front; /// the far member draws light, behind, and nudged by the readability offset. enum Shade { case near, far } /// The two equipment inks: recessive `equipment` gray for scene shapes and cables /// behind the figure, darker `prop` gray for joint-attached items over the limbs. enum PropInk { case equipment, prop init(_ name: String?) { self = name == "prop" ? .prop : .equipment } } /// One resolved equipment drawing primitive, in canvas coordinates — the shape the /// prop layer reduces to for a single frame (mirroring the reference renderer's /// `resolve_props` output; kept 1:1, change them in lockstep). enum PropPrimitive { case line(points: [CGPoint], width: Double, ink: PropInk) /// A closed, filled, outlined polygon — an extruded scene slab. Degenerates to /// the plain line whenever the sweep collapses edge-on (the authored view). case poly(points: [CGPoint], width: Double, ink: PropInk) case circle(center: CGPoint, radius: Double, width: Double, fill: Bool, ink: PropInk) } /// A pose resolved to drawable canvas points (plus the depth-sorted draw order and /// per-limb shading the view needs). struct FigureGeometry { var headCenter: CGPoint var headRadius: Double /// Nil when the face points at (or away from) the camera — no nose tick. var noseStart, noseEnd: CGPoint? /// Quadratic Bézier through pelvis → mid → neck (control = 2·mid − (pelvis+neck)/2). var spineStart, spineControl, spineEnd: CGPoint /// Shoulder girdle and pelvis bars, drawn with the spine: left attach → center → /// right attach, far offsets included so the bars meet the drawn limbs exactly. var girdle: [CGPoint] var pelvisBar: [CGPoint] /// The exercise mat: a world-space quad on the ground plane, sized to the /// motion's footprint and rotating with the camera. Nil when no mat was given. var floor: [CGPoint]? /// Attach → elbow/knee → hand (arms: 3 points); hip → knee → ankle → toe (legs: 4). var limbs: [FigureLimb: [CGPoint]] /// Parts far-to-near, `"head"` always last (`"spine"`, `"arm_r"`, … then `"head"`). var order: [String] var shade: [FigureLimb: Shade] /// Per-vertex nearness in [0, 1] (1 = near/dark side), one value per joint of each /// limb chain, driving the shading gradient: each joint is toned by its own camera /// depth so the ink flows along a limb that reaches in depth. Unlike `shade` (which /// stays binary for geometry and draw order) this fades to a shared mid-tone as a /// pair crosses, so the ink never snaps. See `frameGeometry`. var nearness: [FigureLimb: [Double]] /// The equipment layer resolved for this frame: scene shapes and cables drawn /// behind the figure, joint-attached items drawn over the limbs (before the head). var propsBackground: [PropPrimitive] = [] var propsForeground: [PropPrimitive] = [] } /// FK output in view space (x right, y up, z toward the camera; origin at the root). struct FigurePose { var pelvis, mid, neckB, head: Vec3 var shoulderR, shoulderL, hipR, hipL: Vec3 var limbs: [FigureLimb: [Vec3]] var f2, fRoot: Mat3 // spine-top and root frames, for IK inversion var noseDir: Vec3 /// How side-on the view is: 1 in profile (lateral axis is pure depth), 0 face-on. var k: Double func attach(for limb: FigureLimb) -> Vec3 { switch limb { case .armR: shoulderR; case .armL: shoulderL; case .legR: hipR; case .legL: hipL } } } // MARK: - Solver enum MotionSolver { private static let depthBucket = 3.0 private static let pairs: [(FigureLimb, FigureLimb)] = [(.armR, .armL), (.legR, .legL)] /// Depth shading is a smooth per-vertex gradient: a joint reaches full near/far /// contrast once it sits this fraction of the shoulder/pelvis half-width in front of /// (or behind) its pair's central depth plane, so a straight limb in profile is still /// fully dark/light. See `frameGeometry` and `render.py`'s `SHADE_SPAN_FRAC` — the /// two must stay in sync. private static let shadeSpanFrac = 0.6 /// Fixed draw rank breaking depth-bucket ties (far parts first, `spine` mid-stack). private static let fixedRank: [String: Int] = ["arm_l": 0, "leg_l": 1, "spine": 2, "arm_r": 3, "leg_r": 4] /// Unit vector for a y-up angle in y-down canvas coordinates (props' fixed angles). static func direction(_ degrees: Double) -> CGVector { let r = degrees * .pi / 180 return CGVector(dx: cos(r), dy: -sin(r)) } // MARK: Normalize / tween /// Expand a key frame to a `NormalizedFrame` with defaults filled in. static func normalize(_ kf: MotionKeyFrame) -> NormalizedFrame { let spineValues = kf.spine ?? [.scalar(0), .scalar(0)] let spine = spineValues.map { SpineSeg(flexion: $0.flexion, lateral: $0.lateral, rotation: $0.rotation) } func ball(_ v: JointValue?) -> BallJoint { BallJoint(flexion: v?.flexion ?? 0, abduction: v?.abduction ?? 0, rotation: v?.rotation ?? 0) } func hinge(_ v: JointValue?) -> Hinge { Hinge(flexion: v?.flexion ?? 0) } var pins: [String: CGPoint] = [:] for (key, xy) in kf.pins ?? [:] where xy.count == 2 { pins[key] = CGPoint(x: xy[0], y: xy[1]) } return NormalizedFrame( rootPos: CGPoint(x: kf.root.pos[0], y: kf.root.pos[1]), yaw: kf.root.yaw ?? 0, pitch: kf.root.pitch ?? 0, roll: kf.root.roll ?? 0, spine: spine, neck: NeckJoint(flexion: kf.neck?.flexion ?? 0, rotation: kf.neck?.rotation ?? 0), head: kf.head?.flexion ?? 0, shoulderR: ball(kf.shoulderR), shoulderL: ball(kf.shoulderL), hipR: ball(kf.hipR), hipL: ball(kf.hipL), elbowR: hinge(kf.elbowR), elbowL: hinge(kf.elbowL), kneeR: hinge(kf.kneeR), kneeL: hinge(kf.kneeL), ankleR: hinge(kf.ankleR), ankleL: hinge(kf.ankleL), pins: pins, hold: kf.hold ?? 0.5, tween: kf.tween ?? 0.6) } /// Ease-in-out: 3t² − 2t³. static func ease(_ t: Double) -> Double { 3 * t * t - 2 * t * t * t } /// Interpolate two resolved frames — plain linear per degree of freedom, so limbs /// swing in natural anatomical arcs. A pin survives the tween only when planted in /// BOTH neighboring key frames (a one-sided pin releases naturally). static func lerpFrames(_ a: NormalizedFrame, _ b: NormalizedFrame, _ t: Double) -> NormalizedFrame { func n(_ x: Double, _ y: Double) -> Double { x + (y - x) * t } func lerpBall(_ p: BallJoint, _ q: BallJoint) -> BallJoint { BallJoint(flexion: n(p.flexion, q.flexion), abduction: n(p.abduction, q.abduction), rotation: n(p.rotation, q.rotation)) } func lerpHinge(_ p: Hinge, _ q: Hinge) -> Hinge { Hinge(flexion: n(p.flexion, q.flexion)) } var pins: [String: CGPoint] = [:] for (key, pa) in a.pins { guard let pb = b.pins[key] else { continue } pins[key] = CGPoint(x: n(pa.x, pb.x), y: n(pa.y, pb.y)) } return NormalizedFrame( rootPos: CGPoint(x: n(a.rootPos.x, b.rootPos.x), y: n(a.rootPos.y, b.rootPos.y)), yaw: n(a.yaw, b.yaw), pitch: n(a.pitch, b.pitch), roll: n(a.roll, b.roll), spine: zip(a.spine, b.spine).map { SpineSeg(flexion: n($0.flexion, $1.flexion), lateral: n($0.lateral, $1.lateral), rotation: n($0.rotation, $1.rotation)) }, neck: NeckJoint(flexion: n(a.neck.flexion, b.neck.flexion), rotation: n(a.neck.rotation, b.neck.rotation)), head: n(a.head, b.head), shoulderR: lerpBall(a.shoulderR, b.shoulderR), shoulderL: lerpBall(a.shoulderL, b.shoulderL), hipR: lerpBall(a.hipR, b.hipR), hipL: lerpBall(a.hipL, b.hipL), elbowR: lerpHinge(a.elbowR, b.elbowR), elbowL: lerpHinge(a.elbowL, b.elbowL), kneeR: lerpHinge(a.kneeR, b.kneeR), kneeL: lerpHinge(a.kneeL, b.kneeL), ankleR: lerpHinge(a.ankleR, b.ankleR), ankleL: lerpHinge(a.ankleL, b.ankleL), pins: pins, hold: a.hold, tween: a.tween) } // MARK: Forward kinematics /// Local rotation of a ball joint (shoulder/hip) for side sign `sigma`. private static func ballMatrix(_ j: BallJoint, _ sigma: Double) -> Mat3 { chain(Mat3.rotZ(j.flexion), Mat3.rotX(-sigma * j.abduction), Mat3.rotY(-sigma * j.rotation)) } /// FK one limb from its resolved attach point (arm: [shoulder, elbow, hand]; /// leg: [hip, knee, ankle, toe]). private static func fkLimb(_ limb: FigureLimb, attach: Vec3, upper: BallJoint, lower: Hinge, ankle: Hinge, prof: SkeletonProfile, parent: Mat3) -> [Vec3] { let fu = parent.times(ballMatrix(upper, limb.sigma)) if limb.isArm { let elbow = attach + fu.apply(Vec3(0, -prof.upperArm, 0)) let fl = fu.times(Mat3.rotZ(lower.flexion)) let hand = elbow + fl.apply(Vec3(0, -prof.foreArm, 0)) return [attach, elbow, hand] } let knee = attach + fu.apply(Vec3(0, -prof.thigh, 0)) let fl = fu.times(Mat3.rotZ(-lower.flexion)) let ankleJoint = knee + fl.apply(Vec3(0, -prof.shin, 0)) let toe = ankleJoint + fl.times(Mat3.rotZ(ankle.flexion)).apply(Vec3(prof.foot, 0, 0)) return [attach, knee, ankleJoint, toe] } /// FK a normalized frame into view space through the camera yaw; `camPitch` /// tilts the viewpoint down from slightly above (the scene rotates about the root). static func pose(_ nf: NormalizedFrame, prof: SkeletonProfile, cam: Double, camPitch: Double = 0) -> FigurePose { let fRoot = chain(Mat3.rotX(camPitch), Mat3.rotY(-cam), Mat3.rotY(nf.yaw), Mat3.rotZ(-nf.pitch), Mat3.rotX(nf.roll)) let origin = Vec3(0, 0, 0) let s1 = nf.spine[0], s2 = nf.spine[1] let f1 = chain(fRoot, Mat3.rotZ(-s1.flexion), Mat3.rotX(s1.lateral), Mat3.rotY(-s1.rotation)) let mid = origin + f1.apply(Vec3(0, prof.spine1, 0)) let f2 = chain(f1, Mat3.rotZ(-s2.flexion), Mat3.rotX(s2.lateral), Mat3.rotY(-s2.rotation)) let neckB = mid + f2.apply(Vec3(0, prof.spine2, 0)) let fn = chain(f2, Mat3.rotZ(-nf.neck.flexion), Mat3.rotY(-nf.neck.rotation)) let head = neckB + fn.apply(Vec3(0, prof.neck, 0)) let noseDir = fn.times(Mat3.rotZ(-nf.head)).apply(Vec3(1, 0, 0)) var p = FigurePose( pelvis: origin, mid: mid, neckB: neckB, head: head, shoulderR: neckB + f2.apply(Vec3(0, 0, prof.shoulderHalf)), shoulderL: neckB + f2.apply(Vec3(0, 0, -prof.shoulderHalf)), hipR: origin + fRoot.apply(Vec3(0, 0, prof.hipHalf)), hipL: origin + fRoot.apply(Vec3(0, 0, -prof.hipHalf)), limbs: [:], f2: f2, fRoot: fRoot, noseDir: noseDir, k: abs(fRoot.apply(Vec3(0, 0, 1)).z)) for limb in FigureLimb.allCases { let parent = limb.isArm ? f2 : fRoot p.limbs[limb] = fkLimb(limb, attach: p.attach(for: limb), upper: nf.ball(for: limb), lower: nf.lower(for: limb), ankle: nf.ankle(for: limb), prof: prof, parent: parent) } return p } // MARK: Inverse kinematics /// Analytic two-bone IK in 3D: reach from `attach` toward `target` in the plane /// picked by the authored (FK) mid joint, then convert back to anatomical angles. /// /// The two bend solutions are tried in preference order (arm: nearest the FK guess; /// leg: the authored/anterior side first). The write-back keeps only a branch whose /// recovered angles forward-kinematic back to the solved end — a branch the angle /// representation cannot express (acos loses the bend sign, a near-sagittal limb /// loses its axial rotation) would silently move the pinned extremity, so it is /// rejected in favor of the flip. private static func solveLimb(_ limb: FigureLimb, attach: Vec3, target: Vec3, guessMid: Vec3, lengths: (Double, Double), parent: Mat3) -> (BallJoint, Hinge) { let (a, b) = lengths let toTarget = target - attach let d = clampUnit(toTarget.length, abs(a - b) + 0.5, a + b - 0.01) let dirTarget = toTarget.length > 1e-9 ? toTarget.normalized : Vec3(0, -1, 0) // Guess reliability: how far the authored mid sits off the attach→target line. // Below kneeStraightFrac the two bend solutions straddle the line and the guess // (near-parallel) can pick neither a plane nor a side. let gm = guessMid - attach let gmPerp = gm - dirTarget.scaled(gm.dot(dirTarget)) let reliable = gmPerp.length >= Self.kneeStraightFrac * a var normal: Vec3 if reliable { normal = dirTarget.cross(gm) } else { // Degenerate guess: bow the joint along the anatomical anterior axis. let anterior = parent.apply(Vec3(1, 0, 0)) let ap = anterior - dirTarget.scaled(anterior.dot(dirTarget)) normal = dirTarget.cross(ap) if normal.length < 1e-6 { // target along the anterior axis: any plane works normal = dirTarget.cross(Vec3(0, 0, 1)) if normal.length < 1e-6 { normal = dirTarget.cross(Vec3(0, 1, 0)) } } } normal = normal.normalized let perp = normal.cross(dirTarget) let along = (a * a + d * d - b * b) / (2 * d) let h = max(a * a - along * along, 0).squareRoot() let base = attach + dirTarget.scaled(along) let signs: (Double, Double) if limb.isArm { // Prefer the mid nearest the FK guess; the flip is the fallback. let d1 = (base + perp.scaled(h) - guessMid).length let d2 = (base + perp.scaled(-h) - guessMid).length signs = d1 <= d2 ? (1.0, -1.0) : (-1.0, 1.0) } else { // A knee bends one way only. Honor the authored side when it is reliable, // else bend anatomically forward (anterior); the flip is the fallback. let ref = reliable ? gmPerp : parent.apply(Vec3(1, 0, 0)) let first = perp.dot(ref) >= 0 ? 1.0 : -1.0 signs = (first, -first) } var fallback: (BallJoint, Hinge)? for sign in [signs.0, signs.1] { let mid = base + perp.scaled(sign * h) let end = mid + (target - mid).normalized.scaled(b) let (upper, lower) = invertLimb(limb, attach: attach, mid: mid, end: end, parent: parent) // Re-pose the recovered angles (fkLimb's math, upper→lower) and accept this // branch only if the pinned end lands back on the solved point in the screen // plane — that is where the pin lives. A branch the write-back mirrors leaves // the pin in the drawing and is rejected; one off only in depth (rotation // gated at rotMinLateral) still holds its pin. Solving is unpitched, so // view-space (x, y) is the canvas projection (the anchor offset cancels in // the difference). let fu = parent.times(ballMatrix(upper, limb.sigma)) let fl = limb.isArm ? fu.times(Mat3.rotZ(lower.flexion)) : fu.times(Mat3.rotZ(-lower.flexion)) let reMid = attach + fu.apply(Vec3(0, -a, 0)) let reEnd = reMid + fl.apply(Vec3(0, -b, 0)) if fallback == nil { fallback = (upper, lower) } if hypot(reEnd.x - end.x, reEnd.y - end.y) <= Self.branchReproTol { return (upper, lower) } } return fallback! } /// Axial rotation of a two-bone limb is recoverable only from the lower bone's /// lateral tip; below this magnitude the limb is effectively in-plane and its /// rotation is left at 0 (see `invertLimb`). private static let rotMinLateral = 0.08 /// When an authored mid joint (knee or elbow) sits within this fraction of the /// upper bone length off the attach→target line (~sin 8.6°), the FK guess is /// unreliable: the two bend solutions straddle the line and the near-parallel guess /// can pick neither a side nor a plane. The bend plane is then taken from the /// anatomical anterior axis (knees forward, elbow flexion carries the forearm /// anterior) instead of the guess, for both arms and legs (see `solveLimb`). private static let kneeStraightFrac = 0.15 /// A recovered IK branch is kept only if its re-posed extremity lands within this /// many canvas units of the solved point, measured in the screen plane where the /// pin lives (see `solveLimb`). A branch the anatomical write-back cannot /// represent — the acos bend-sign loss or a rotation gated at `rotMinLateral` — /// mirrors the lower bone and misses by a wide margin; the small residual a /// correctly-authored, on-pin branch leaves when its rotation grazes the gating /// boundary is only a couple of units, so this margin flips genuine mirrors while /// leaving well-authored branches on their FK-guess side (a tighter margin would /// flip them too, shifting sound geometry). private static let branchReproTol = 4.0 /// Recover anatomical angles from limb joint positions (the inverse of `fkLimb`, /// ignoring the foot). Assumes |abduction| < 90; the leg's rotation sign flips /// because knees hinge backward. private static func invertLimb(_ limb: FigureLimb, attach: Vec3, mid: Vec3, end: Vec3, parent: Mat3) -> (BallJoint, Hinge) { let sigma = limb.sigma let parentT = parent.transposed let u = parentT.apply(mid - attach).normalized let abduction = asin(clampUnit(sigma * u.z)) * 180 / .pi let flexion = atan2(u.x, -u.y) * 180 / .pi let peel = chain(Mat3.rotZ(flexion), Mat3.rotX(-sigma * abduction)).transposed let w = peel.apply(parentT.apply(end - mid)).normalized let bend = acos(clampUnit(-w.y)) * 180 / .pi // Axial rotation is observable only through the lower bone's lateral tip // (w.z); a near-sagittal limb carries no recoverable rotation. `bend` // alone is too weak a guard: a limb can straighten through this degeneracy // while still visibly bent, and atan2 then snaps to ±180 on the sign of a // near-zero anterior component — twisting the limb a half-turn and // flipping a pinned hand/foot backward. let twist = bend > 0.5 && abs(w.z) > Self.rotMinLateral let rotation: Double if limb.isArm { rotation = twist ? sigma * atan2(w.z, w.x) * 180 / .pi : 0 } else { rotation = twist ? sigma * atan2(-w.z, -w.x) * 180 / .pi : 0 } return (BallJoint(flexion: flexion, abduction: abduction, rotation: rotation), Hinge(flexion: bend)) } private static func viewFromCanvas(_ pt: CGPoint, anchor: CGPoint, depth: Double) -> Vec3 { Vec3(pt.x - anchor.x, anchor.y - pt.y, depth) } /// Pose a frame and apply pins: for each pinned limb, solve IK against the canvas /// target (at the limb's FK depth), write the solved angles back, and re-pose. private static func resolve(_ nf: NormalizedFrame, prof: SkeletonProfile, cam: Double) -> (NormalizedFrame, FigurePose) { var frame = nf var p = pose(frame, prof: prof, cam: cam) let anchor = frame.rootPos var solved = false for limb in FigureLimb.allCases { guard let pin = frame.pins[limb.pinKey], let chainPts = p.limbs[limb] else { continue } let attach = chainPts[0] let target = viewFromCanvas(pin, anchor: anchor, depth: chainPts[2].z) let lengths: (Double, Double) = limb.isArm ? (prof.upperArm, prof.foreArm) : (prof.thigh, prof.shin) let parent = limb.isArm ? p.f2 : p.fRoot var upper: BallJoint let lower: Hinge (upper, lower) = solveLimb(limb, attach: attach, target: target, guessMid: chainPts[1], lengths: lengths, parent: parent) // invertLimb returns principal-value angles (atan2/asin); unwrap the // flexion/rotation toward the pose being replaced so key frames lerp the // short way and tween ticks stay continuous. Abduction is asin-ranged and // cannot wrap. let prev = frame.ball(for: limb) upper.flexion += 360 * ((prev.flexion - upper.flexion) / 360).rounded() upper.rotation += 360 * ((prev.rotation - upper.rotation) / 360).rounded() frame.setUpper(upper, for: limb) frame.setLower(lower, for: limb) solved = true } if solved { p = pose(frame, prof: prof, cam: cam) } return (frame, p) } // MARK: Frame geometry /// Python's `round(x)` uses banker's rounding; match it so depth buckets agree. private static func bucket(_ depth: Double) -> Int { Int((depth / depthBucket).rounded(.toNearestOrEven)) } private static func chainDepth(_ pts: [Vec3]) -> Double { pts.reduce(0) { $0 + $1.z } / Double(pts.count) } /// Resolve a normalized frame into drawable geometry. Returns the frame with /// IK-resolved angles (for tweening) plus the canvas geometry: near/far shading /// (the near pair member draws dark and in front; canvas-right wins depth-bucket /// ties in face-on views), the readability offset applied to the far member of /// each pair and subtracted from a far pin's target before IK (authored pins /// restored afterward), the depth-sorted draw order (head last), the spine curve, /// and the foreshortened gaze nose tick (hidden when the face points at/away from /// the camera). /// The standard slightly-elevated viewpoint: the camera pitches down a touch so /// the floor reads as a plane. Kept 1:1 with the reference renderer's CAMERA_PITCH. static let defaultPitch: Double = 10 /// Exercise-mat half-depth (into the screen) and the ground line's canvas y, /// shared with the reference renderer. static let floorHalfDepth: Double = 30 static let groundY: Double = 152 static func frameGeometry(_ nf: NormalizedFrame, prof: SkeletonProfile, cam: Double, pitch: Double = MotionSolver.defaultPitch, mat: (lo: Double, hi: Double)? = nil) -> (NormalizedFrame, FigureGeometry) { let p0 = pose(nf, prof: prof, cam: cam, camPitch: pitch) var shade: [FigureLimb: Shade] = [:] for (right, left) in pairs { guard let rp = p0.limbs[right], let lp = p0.limbs[left] else { continue } let dr = chainDepth(rp), dl = chainDepth(lp) let near: FigureLimb if bucket(dr) == bucket(dl) { near = rp[0].x >= lp[0].x ? right : left // view x == canvas offset from anchor } else { near = dr > dl ? right : left } shade[right] = near == right ? .near : .far shade[left] = near == left ? .near : .far } let fo = prof.farOffset let off = CGPoint(x: (fo.first ?? 6) * p0.k, y: (fo.count > 1 ? fo[1] : 2) * p0.k) var work = nf for limb in FigureLimb.allCases where shade[limb] == .far { if let pin = work.pins[limb.pinKey] { work.pins[limb.pinKey] = CGPoint(x: pin.x - off.x, y: pin.y - off.y) } } // Pins are canvas targets in the authored, unpitched view: solve IK flat, // then tilt the *posed* body - the camera elevation is pure presentation, // so contacts straddle the floor plane instead of pins going out of reach. var (resolved, p) = resolve(work, prof: prof, cam: cam) resolved.pins = nf.pins // keep authored pins; only angles resolved if pitch != 0 { p = pose(resolved, prof: prof, cam: cam, camPitch: pitch) } let anchor = nf.rootPos func scr(_ v: Vec3, _ limbOffset: CGPoint = .zero) -> CGPoint { CGPoint(x: anchor.x + v.x + limbOffset.x, y: anchor.y - v.y + limbOffset.y) } let pelvis = scr(p.pelvis), mid = scr(p.mid), neckB = scr(p.neckB) let control = CGPoint(x: 2 * mid.x - (pelvis.x + neckB.x) / 2, y: 2 * mid.y - (pelvis.y + neckB.y) / 2) var limbs: [FigureLimb: [CGPoint]] = [:] var depths: [String: Double] = ["spine": chainDepth([p.pelvis, p.mid, p.neckB])] for limb in FigureLimb.allCases { guard let pts = p.limbs[limb] else { continue } let limbOffset = shade[limb] == .far ? off : .zero limbs[limb] = pts.map { scr($0, limbOffset) } depths[limb.rawValue] = chainDepth(pts) } // Per-vertex depth shading: tone each drawn joint by its own camera depth // relative to its pair's central plane, normalized by the profile half-width // (× shadeSpanFrac), so the ink flows along a limb that reaches toward or away // from the camera instead of the bone snapping to one flat tone. The per-vertex // average reproduces the old flat per-limb tone. Kept 1:1 with render.py. var nearness: [FigureLimb: [Double]] = [:] for (right, left) in pairs { guard let rp = p.limbs[right], let lp = p.limbs[left] else { continue } let ref = (chainDepth(rp) + chainDepth(lp)) / 2 let half = (right == .armR ? prof.shoulderHalf : prof.hipHalf) * p.k let span = half * shadeSpanFrac func tone(_ z: Double) -> Double { span != 0 ? 0.5 + 0.5 * max(-1, min(1, (z - ref) / span)) : 0.5 } nearness[right] = rp.map { tone($0.z) } nearness[left] = lp.map { tone($0.z) } } let head = scr(p.head) var noseStart: CGPoint?, noseEnd: CGPoint? let nose = p.noseDir let mag = (nose.x * nose.x + nose.y * nose.y).squareRoot() if mag > 0.3 { let ux = nose.x / mag, uy = -nose.y / mag, r = prof.headR noseStart = CGPoint(x: head.x + ux * r, y: head.y + uy * r) noseEnd = CGPoint(x: head.x + ux * (r + 7 * mag), y: head.y + uy * (r + 7 * mag)) } let order = depths.keys.sorted { lhs, rhs in let bl = bucket(depths[lhs]!), br = bucket(depths[rhs]!) if bl != br { return bl < br } return (fixedRank[lhs] ?? 0) < (fixedRank[rhs] ?? 0) } + ["head"] func attachPoint(_ v: Vec3, _ limb: FigureLimb) -> CGPoint { scr(v, shade[limb] == .far ? off : .zero) } // The exercise mat: a ground-plane quad rotating with the camera about the // figure's vertical axis (`mat` = footprint bounds in the authored view). var floor: [CGPoint]? if let mat { let yg = -(groundY + 4 - anchor.y) let rc = Mat3.rotX(pitch).times(Mat3.rotY(-cam)) floor = [(mat.lo, floorHalfDepth), (mat.hi, floorHalfDepth), (mat.hi, -floorHalfDepth), (mat.lo, -floorHalfDepth)].map { dx, dz in scr(rc.apply(Vec3(dx - anchor.x, yg, dz))) } } let geo = FigureGeometry( headCenter: head, headRadius: prof.headR, noseStart: noseStart, noseEnd: noseEnd, spineStart: pelvis, spineControl: control, spineEnd: neckB, girdle: [attachPoint(p.shoulderL, .armL), neckB, attachPoint(p.shoulderR, .armR)], pelvisBar: [attachPoint(p.hipL, .legL), pelvis, attachPoint(p.hipR, .legR)], floor: floor, limbs: limbs, order: order, shade: shade, nearness: nearness) return (resolved, geo) } // MARK: Props /// Prop joint refs → (limb, chain index): extremities are index 2, mid joints /// (elbows/knees) index 1, so equipment can ride either joint. Kept 1:1 with /// the reference renderer's `JOINT_LIMB` (legs end in a foot bone, so a foot /// prop rides the ankle at index 2). private static let propJoints: [String: (limb: FigureLimb, index: Int)] = [ "hand_r": (.armR, 2), "elbow_r": (.armR, 1), "hand_l": (.armL, 2), "elbow_l": (.armL, 1), "foot_r": (.legR, 2), "knee_r": (.legR, 1), "foot_l": (.legL, 2), "knee_l": (.legL, 1), ] /// The joint point a prop follows (plus the unit direction of the bone ending /// at the first joint, for perpendicular items). Nil when any referenced limb /// isn't drawn this frame. private static func jointAnchor(_ geo: FigureGeometry, _ ref: PropJointRef) -> (point: CGPoint, direction: CGVector)? { var points: [CGPoint] = [] var direction: CGVector? for name in ref.names { guard let joint = propJoints[name], let chain = geo.limbs[joint.limb], chain.count > joint.index else { return nil } let a = chain[joint.index - 1] let b = chain[joint.index] points.append(b) if direction == nil { let d = max(hypot(b.x - a.x, b.y - a.y), 1) direction = CGVector(dx: (b.x - a.x) / d, dy: (b.y - a.y) / d) } } guard let direction, !points.isEmpty else { return nil } let center = CGPoint(x: points.reduce(0) { $0 + $1.x } / CGFloat(points.count), y: points.reduce(0) { $0 + $1.y } / CGFloat(points.count)) return (center, direction) } /// View-space rotation carrying authored-view prop geometry to the camera /// `yawOffset` degrees past the authored yaw: a rotation about the world-vertical /// axis through the root anchor, conjugated by the camera elevation. Nil (the /// identity) at offset 0, so the authored view stays bit-exact. static func propRotation(pitch: Double, yawOffset: Double) -> Mat3? { guard yawOffset != 0 else { return nil } return Mat3.rotX(pitch).times(Mat3.rotY(-yawOffset)).times(Mat3.rotX(-pitch)) } /// Props → drawable primitives for one frame: (background, foreground). /// /// `geo` is the frame's drawn (possibly orbit-rotated) geometry, `authored` the /// same frame at the authored camera (pass `geo` itself when not orbiting), /// `anchor` the frame's root canvas anchor, and `rotation` the `propRotation` /// between them. Joint positions come from `geo`; everything authored — scene /// points, cable anchors, bar angles, pad perpendiculars, roller offsets — /// resolves against `authored` and rotates. `pitch` is the camera elevation, /// needed by `axis` props whose world-space direction projects through it /// (like the floor quad). Kept 1:1 with the reference renderer's /// `resolve_props` — change them in lockstep. static func resolveProps(_ props: [MotionProp], geo: FigureGeometry, authored: FigureGeometry, anchor: CGPoint, rotation: Mat3?, pitch: Double = MotionSolver.defaultPitch) -> (bg: [PropPrimitive], fg: [PropPrimitive]) { /// Authored canvas point (+ depth toward the camera) → drawn canvas. func place(_ x: Double, _ y: Double, _ z: Double) -> CGPoint { guard let rotation else { return CGPoint(x: x, y: y) } let v = rotation.apply(Vec3(x - anchor.x, anchor.y - y, z)) return CGPoint(x: anchor.x + v.x, y: anchor.y - v.y) } /// Authored canvas-space direction/offset → drawn canvas (y-down). func swing(_ vec: CGVector) -> CGVector { guard let rotation else { return vec } let v = rotation.apply(Vec3(vec.dx, -vec.dy, 0)) return CGVector(dx: v.x, dy: -v.y) } var bg: [PropPrimitive] = [] var fg: [PropPrimitive] = [] for prop in props { switch prop.type { case "scene": for shape in prop.shapes ?? [] { let ink = PropInk(shape.color) let z = shape.z ?? 0 switch shape.kind { case "line": guard let pts = shape.pts else { continue } let lifted = pts.compactMap { pt -> (x: Double, y: Double, z: Double)? in pt.count >= 2 ? (pt[0], pt[1], z + (pt.count > 2 ? pt[2] : 0)) : nil } if let depth = shape.depth, depth != 0 { // An extruded slab: the polyline swept through ±depth, // filled and outlined so it degenerates to the plain // line whenever the sweep collapses edge-on. let quad = lifted.map { place($0.x, $0.y, $0.z + depth) } + lifted.reversed().map { place($0.x, $0.y, $0.z - depth) } bg.append(.poly(points: quad, width: shape.w ?? 4, ink: ink)) } else { bg.append(.line(points: lifted.map { place($0.x, $0.y, $0.z) }, width: shape.w ?? 4, ink: ink)) } case "circle": guard let c = shape.c, c.count == 2, let r = shape.r else { continue } bg.append(.circle(center: place(c[0], c[1], z), radius: r, width: shape.w ?? 3, fill: shape.fill ?? false, ink: ink)) default: break } } case "cable": guard let from = prop.from, from.count >= 2, let to = prop.to, let end = jointAnchor(geo, to) else { continue } let start = place(from[0], from[1], from.count > 2 ? from[2] : 0) bg.append(.line(points: [start, end.point], width: prop.w ?? 2, ink: .equipment)) case "roller": // A machine roller pad seen end-on: a disc riding the limb's lower // bone near the joint, on the `side` (+1/−1) of the bone it presses. // The offset resolves along the authored-view bone, then rotates. guard let at = prop.at, let now = jointAnchor(geo, at), let auth = jointAnchor(authored, at) else { continue } let r = prop.r ?? 5 let back = prop.back ?? 0 let side = prop.side ?? 1 let d = auth.direction let px = d.dy * side, py = -d.dx * side let off = swing(CGVector(dx: -d.dx * back + px * (r + 3), dy: -d.dy * back + py * (r + 3))) fg.append(.circle(center: CGPoint(x: now.point.x + off.dx, y: now.point.y + off.dy), radius: r, width: 3, fill: true, ink: .prop)) case "bar", "dumbbell", "pad": let defaults: (halfLen: Double, width: Double, plateR: Double) = switch prop.type { case "bar": (24, 4, 0) case "dumbbell": (7, 3, 4.5) default: (8, 7, 0) } guard let at = prop.at, let now = jointAnchor(geo, at), let auth = jointAnchor(authored, at) else { continue } let u: CGVector if prop.axis == "z" { // A cross-body rod (barbell, pull-up bar): its true 3D axis // is the world left-right axis, projected through the camera // elevation like the floor quad — a small vertical sliver // end-on in a profile view (the plates read nearly // concentric, seen from slightly above), full span face-on, // swinging with the figure in between, symmetric at 0 and 180. let pr = pitch * .pi / 180 let axis = Vec3(0, -sin(pr), cos(pr)) let v = rotation?.apply(axis) ?? axis u = CGVector(dx: v.x, dy: -v.y) } else if prop.type == "bar" || prop.angle != nil { u = swing(direction(prop.angle ?? 0)) // fixed authored-view angle, foreshortens under orbit } else { u = swing(CGVector(dx: -auth.direction.dy, dy: auth.direction.dx)) } let h = prop.halfLen ?? defaults.halfLen let a = CGPoint(x: now.point.x - u.dx * h, y: now.point.y - u.dy * h) let b = CGPoint(x: now.point.x + u.dx * h, y: now.point.y + u.dy * h) fg.append(.line(points: [a, b], width: prop.w ?? defaults.width, ink: .prop)) let plateR = prop.plateR ?? defaults.plateR if plateR > 0 { for end in [a, b] { fg.append(.circle(center: end, radius: plateR, width: 3, fill: true, ink: .prop)) } } default: break } } return (bg, fg) } } /// The full looping animation for one motion: key frames resolved to anatomical angles, /// then continuous-time sampling — hold at each key frame, then an eased anatomical-space /// tween to the next; the last frame tweens back to the first. struct MotionTimeline { let resolved: [NormalizedFrame] let profile: SkeletonProfile let cam: Double let pitch: Double let duration: Double /// The motion's footprint in the authored view (min/max screen x of every figure /// point across the key frames, padded) — the exercise mat spans it. let mat: (lo: Double, hi: Double) init?(motion: ExerciseMotion, profile: SkeletonProfile) { let cam = motion.camera?.yaw ?? 0 let pitch = motion.camera?.pitch ?? MotionSolver.defaultPitch let norms = motion.frames.map { MotionSolver.normalize($0) } guard !norms.isEmpty else { return nil } var resolved: [NormalizedFrame] = [] var lo = Double.infinity, hi = -Double.infinity for norm in norms { let (frame, geo) = MotionSolver.frameGeometry(norm, prof: profile, cam: cam, pitch: pitch) resolved.append(frame) var xs: [Double] = [Double(geo.headCenter.x) - geo.headRadius, Double(geo.headCenter.x) + geo.headRadius, Double(geo.spineStart.x), Double(geo.spineControl.x), Double(geo.spineEnd.x)] xs += (geo.girdle + geo.pelvisBar).map { Double($0.x) } xs += geo.limbs.values.flatMap { $0.map { Double($0.x) } } lo = min(lo, xs.min() ?? lo) hi = max(hi, xs.max() ?? hi) } let duration = resolved.reduce(0) { $0 + $1.hold + $1.tween } guard duration > 0 else { return nil } self.resolved = resolved self.profile = profile self.cam = cam self.pitch = pitch self.duration = duration self.mat = (lo - 12, hi + 12) } /// The resolved frame (or eased tween) at wall-clock `time`, looping every /// `duration` seconds. func frame(at time: Double) -> NormalizedFrame { var t = time.truncatingRemainder(dividingBy: duration) if t < 0 { t += duration } for (i, f) in resolved.enumerated() { if t < f.hold { return f } t -= f.hold if t < f.tween { let next = resolved[(i + 1) % resolved.count] return MotionSolver.lerpFrames(f, next, MotionSolver.ease(t / f.tween)) } t -= f.tween } return resolved[0] } /// The drawable geometry at wall-clock `time`. `yawOffset` turns the camera past /// the exercise's authored yaw — the slow-orbit presentation. Pins are canvas /// targets in the *authored* view, so the pose resolves there first and the posed /// body is then rotated; re-pinning in a rotated view would glue hands to screen /// points that no longer correspond to anything (arms visibly "stuck" mid-orbit). /// `props` are resolved into the geometry's primitive layers the same way: joint /// anchors follow the rotated figure, while authored constructs (scene points, /// cable anchors, bar angles, pad perpendiculars) resolve in the authored view /// and rotate about the root anchor's vertical axis. func geometry(at time: Double, yawOffset: Double = 0, props: [MotionProp] = []) -> FigureGeometry { let frame = frame(at: time) var geo: FigureGeometry let authored: FigureGeometry if yawOffset == 0 { geo = MotionSolver.frameGeometry(frame, prof: profile, cam: cam, pitch: pitch, mat: mat).1 authored = geo } else { let (posedFrame, authoredGeo) = MotionSolver.frameGeometry(frame, prof: profile, cam: cam, pitch: pitch) authored = authoredGeo var posed = posedFrame posed.pins = [:] geo = MotionSolver.frameGeometry(posed, prof: profile, cam: cam + yawOffset, pitch: pitch, mat: mat).1 } guard !props.isEmpty else { return geo } let rotation = MotionSolver.propRotation(pitch: pitch, yawOffset: yawOffset) (geo.propsBackground, geo.propsForeground) = MotionSolver.resolveProps( props, geo: geo, authored: authored, anchor: frame.rootPos, rotation: rotation, pitch: pitch) return geo } }