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,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]
|
||||
/// Pelvis→mid and mid→neck 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user