The orbit was always a real camera orbit — figure and props share one rigid rotation — but a bar's screen-space angle authored the wrong 3D rod: the default horizontal encoded a rod along the body axis, so barbells hovered fixed on screen and vanished at the head-on view where they should span widest. Line props now take "axis": "z" (both renderers in lockstep, fixture-pinned): the world left-right direction projects through the camera pitch like the floor quad — end-on plates in profile, full span face-on, swinging with the hands in between. Applied to the ten cross-body bars; vertical handles were already orbit-invariant. Goblet Squat's hand pins sat so close to the shoulders that the two-bone IK was degenerate, flipping between a chicken-wing and an elbow-behind solve; re-pinned level with the shoulders so the elbows tuck straight down through the whole rep. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
289 lines
12 KiB
Swift
289 lines
12 KiB
Swift
//
|
||
// 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 `<Exercise Name>.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?
|
||
/// Presentation-only zoom about the ground-center anchor (nil = 1). Standing
|
||
/// motions author full-size anatomy — taller than the canvas — and zoom out
|
||
/// to fit; the solver and fixtures stay in full-size canvas units.
|
||
let zoom: 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, authored in canvas coordinates in the
|
||
/// authored view, with world-space 3D form for the orbiting presentation.
|
||
struct PropSceneShape: Codable {
|
||
let kind: String // "line" | "circle"
|
||
/// line: polyline points, `[x, y]` or `[x, y, z]`; `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?
|
||
/// The shape's depth plane (+ toward the camera in the authored view).
|
||
let z: Double?
|
||
/// Extrusion half-width: a line with `depth` is a slab (a seat, a platform)
|
||
/// that opens into a swept quad as the camera orbits.
|
||
let depth: 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]` or `[x, y, z]` → 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?
|
||
/// True 3D axis, superseding `angle`: `"z"` marks a cross-body rod
|
||
/// (barbell, pull-up bar) whose projection foreshortens naturally —
|
||
/// end-on in a profile view, full span face-on.
|
||
let axis: String?
|
||
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]?
|
||
/// 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 `<Exercise Name>.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 `<Exercise Name>.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)
|
||
}
|
||
}
|