// // ExerciseMotion.swift // Workouts // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import Foundation /// Codable mirror of the Exercise Library's anatomical 3D rig data (see /// `Exercise Library/SYSTEM.md`). /// /// A **skeleton profile** is a table of bone lengths (plus real shoulder/pelvis widths /// and feet); a **motion script** is key frames of anatomical joint angles measured in /// degrees from the neutral standing pose (flexion forward, abduction away from the /// midline, rotation external — spine/neck rotation is turn-right positive), a pelvis /// `root` anchor with trunk orientation, an orthographic camera yaw, optional IK pins /// for planted hands/feet, and hold/tween timings. The app bundles verbatim copies /// exported by `render.py --export` into `Resources/ExerciseMotions/` — `skeleton.json` /// plus one `.motion.json` per library entry. /// Bone lengths for one figure profile (`neutral` is the only one the app renders). struct SkeletonProfile: 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 let foot: Double /// Half the shoulder / pelvis width — the lateral offset of each limb's attachment /// from the spine, giving the figure real depth face-on. let shoulderHalf: Double let hipHalf: Double /// Profile-view readability nudge `[dx, dy]` for the far member of a limb pair, /// scaled by how side-on the view is (vanishes face-on). let farOffset: [Double] } /// The skeleton file: named profiles plus per-joint ROM (unused by the renderer). struct Skeleton: Codable { let profiles: [String: SkeletonProfile] } /// A single joint value: a bare number (shorthand for `{"flexion": n}`) or a dict of /// named degrees of freedom. Ball joints read `flexion`/`abduction`/`rotation`, spine /// segments `flexion`/`lateral`/`rotation`, hinges only `flexion`; each accessor /// returns 0 for a DoF the value omits (matching the reference's `_full`). enum JointValue: Codable { case scalar(Double) case object(flexion: Double?, abduction: Double?, rotation: Double?, lateral: Double?) private enum CodingKeys: String, CodingKey { case flexion, abduction, rotation, lateral } init(from decoder: Decoder) throws { if let single = try? decoder.singleValueContainer(), let value = try? single.decode(Double.self) { self = .scalar(value) return } let container = try decoder.container(keyedBy: CodingKeys.self) self = .object(flexion: try container.decodeIfPresent(Double.self, forKey: .flexion), abduction: try container.decodeIfPresent(Double.self, forKey: .abduction), rotation: try container.decodeIfPresent(Double.self, forKey: .rotation), lateral: try container.decodeIfPresent(Double.self, forKey: .lateral)) } func encode(to encoder: Encoder) throws { switch self { case .scalar(let value): var container = encoder.singleValueContainer() try container.encode(value) case .object(let flexion, let abduction, let rotation, let lateral): var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(flexion, forKey: .flexion) try container.encodeIfPresent(abduction, forKey: .abduction) try container.encodeIfPresent(rotation, forKey: .rotation) try container.encodeIfPresent(lateral, forKey: .lateral) } } var flexion: Double { switch self { case .scalar(let value): value case .object(let flexion, _, _, _): flexion ?? 0 } } var abduction: Double { if case .object(_, let abduction, _, _) = self { return abduction ?? 0 }; return 0 } var rotation: Double { if case .object(_, _, let rotation, _) = self { return rotation ?? 0 }; return 0 } var lateral: Double { if case .object(_, _, _, let lateral) = self { return lateral ?? 0 }; return 0 } } /// The pelvis anchor plus trunk orientation. `pos` is `[x, y]` in 320×180 canvas /// coordinates; `yaw`/`pitch`/`roll` are the trunk's facing, forward bow, and side-lean /// (all optional, degrees). struct RootValue: Codable { let pos: [Double] let yaw: Double? let pitch: Double? let roll: Double? } /// The orthographic camera: `yaw` 0 is the classic side view, 90 face-on. struct MotionCamera: Codable { let yaw: Double? /// Camera elevation override; nil uses the standard slightly-raised viewpoint. let pitch: Double? } /// 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" | "roller" /// 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? /// roller: which side of the lower bone the disc presses (+1/−1), its radius, /// and how far back along the bone from the joint it sits. let side: Double? let r: Double? let back: Double? } /// A key frame of anatomical joint angles. A joint absent from the frame poses at /// neutral (all zeros); `spine` is `[lower, upper]` segments; a bare number anywhere a /// `JointValue` appears is its flexion. 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 anchor plus trunk orientation. let root: RootValue /// The two chained spine segments (pelvis→mid, mid→neck). let spine: [JointValue]? /// Neck: flexion (+ rotation). let neck: JointValue? /// Extra gaze pitch layered on the neck (a bare number = flexion). let head: JointValue? /// 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]]? let shoulderR: JointValue? let shoulderL: JointValue? let elbowR: JointValue? let elbowL: JointValue? let hipR: JointValue? let hipL: JointValue? let kneeR: JointValue? let kneeL: JointValue? let ankleR: JointValue? let ankleL: JointValue? enum CodingKeys: String, CodingKey { case hold, tween, root, spine, neck, head, pins case shoulderR = "shoulder_r", shoulderL = "shoulder_l" case elbowR = "elbow_r", elbowL = "elbow_l" case hipR = "hip_r", hipL = "hip_l" case kneeR = "knee_r", kneeL = "knee_l" case ankleR = "ankle_r", ankleL = "ankle_l" } } /// 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? /// Orthographic camera. Nil is the side view (`yaw` 0). let camera: MotionCamera? /// 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] } /// 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 profile: SkeletonProfile } /// Every exercise with a bundled motion script, sorted alphabetically. XcodeGen /// flattens resource groups, so `.motion.json` files land in the /// bundle root alongside `skeleton.json` — enumerate all json and filter on the /// compound suffix (which `skeleton.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 skeleton 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 skeletonURL = Bundle.main.url(forResource: "skeleton", withExtension: "json"), let motionData = try? Data(contentsOf: motionURL), let skeletonData = try? Data(contentsOf: skeletonURL), let motion = try? JSONDecoder().decode(ExerciseMotion.self, from: motionData), let skeleton = try? JSONDecoder().decode(Skeleton.self, from: skeletonData), let profile = skeleton.profiles["neutral"] else { return nil } return Resources(motion: motion, profile: profile) } }