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:
2026-07-06 01:15:52 -04:00
parent ddd5631ef2
commit 7274f155e9
79 changed files with 2521 additions and 11 deletions
@@ -0,0 +1,101 @@
//
// ExerciseMotion.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// Codable mirror of the Exercise Library's rig data (see `Exercise Library/SYSTEM.md`).
///
/// A **body profile** is a table of bone lengths; a **motion script** is key frames of
/// absolute joint angles (degrees, y-up: 0 = right, 90 = up, 90 = down), a pelvis
/// `root` in 320×180 canvas coordinates, optional IK pins for planted hands/feet, and
/// hold/tween timings. The app bundles verbatim copies exported by
/// `render.py --export` into `Resources/ExerciseMotions/` `body.json` plus one
/// `<Exercise Name>.motion.json` per library entry.
/// Bone lengths for one figure profile (`neutral` is the default).
struct ExerciseBodyProfile: Codable {
let headR: Double
let neck: Double
let spine1: Double
let spine2: Double
let upperArm: Double
let foreArm: Double
let thigh: Double
let shin: Double
/// Offset separating left-limb attachments visually: `[dx, dy]` in canvas points.
let leftOffset: [Double]
}
/// One exercise's motion script: key frames plus the parts drawn in the accent color.
struct ExerciseMotion: Codable {
let name: String
/// 1-based frame used for the static visual (unused by the animated renderer).
let primary: Int?
/// Parts (`arm_r`, `leg_l`, `spine`, ) drawn in the working accent color.
let working: [String]?
/// Limbs fully occluded in this view never drawn.
let hide: [String]?
let frames: [MotionKeyFrame]
}
/// A key frame of absolute joint angles. Limbs listed in the motion's `hide` may be
/// absent entirely; a two-element array is `[upper, lower]` bone angles.
struct MotionKeyFrame: Codable {
/// Seconds held at this key frame (default 0.5).
let hold: Double?
/// Seconds animating to the *next* frame (default 0.6); the last frame tweens
/// back to the first, looping.
let tween: Double?
/// Pelvis position, canvas coordinates `[x, y]`.
let root: [Double]
/// Pelvismid and midneck angles.
let spine: [Double]
/// Head direction from the neck joint.
let neck: Double
/// Nose-tick direction (the belly is on that side).
let gaze: Double
let armR: [Double]?
let armL: [Double]?
let legR: [Double]?
let legL: [Double]?
/// IK targets for planted extremities, keyed `hand_r`/`hand_l`/`foot_r`/`foot_l`.
/// A pin present in two consecutive key frames stays planted through the tween.
let pins: [String: [Double]]?
enum CodingKeys: String, CodingKey {
case hold, tween, root, spine, neck, gaze, pins
case armR = "arm_r"
case armL = "arm_l"
case legR = "leg_r"
case legL = "leg_l"
}
}
/// Finds and decodes the bundled rig resources for an exercise, by exact name match
/// against the exported `<Exercise Name>.motion.json` files.
enum ExerciseMotionLibrary {
struct Resources {
let motion: ExerciseMotion
let body: ExerciseBodyProfile
}
/// The motion script plus the neutral body profile for `exerciseName`, or `nil`
/// when no bundled motion matches (most exercises have none the caller keeps
/// its space empty).
static func resources(for exerciseName: String) -> Resources? {
guard
let motionURL = Bundle.main.url(forResource: exerciseName, withExtension: "motion.json"),
let bodyURL = Bundle.main.url(forResource: "body", withExtension: "json"),
let motionData = try? Data(contentsOf: motionURL),
let bodyData = try? Data(contentsOf: bodyURL),
let motion = try? JSONDecoder().decode(ExerciseMotion.self, from: motionData),
let profiles = try? JSONDecoder().decode([String: ExerciseBodyProfile].self, from: bodyData),
let body = profiles["neutral"]
else { return nil }
return Resources(motion: motion, body: body)
}
}