Files
workouts/Workouts/ExerciseFigure/ExerciseMotion.swift
T
rzen 888852cc2e Replace the YAML exercise catalogs with the motion-rig exercise library
The exercise picker now lists the bundled exercise library — the
motion-rig exercises exported from Exercise Library/ — instead of the
two *.exercises.yaml starter catalogs. ExerciseListLoader and the Yams
dependency are removed; ExerciseMotionLibrary gains exerciseNames, the
bundle enumeration the picker reads.
2026-07-06 12:27:44 -04:00

115 lines
4.5 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.
//
// 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
}
/// Every exercise with a bundled motion script, sorted alphabetically. XcodeGen
/// flattens resource groups, so `<Exercise Name>.motion.json` files land in the
/// bundle root alongside `body.json` — enumerate all json and filter on the
/// compound suffix (which `body.json` doesn't match).
static let exerciseNames: [String] = {
let urls = Bundle.main.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? []
return urls
.map(\.lastPathComponent)
.filter { $0.hasSuffix(".motion.json") }
.map { String($0.dropLast(".motion.json".count)) }
.sorted()
}()
/// 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)
}
}