Rebuild the exercise figure on an anatomical 3D skeleton
The library's planar world-angle rig becomes a genuine 3D anatomical model: skeleton.json holds bone-length profiles (real shoulder/pelvis widths, feet, neutral/female/male) and per-joint ROM; motions pose joints with anatomical angles (flexion/abduction/rotation from neutral standing) under a per-exercise orthographic camera, resolved by kinematics.py (3D FK, analytic two-bone IK with anatomical write-back) and validated against physiological ranges. All 20 sagittal motions were migrated by planar decomposition with 0.00 px golden parity against the old renderer — relabeled to true anatomy, since shading is now near-dark/far-light by camera depth rather than by limb suffix — and the face-on machines are re-authored honestly: Abductor/Adductor with real hip abduction (the foreshortened "frontal" profile is retired) and Rotary with genuine spine axial rotation. Figures gain articulated feet; profiles swap without touching a single motion script; --orbit sweeps the camera 360° while a motion loops. The in-app SwiftUI renderer (iOS + watch) is ported to the same model and consumes the exported motions verbatim; figure-fixtures.json pins its geometry to the Python pipeline within 0.5 px across every exercise, key frame, tween, and orbit sample. Also makes the watch bridge logger nonisolated for the newer SDK's stricter isolation checking. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
@@ -7,17 +7,20 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Codable mirror of the Exercise Library's rig data (see `Exercise Library/SYSTEM.md`).
|
||||
/// Codable mirror of the Exercise Library's anatomical 3D 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.
|
||||
/// 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 default).
|
||||
struct ExerciseBodyProfile: Codable {
|
||||
/// 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
|
||||
@@ -26,26 +29,81 @@ struct ExerciseBodyProfile: Codable {
|
||||
let foreArm: Double
|
||||
let thigh: Double
|
||||
let shin: Double
|
||||
/// Offset separating left-limb attachments visually: `[dx, dy]` in canvas points.
|
||||
let leftOffset: [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]
|
||||
}
|
||||
|
||||
/// 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]
|
||||
/// 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?
|
||||
}
|
||||
|
||||
/// A prop's joint reference: one joint (`"hand_r"`, `"knee_l"`, `"elbow_r"`, …)
|
||||
@@ -118,52 +176,76 @@ struct MotionProp: Codable {
|
||||
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.
|
||||
/// 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 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]?
|
||||
/// 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, gaze, pins
|
||||
case armR = "arm_r"
|
||||
case armL = "arm_l"
|
||||
case legR = "leg_r"
|
||||
case legL = "leg_l"
|
||||
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 body: ExerciseBodyProfile
|
||||
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 `body.json` — enumerate all json and filter on the
|
||||
/// compound suffix (which `body.json` doesn't match).
|
||||
/// 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
|
||||
@@ -173,19 +255,19 @@ enum ExerciseMotionLibrary {
|
||||
.sorted()
|
||||
}()
|
||||
|
||||
/// The motion script plus the neutral body profile for `exerciseName`, or `nil`
|
||||
/// 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 bodyURL = Bundle.main.url(forResource: "body", withExtension: "json"),
|
||||
let skeletonURL = Bundle.main.url(forResource: "skeleton", withExtension: "json"),
|
||||
let motionData = try? Data(contentsOf: motionURL),
|
||||
let bodyData = try? Data(contentsOf: bodyURL),
|
||||
let skeletonData = try? Data(contentsOf: skeletonURL),
|
||||
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"]
|
||||
let skeleton = try? JSONDecoder().decode(Skeleton.self, from: skeletonData),
|
||||
let profile = skeleton.profiles["neutral"]
|
||||
else { return nil }
|
||||
return Resources(motion: motion, body: body)
|
||||
return Resources(motion: motion, profile: profile)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user