Files
workouts/Workouts/ExerciseFigure/MotionSolver.swift
T
rzen 7274f155e9 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.
2026-07-06 01:15:52 -04:00

289 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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]
}
}