Add the exercise reference library, animated exercise figures, and exercise categories
Exercise Library/ holds per-exercise reference docs (setup, cues, mistakes, progressions) with SVG visuals and a Python-rendered motion pipeline; Workouts/ExerciseFigure renders the bundled *.motion.json rigs as animated stick figures on the exercise screen. Exercises gain a warm-up/main-circuit category, timed exercises display hold time via planSummary, and a completed exercise reopens to a check screen instead of its timers.
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
//
|
||||
// MotionSolver.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
/// Swift port of the Exercise Library's reference solver (`Exercise Library/render.py`):
|
||||
/// forward kinematics, analytic 2-bone IK, and angle-space tweening with shortest-path
|
||||
/// lerp and ease-in-out. The math is kept 1:1 with the Python so both renderers produce
|
||||
/// the same figure from the same data — change them in lockstep.
|
||||
///
|
||||
/// Angles are absolute world angles in degrees, y-up (0 = right, 90 = up, −90 = down);
|
||||
/// points are y-down 320×180 canvas coordinates, so dir(θ) = (cos θ, −sin θ).
|
||||
|
||||
/// 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 isLeft: Bool { self == .armL || self == .legL }
|
||||
|
||||
/// 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"
|
||||
}
|
||||
}
|
||||
|
||||
/// `[upper, lower]` bone lengths for this limb.
|
||||
func boneLengths(_ body: ExerciseBodyProfile) -> [Double] {
|
||||
switch self {
|
||||
case .armR, .armL: [body.upperArm, body.foreArm]
|
||||
case .legR, .legL: [body.thigh, body.shin]
|
||||
}
|
||||
}
|
||||
|
||||
/// The authored `[upper, lower]` angles for this limb, if present in the frame.
|
||||
func angles(in frame: MotionKeyFrame) -> [Double]? {
|
||||
switch self {
|
||||
case .armR: frame.armR
|
||||
case .armL: frame.armL
|
||||
case .legR: frame.legR
|
||||
case .legL: frame.legL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A key frame resolved to pure angles (pinned limbs replaced by their IK solution),
|
||||
/// so poses interpolate cleanly in angle space.
|
||||
struct FigurePose {
|
||||
var root: CGPoint
|
||||
var spine: [Double]
|
||||
var neck: Double
|
||||
var gaze: Double
|
||||
var limbs: [FigureLimb: [Double]]
|
||||
var pins: [String: CGPoint]
|
||||
var hold: Double
|
||||
var tween: Double
|
||||
}
|
||||
|
||||
/// Trunk FK: pelvis, spine mid, neck joint, and the offset left-limb attachments.
|
||||
struct FigureAttachments {
|
||||
let pelvis: CGPoint
|
||||
let mid: CGPoint
|
||||
let neck: CGPoint
|
||||
let shoulderL: CGPoint
|
||||
let hipL: CGPoint
|
||||
|
||||
func point(for limb: FigureLimb) -> CGPoint {
|
||||
switch limb {
|
||||
case .armR: neck
|
||||
case .armL: shoulderL
|
||||
case .legR: pelvis
|
||||
case .legL: hipL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pose resolved to drawable points.
|
||||
struct FigureGeometry {
|
||||
var headCenter: CGPoint
|
||||
var headRadius: Double
|
||||
var noseStart: CGPoint
|
||||
var noseEnd: CGPoint
|
||||
/// Quadratic Bézier through pelvis → mid → neck (control = 2·mid − (pelvis+neck)/2).
|
||||
var spineStart: CGPoint
|
||||
var spineControl: CGPoint
|
||||
var spineEnd: CGPoint
|
||||
/// Attachment → elbow/knee → extremity, per drawn limb.
|
||||
var limbs: [FigureLimb: [CGPoint]]
|
||||
}
|
||||
|
||||
enum MotionSolver {
|
||||
/// Unit vector for a y-up angle, in y-down canvas coordinates.
|
||||
static func direction(_ degrees: Double) -> CGVector {
|
||||
let r = degrees * .pi / 180
|
||||
return CGVector(dx: cos(r), dy: -sin(r))
|
||||
}
|
||||
|
||||
/// Chain FK: `[start, joint1, joint2, …]` for per-bone absolute angles.
|
||||
static func walk(from start: CGPoint, angles: [Double], lengths: [Double]) -> [CGPoint] {
|
||||
var points = [start]
|
||||
var current = start
|
||||
for (angle, length) in zip(angles, lengths) {
|
||||
let d = direction(angle)
|
||||
current = CGPoint(x: current.x + d.dx * length, y: current.y + d.dy * length)
|
||||
points.append(current)
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/// Y-up world angle (degrees) of the segment a→b.
|
||||
static func angle(from a: CGPoint, to b: CGPoint) -> Double {
|
||||
atan2(-(b.y - a.y), b.x - a.x) * 180 / .pi
|
||||
}
|
||||
|
||||
/// Analytic 2-bone IK: `[upper, lower]` angles reaching from `start` toward
|
||||
/// `target`, choosing the elbow/knee solution nearest the authored guess.
|
||||
static func ik2(start: CGPoint, target: CGPoint, upper a: Double, lower b: Double, guess: [Double]) -> [Double] {
|
||||
let dx = target.x - start.x
|
||||
let dyUp = -(target.y - start.y)
|
||||
// Clamp the reach inside the chain's annulus so acos stays defined.
|
||||
let d = max(abs(a - b) + 0.5, min(a + b - 0.01, hypot(dx, dyUp)))
|
||||
let base = atan2(dyUp, dx) * 180 / .pi
|
||||
let alpha = acos((a * a + d * d - b * b) / (2 * a * d)) * 180 / .pi
|
||||
let guessElbow = walk(from: start, angles: guess, lengths: [a, b])[1]
|
||||
var best: (distance: Double, angles: [Double])?
|
||||
for sign in [1.0, -1.0] {
|
||||
let upperAngle = base + sign * alpha
|
||||
let elbow = walk(from: start, angles: [upperAngle], lengths: [a])[1]
|
||||
let distance = Double(hypot(elbow.x - guessElbow.x, elbow.y - guessElbow.y))
|
||||
if best == nil || distance < best!.distance {
|
||||
best = (distance, [upperAngle, angle(from: elbow, to: target)])
|
||||
}
|
||||
}
|
||||
return best!.angles
|
||||
}
|
||||
|
||||
static func attachments(root: CGPoint, spine: [Double], body: ExerciseBodyProfile) -> FigureAttachments {
|
||||
let mid = walk(from: root, angles: [spine[0]], lengths: [body.spine1])[1]
|
||||
let neck = walk(from: mid, angles: [spine[1]], lengths: [body.spine2])[1]
|
||||
let ox = body.leftOffset.count > 0 ? body.leftOffset[0] : 6
|
||||
let oy = body.leftOffset.count > 1 ? body.leftOffset[1] : 2
|
||||
return FigureAttachments(
|
||||
pelvis: root, mid: mid, neck: neck,
|
||||
shoulderL: CGPoint(x: neck.x + ox, y: neck.y + oy),
|
||||
hipL: CGPoint(x: root.x + ox, y: root.y + oy)
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve a key frame to pure angles: replace each pinned limb's authored angles
|
||||
/// with its IK solution (the authored angles only pick the bend direction).
|
||||
static func normalize(_ kf: MotionKeyFrame, body: ExerciseBodyProfile) -> FigurePose {
|
||||
let root = CGPoint(x: kf.root[0], y: kf.root[1])
|
||||
var pins: [String: CGPoint] = [:]
|
||||
for (key, xy) in kf.pins ?? [:] where xy.count == 2 {
|
||||
pins[key] = CGPoint(x: xy[0], y: xy[1])
|
||||
}
|
||||
let at = attachments(root: root, spine: kf.spine, body: body)
|
||||
var limbs: [FigureLimb: [Double]] = [:]
|
||||
for limb in FigureLimb.allCases {
|
||||
guard let authored = limb.angles(in: kf) else { continue }
|
||||
if let pin = pins[limb.pinKey] {
|
||||
let lengths = limb.boneLengths(body)
|
||||
limbs[limb] = ik2(start: at.point(for: limb), target: pin,
|
||||
upper: lengths[0], lower: lengths[1], guess: authored)
|
||||
} else {
|
||||
limbs[limb] = authored
|
||||
}
|
||||
}
|
||||
return FigurePose(root: root, spine: kf.spine, neck: kf.neck, gaze: kf.gaze,
|
||||
limbs: limbs, 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
|
||||
}
|
||||
|
||||
/// Shortest-path angular interpolation, so limbs swing in natural arcs.
|
||||
static func lerpAngle(_ a: Double, _ b: Double, _ t: Double) -> Double {
|
||||
var delta = (b - a + 180).truncatingRemainder(dividingBy: 360)
|
||||
if delta < 0 { delta += 360 }
|
||||
return a + (delta - 180) * t
|
||||
}
|
||||
|
||||
/// Interpolate two normalized poses. A pin survives the tween only if planted in
|
||||
/// BOTH neighboring key frames (so planted limbs hold exactly and releasing pins
|
||||
/// release naturally); a limb absent from either side is dropped for the tween.
|
||||
static func lerp(_ a: FigurePose, _ b: FigurePose, _ t: Double) -> FigurePose {
|
||||
var limbs: [FigureLimb: [Double]] = [:]
|
||||
for (limb, va) in a.limbs {
|
||||
guard let vb = b.limbs[limb] else { continue }
|
||||
limbs[limb] = zip(va, vb).map { lerpAngle($0, $1, t) }
|
||||
}
|
||||
var pins: [String: CGPoint] = [:]
|
||||
for (key, pa) in a.pins {
|
||||
guard let pb = b.pins[key] else { continue }
|
||||
pins[key] = CGPoint(x: pa.x + (pb.x - pa.x) * t, y: pa.y + (pb.y - pa.y) * t)
|
||||
}
|
||||
return FigurePose(
|
||||
root: CGPoint(x: a.root.x + (b.root.x - a.root.x) * t,
|
||||
y: a.root.y + (b.root.y - a.root.y) * t),
|
||||
spine: zip(a.spine, b.spine).map { lerpAngle($0, $1, t) },
|
||||
neck: lerpAngle(a.neck, b.neck, t),
|
||||
gaze: lerpAngle(a.gaze, b.gaze, t),
|
||||
limbs: limbs, pins: pins, hold: a.hold, tween: a.tween
|
||||
)
|
||||
}
|
||||
|
||||
/// Normalized pose → drawable points. Limbs with an active pin are re-solved so
|
||||
/// planted hands/feet hold that point exactly through tweens.
|
||||
static func geometry(of pose: FigurePose, body: ExerciseBodyProfile, hide: Set<String>) -> FigureGeometry {
|
||||
let at = attachments(root: pose.root, spine: pose.spine, body: body)
|
||||
let head = walk(from: at.neck, angles: [pose.neck], lengths: [body.neck])[1]
|
||||
let noseTip = walk(from: head, angles: [pose.gaze], lengths: [body.headR + 7])[1]
|
||||
// Nose tick: 7pt outward from the head rim along the gaze direction.
|
||||
let d = max(hypot(noseTip.x - head.x, noseTip.y - head.y), 1)
|
||||
let ux = (noseTip.x - head.x) / d
|
||||
let uy = (noseTip.y - head.y) / d
|
||||
let r = body.headR
|
||||
|
||||
var limbs: [FigureLimb: [CGPoint]] = [:]
|
||||
for (limb, authored) in pose.limbs where !hide.contains(limb.rawValue) {
|
||||
let lengths = limb.boneLengths(body)
|
||||
var angles = authored
|
||||
if let pin = pose.pins[limb.pinKey] {
|
||||
angles = ik2(start: at.point(for: limb), target: pin,
|
||||
upper: lengths[0], lower: lengths[1], guess: angles)
|
||||
}
|
||||
limbs[limb] = walk(from: at.point(for: limb), angles: angles, lengths: lengths)
|
||||
}
|
||||
|
||||
return FigureGeometry(
|
||||
headCenter: head,
|
||||
headRadius: r,
|
||||
noseStart: CGPoint(x: head.x + ux * r, y: head.y + uy * r),
|
||||
noseEnd: CGPoint(x: head.x + ux * (r + 7), y: head.y + uy * (r + 7)),
|
||||
spineStart: at.pelvis,
|
||||
spineControl: CGPoint(x: 2 * at.mid.x - (at.pelvis.x + at.neck.x) / 2,
|
||||
y: 2 * at.mid.y - (at.pelvis.y + at.neck.y) / 2),
|
||||
spineEnd: at.neck,
|
||||
limbs: limbs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The full looping animation for one motion: normalized key poses plus
|
||||
/// continuous-time sampling — hold at each key frame, then an eased angle-space tween
|
||||
/// to the next; the last frame tweens back to the first.
|
||||
struct MotionTimeline {
|
||||
let poses: [FigurePose]
|
||||
let duration: Double
|
||||
|
||||
init?(motion: ExerciseMotion, body: ExerciseBodyProfile) {
|
||||
let poses = motion.frames.map { MotionSolver.normalize($0, body: body) }
|
||||
let duration = poses.reduce(0) { $0 + $1.hold + $1.tween }
|
||||
guard !poses.isEmpty, duration > 0 else { return nil }
|
||||
self.poses = poses
|
||||
self.duration = duration
|
||||
}
|
||||
|
||||
/// The pose at wall-clock `time`, looping every `duration` seconds.
|
||||
func pose(at time: Double) -> FigurePose {
|
||||
var t = time.truncatingRemainder(dividingBy: duration)
|
||||
if t < 0 { t += duration }
|
||||
for (i, pose) in poses.enumerated() {
|
||||
if t < pose.hold { return pose }
|
||||
t -= pose.hold
|
||||
if t < pose.tween {
|
||||
let next = poses[(i + 1) % poses.count]
|
||||
return MotionSolver.lerp(pose, next, MotionSolver.ease(t / pose.tween))
|
||||
}
|
||||
t -= pose.tween
|
||||
}
|
||||
return poses[0]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user