// // 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 /// `.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]? /// 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 extremity (`"hand_r"`) or the midpoint of two /// (`["foot_r", "foot_l"]`). Extremity keys match the pin keys. 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 `.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 `.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) } }