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.
102 lines
3.9 KiB
Swift
102 lines
3.9 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
}
|