Files
workouts/Workouts/ExerciseFigure/ExerciseMotion.swift
T
rzen 81186c51b1 Give machine props world-space 3D form that rotates with the camera
Scene shapes, cable anchors, bar angles, pad perpendiculars, and roller
offsets all resolve in the authored view exactly as before, then rotate
about the world-vertical axis through the root anchor - the same
resolve-then-rotate pattern as the figure's pins and the mat - so at the
authored yaw every exercise renders bit-identically to today, and under
an orbiting camera the equipment turns with the figure while staying
welded to its hands and feet. Scene lines gain an optional depth plane
(z) and slab extrusion (depth) so seats, backrests, and platforms keep
form edge-on; the rect shape is retired (re-authored as slab lines).
All 14 machines' props re-authored with depths and verified at eight
orbit angles. The fixture snapshots move into the pipeline as
render.py --fixtures and now cover orbit-presentation samples with
resolved prop primitives for a spread of prop flavors; the in-app
renderer resolves props in MotionSolver (lockstep with resolve_props)
and the view just draws primitives.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
2026-07-06 22:15:45 -04:00

283 lines
12 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 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?
}
/// 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?
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 `<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)
}
}