Library detail screens now lead with the user's recorded machine settings (per-split when they disagree, empty-state card for machine-based entries) and append the weight progression chart. Starter seeds mark machine exercises with an empty machineSettings list so the settings UI lights up before first use. The figure rig gains a frontal body profile for face-on machines, props that can ride mid joints (knees/elbows), and an alternating four-frame Bird Dog loop. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
192 lines
7.3 KiB
Swift
192 lines
7.3 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?
|
||
/// Body-profile override (e.g. `"frontal"`: foreshortened legs for face-on
|
||
/// seated machines). Nil uses the `neutral` profile.
|
||
let figure: String?
|
||
/// 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]?
|
||
/// Equipment layer: scene shapes and cables behind the figure, joint-attached
|
||
/// items (bar/dumbbell/pad) over the limbs. See SYSTEM.md "The props layer".
|
||
let props: [MotionProp]?
|
||
let frames: [MotionKeyFrame]
|
||
}
|
||
|
||
/// A prop's joint reference: one joint (`"hand_r"`, `"knee_l"`, `"elbow_r"`, …)
|
||
/// or the midpoint of two (`["foot_r", "foot_l"]`, `["knee_r", "foot_r"]`).
|
||
/// Extremity keys match the pin keys; elbows/knees are the mid joints.
|
||
enum PropJointRef: Codable {
|
||
case single(String)
|
||
case midpoint([String])
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.singleValueContainer()
|
||
if let name = try? container.decode(String.self) {
|
||
self = .single(name)
|
||
} else {
|
||
self = .midpoint(try container.decode([String].self))
|
||
}
|
||
}
|
||
|
||
func encode(to encoder: Encoder) throws {
|
||
var container = encoder.singleValueContainer()
|
||
switch self {
|
||
case .single(let name): try container.encode(name)
|
||
case .midpoint(let names): try container.encode(names)
|
||
}
|
||
}
|
||
|
||
var names: [String] {
|
||
switch self {
|
||
case .single(let name): [name]
|
||
case .midpoint(let names): names
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One static shape of a `scene` prop, in canvas coordinates.
|
||
struct PropSceneShape: Codable {
|
||
let kind: String // "line" | "circle" | "rect"
|
||
/// line: polyline points; `w` is the stroke width.
|
||
let pts: [[Double]]?
|
||
let w: Double?
|
||
/// circle: center + radius; `fill` false draws an outline.
|
||
let c: [Double]?
|
||
let r: Double?
|
||
let fill: Bool?
|
||
/// rect: origin + size (`w`/`h`), corner radius `r`; always filled.
|
||
let x: Double?
|
||
let y: Double?
|
||
let h: Double?
|
||
/// Optional palette override: `"prop"` for the darker attached-item gray.
|
||
let color: String?
|
||
}
|
||
|
||
/// One equipment prop. `type` selects the flavor; the other fields apply per type
|
||
/// (mirroring the reference renderer's `resolve_props`).
|
||
struct MotionProp: Codable {
|
||
let type: String // "scene" | "cable" | "bar" | "dumbbell" | "pad"
|
||
/// scene: the static shapes.
|
||
let shapes: [PropSceneShape]?
|
||
/// cable: fixed anchor `[x, y]` → moving joint `to`.
|
||
let from: [Double]?
|
||
let to: PropJointRef?
|
||
/// bar/dumbbell/pad: the joint(s) the item is centered on.
|
||
let at: PropJointRef?
|
||
/// Fixed world angle (degrees, y-up). Default: bars are horizontal;
|
||
/// dumbbells/pads sit perpendicular to the lower bone.
|
||
let angle: Double?
|
||
let halfLen: Double?
|
||
let w: Double?
|
||
/// End-disc radius (dumbbell plates default 4.5; bars none).
|
||
let plateR: Double?
|
||
}
|
||
|
||
/// 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). Absent when the figure
|
||
/// faces the viewer — no nose is drawn.
|
||
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[motion.figure ?? "neutral"]
|
||
else { return nil }
|
||
return Resources(motion: motion, body: body)
|
||
}
|
||
}
|