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:
2026-07-06 20:10:50 -04:00
parent 6521de8f17
commit 3c7a790e9d
153 changed files with 2827 additions and 1686 deletions
@@ -8,12 +8,14 @@
import SwiftUI
/// The looping animated stick-figure for the run screen's bottom half, rendered with
/// `Canvas` + `TimelineView(.animation)` from the bundled rig data (see
/// `Canvas` + `TimelineView(.animation)` from the bundled anatomical rig data (see
/// `Exercise Library/SYSTEM.md` for the visual language).
///
/// Draw order matters: left limbs behind, spine, right limbs, then the head filled
/// opaque with the background color so overhead arms pass behind the face plus the
/// nose tick (gaze).
/// The solver hands back a depth-sorted draw order per frame (far parts first, the head
/// always last so overhead arms pass behind the face) and a near/far tag per limb: the
/// member nearer the camera draws in the dark ink at the heavier width, the far member
/// in the light ink one step thinner and nudged by the readability offset. Working parts
/// swap the same near/far inks for the accent teals; the spine is always dark.
/// Everything the renderer needs for one exercise, resolved once from the bundle.
struct FigureAnimation {
@@ -24,22 +26,26 @@ struct FigureAnimation {
let hide: Set<String>
/// Equipment layer (see SYSTEM.md "The props layer").
let props: [MotionProp]
let bodyProfile: ExerciseBodyProfile
init?(exerciseName: String) {
guard
let resources = ExerciseMotionLibrary.resources(for: exerciseName),
let timeline = MotionTimeline(motion: resources.motion, body: resources.body)
let timeline = MotionTimeline(motion: resources.motion, profile: resources.profile)
else { return nil }
self.timeline = timeline
self.working = Set(resources.motion.working ?? [])
self.hide = Set(resources.motion.hide ?? [])
self.props = resources.motion.props ?? []
self.bodyProfile = resources.body
}
/// Drawable geometry at `time`, with limbs listed in `hide` dropped so neither they
/// nor any prop riding them are drawn.
func geometry(at time: Double) -> FigureGeometry {
MotionSolver.geometry(of: timeline.pose(at: time), body: bodyProfile, hide: hide)
var geo = timeline.geometry(at: time)
for name in hide {
if let limb = FigureLimb(rawValue: name) { geo.limbs[limb] = nil }
}
return geo
}
}
@@ -102,39 +108,39 @@ struct ExerciseFigureView: View {
// Equipment behind the figure: scene shapes and cables.
drawBackgroundProps(&ctx, geo)
// Left limbs (behind), spine, right limbs.
drawLimb(&ctx, geo, .armL)
drawLimb(&ctx, geo, .legL)
drawSpine(&ctx, geo)
drawLimb(&ctx, geo, .armR)
drawLimb(&ctx, geo, .legR)
// Joint-attached equipment over the limbs (bars, dumbbells, pads).
drawAttachedProps(&ctx, geo)
// Head last, filled with the background color so limbs pass behind the face.
let r = geo.headRadius
let headRect = CGRect(x: geo.headCenter.x - r, y: geo.headCenter.y - r,
width: 2 * r, height: 2 * r)
let headPath = Path(ellipseIn: headRect)
ctx.fill(headPath, with: .color(.figureHeadFill))
ctx.stroke(headPath, with: .color(.figureRight), lineWidth: 6)
if let noseStart = geo.noseStart, let noseEnd = geo.noseEnd {
stroke(&ctx, [noseStart, noseEnd], color: .figureRight, width: 4)
// Parts far-to-near. The head paints last (opaque), preceded by the joint-attached
// equipment so bars/pads sit over the limbs but behind the face.
for part in geo.order {
switch part {
case "head":
drawAttachedProps(&ctx, geo)
drawHead(&ctx, geo)
case "spine":
drawSpine(&ctx, geo)
default:
guard let limb = FigureLimb(rawValue: part), let points = geo.limbs[limb] else { continue }
let shade = geo.shade[limb] ?? .near
stroke(&ctx, points, color: ink(part, shade: shade), width: shade == .near ? 6 : 5)
}
}
}
private func drawLimb(_ ctx: inout GraphicsContext, _ geo: FigureGeometry, _ limb: FigureLimb) {
guard let points = geo.limbs[limb] else { return }
stroke(&ctx, points, color: color(for: limb.rawValue, isLeft: limb.isLeft),
width: limb.isLeft ? 5 : 6)
private func drawHead(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
let r = geo.headRadius
let headRect = CGRect(x: geo.headCenter.x - r, y: geo.headCenter.y - r, width: 2 * r, height: 2 * r)
let headPath = Path(ellipseIn: headRect)
ctx.fill(headPath, with: .color(.figureHeadFill))
ctx.stroke(headPath, with: .color(.figureNear), lineWidth: 6)
if let noseStart = geo.noseStart, let noseEnd = geo.noseEnd {
stroke(&ctx, [noseStart, noseEnd], color: .figureNear, width: 4)
}
}
private func drawSpine(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
var path = Path()
path.move(to: geo.spineStart)
path.addQuadCurve(to: geo.spineEnd, control: geo.spineControl)
ctx.stroke(path, with: .color(color(for: "spine", isLeft: false)),
ctx.stroke(path, with: .color(ink("spine", shade: .near)),
style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round))
}
@@ -142,7 +148,8 @@ struct ExerciseFigureView: View {
/// Prop joint refs (limb, chain index): extremities are index 2, mid joints
/// (elbows/knees) index 1, so equipment can ride either joint. Kept 1:1 with
/// the reference renderer's `JOINT_LIMB`.
/// the reference renderer's `JOINT_LIMB` (legs now end in a foot bone, so a foot
/// prop rides the ankle at index 2).
private static let propJoints: [String: (limb: FigureLimb, index: Int)] = [
"hand_r": (.armR, 2), "elbow_r": (.armR, 1),
"hand_l": (.armL, 2), "elbow_l": (.armL, 1),
@@ -248,11 +255,13 @@ struct ExerciseFigureView: View {
}
}
private func color(for part: String, isLeft: Bool) -> Color {
/// Near parts take the dark ink, far parts the light one; working parts swap for the
/// accent teals. The spine (passed `.near`) is always dark unless it's the working part.
private func ink(_ part: String, shade: Shade) -> Color {
if figure.working.contains(part) {
return isLeft ? .figureLeftWorking : .figureRightWorking
return shade == .near ? .figureNearWorking : .figureFarWorking
}
return isLeft ? .figureLeft : .figureRight
return shade == .near ? .figureNear : .figureFar
}
private func stroke(_ ctx: inout GraphicsContext, _ points: [CGPoint], color: Color, width: CGFloat) {
@@ -265,8 +274,8 @@ struct ExerciseFigureView: View {
// MARK: - Figure Palette
/// The reference palette (`render.py`), made dark-mode adaptive: the prominent right
/// side stays strong (near-black light gray), the recessive left side stays muted,
/// The reference palette (`render.py`), made dark-mode adaptive: the prominent near
/// side stays strong (near-black light gray), the recessive far side stays muted,
/// and the working teals brighten/desaturate so they read on black. watchOS has no
/// dynamic-provider `UIColor` and renders on black, so it takes the dark variant
/// verbatim.
@@ -283,18 +292,18 @@ private extension Color {
#endif
}
/// Right-side limbs, head, nose the prominent stroke (`#3a3f4b`).
static let figureRight = figure(light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83))
/// Near-side limbs, spine, head, nose the prominent stroke (`#3a3f4b`).
static let figureNear = figure(light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83))
/// Left-side limbs, drawn behind the recessive stroke (`#a9afba`).
static let figureLeft = figure(light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43))
/// Far-side limbs, drawn behind the recessive stroke (`#a9afba`).
static let figureFar = figure(light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43))
/// Working right-side parts teal accent (`#0d9488`).
static let figureRightWorking = figure(light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75))
/// Working near-side parts teal accent (`#0d9488`).
static let figureNearWorking = figure(light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75))
/// Working left-side parts light teal (`#86cfc5`), kept more muted than the
/// right so the R/L hierarchy holds in both modes.
static let figureLeftWorking = figure(light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52))
/// Working far-side parts light teal (`#86cfc5`), kept more muted than the near
/// so the near/far hierarchy holds in both modes.
static let figureFarWorking = figure(light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52))
/// Scene equipment seats, benches, frames, cables (`#c5cad4`); sits behind
/// the figure, one step lighter than the ground line.
+138 -56
View File
@@ -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]
/// Pelvismid and midneck 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 (pelvismid, midneck).
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)
}
}
+424 -212
View File
@@ -8,13 +8,83 @@
import CoreGraphics
import Foundation
/// Swift port of the Exercise Library's reference solver (`Exercise Library/render.py`):
/// forward kinematics, analytic 2-bone IK, and angle-space tweening with shortest-path
/// lerp and ease-in-out. The math is kept 1:1 with the Python so both renderers produce
/// the same figure from the same data change them in lockstep.
/// Swift port of the Exercise Library's anatomical 3D solver (`Exercise Library/
/// kinematics.py` + `render.py`'s `frame_geometry`). It poses one shared skeleton by
/// anatomical joint angles, runs forward kinematics in ISB model space (X anterior,
/// Y up, Z anatomical right), projects orthographically through a per-exercise camera
/// yaw, and resolves each frame into drawable canvas geometry near/far shading, the
/// readability offset, draw order, spine curve, and the gaze nose tick. The math is
/// kept 1:1 with the Python so both renderers produce the same figure; it is held to
/// the reference by `WorkoutsTests/Fixtures/figure-fixtures.json`. Change them in
/// lockstep.
///
/// Angles are absolute world angles in degrees, y-up (0 = right, 90 = up, 90 = down);
/// points are y-down 320×180 canvas coordinates, so dir(θ) = (cos θ, sin θ).
/// Rotation conventions (all degrees): root = Ry(yaw)·Rz(pitch)·Rx(roll) after the
/// camera Ry(camYaw); ball joints (shoulder/hip) = Rz(flexion)·Rx(σ·abduction)·
/// Ry(σ·rotation) with σ = +1 right / 1 left; the knee hinges backward via Rz(flexion);
/// spine segments = Rz(flexion)·Rx(lateral)·Ry(rotation); the neck = Rz(flexion)·
/// Ry(rotation). Canvas maps view x x, view y y (origin at the root anchor).
// MARK: - Linear algebra
/// A 3-vector in model / view space.
struct Vec3 {
var x, y, z: Double
init(_ x: Double, _ y: Double, _ z: Double) { self.x = x; self.y = y; self.z = z }
static func + (a: Vec3, b: Vec3) -> Vec3 { Vec3(a.x + b.x, a.y + b.y, a.z + b.z) }
static func - (a: Vec3, b: Vec3) -> Vec3 { Vec3(a.x - b.x, a.y - b.y, a.z - b.z) }
func scaled(_ s: Double) -> Vec3 { Vec3(x * s, y * s, z * s) }
func dot(_ b: Vec3) -> Double { x * b.x + y * b.y + z * b.z }
func cross(_ b: Vec3) -> Vec3 { Vec3(y * b.z - z * b.y, z * b.x - x * b.z, x * b.y - y * b.x) }
var length: Double { dot(self).squareRoot() }
var normalized: Vec3 { let d = length; return d > 1e-9 ? scaled(1 / d) : Vec3(0, 0, 0) }
}
/// A row-major 3×3 rotation matrix (rows r0/r1/r2), matching the reference's
/// tuple-of-rows so multiplication and transpose stay identical.
struct Mat3 {
var r0, r1, r2: Vec3
static func rotX(_ deg: Double) -> Mat3 {
let r = deg * .pi / 180, c = cos(r), s = sin(r)
return Mat3(r0: Vec3(1, 0, 0), r1: Vec3(0, c, -s), r2: Vec3(0, s, c))
}
static func rotY(_ deg: Double) -> Mat3 {
let r = deg * .pi / 180, c = cos(r), s = sin(r)
return Mat3(r0: Vec3(c, 0, s), r1: Vec3(0, 1, 0), r2: Vec3(-s, 0, c))
}
static func rotZ(_ deg: Double) -> Mat3 {
let r = deg * .pi / 180, c = cos(r), s = sin(r)
return Mat3(r0: Vec3(c, -s, 0), r1: Vec3(s, c, 0), r2: Vec3(0, 0, 1))
}
/// `self · v`.
func apply(_ v: Vec3) -> Vec3 { Vec3(r0.dot(v), r1.dot(v), r2.dot(v)) }
/// `self · b`.
func times(_ b: Mat3) -> Mat3 {
let c0 = Vec3(b.r0.x, b.r1.x, b.r2.x)
let c1 = Vec3(b.r0.y, b.r1.y, b.r2.y)
let c2 = Vec3(b.r0.z, b.r1.z, b.r2.z)
return Mat3(r0: Vec3(r0.dot(c0), r0.dot(c1), r0.dot(c2)),
r1: Vec3(r1.dot(c0), r1.dot(c1), r1.dot(c2)),
r2: Vec3(r2.dot(c0), r2.dot(c1), r2.dot(c2)))
}
var transposed: Mat3 {
Mat3(r0: Vec3(r0.x, r1.x, r2.x), r1: Vec3(r0.y, r1.y, r2.y), r2: Vec3(r0.z, r1.z, r2.z))
}
}
/// Left-to-right matrix product (`chain(a, b, c) == a·b·c`).
private func chain(_ mats: Mat3...) -> Mat3 {
var m = mats[0]
for n in mats.dropFirst() { m = m.times(n) }
return m
}
private func clampUnit(_ x: Double, _ lo: Double = -1, _ hi: Double = 1) -> Double { max(lo, min(hi, x)) }
// MARK: - Frame model
/// The four two-bone limbs, keyed by their motion-script names.
enum FigureLimb: String, CaseIterable {
@@ -23,8 +93,9 @@ enum FigureLimb: String, CaseIterable {
case legR = "leg_r"
case legL = "leg_l"
var isLeft: Bool { self == .armL || self == .legL }
var isArm: Bool { self == .armR || self == .armL }
/// Side sign for a ball joint's abduction/rotation (+1 right, 1 left).
var sigma: Double { (self == .armR || self == .legR) ? 1 : -1 }
/// The key a planted extremity uses in a key frame's `pins`.
var pinKey: String {
switch self {
@@ -34,261 +105,402 @@ enum FigureLimb: String, CaseIterable {
case .legL: "foot_l"
}
}
/// `[upper, lower]` bone lengths for this limb.
func boneLengths(_ body: ExerciseBodyProfile) -> [Double] {
switch self {
case .armR, .armL: [body.upperArm, body.foreArm]
case .legR, .legL: [body.thigh, body.shin]
}
}
/// The authored `[upper, lower]` angles for this limb, if present in the frame.
func angles(in frame: MotionKeyFrame) -> [Double]? {
switch self {
case .armR: frame.armR
case .armL: frame.armL
case .legR: frame.legR
case .legL: frame.legL
}
}
}
/// A key frame resolved to pure angles (pinned limbs replaced by their IK solution),
/// so poses interpolate cleanly in angle space.
struct FigurePose {
var root: CGPoint
var spine: [Double]
var neck: Double
/// Absent for figures facing the viewer no nose tick.
var gaze: Double?
var limbs: [FigureLimb: [Double]]
/// Shoulder / hip: forward flexion, abduction away from the midline, external rotation.
struct BallJoint { var flexion, abduction, rotation: Double }
/// One spine segment: forward curl, right side-bend, turn-right rotation.
struct SpineSeg { var flexion, lateral, rotation: Double }
/// Neck: forward flexion plus turn-right rotation.
struct NeckJoint { var flexion, rotation: Double }
/// Elbow / knee / ankle: a single flexion angle (knees hinge backward automatically).
struct Hinge { var flexion: Double }
/// A key frame expanded to full anatomical dicts (defaults filled in), the shape FK
/// and tweening operate on. Pinned limbs' `Ball`/`Hinge` values are overwritten by the
/// IK solution during `resolve`, so poses always interpolate in anatomical space.
struct NormalizedFrame {
var rootPos: CGPoint
var yaw, pitch, roll: Double
var spine: [SpineSeg] // two chained segments
var neck: NeckJoint
var head: Double // extra gaze pitch (flexion)
var shoulderR, shoulderL, hipR, hipL: BallJoint
var elbowR, elbowL, kneeR, kneeL, ankleR, ankleL: Hinge
var pins: [String: CGPoint]
var hold: Double
var tween: Double
}
var hold, tween: Double
/// Trunk FK: pelvis, spine mid, neck joint, and the offset left-limb attachments.
struct FigureAttachments {
let pelvis: CGPoint
let mid: CGPoint
let neck: CGPoint
let shoulderL: CGPoint
let hipL: CGPoint
func ball(for limb: FigureLimb) -> BallJoint {
switch limb { case .armR: shoulderR; case .armL: shoulderL; case .legR: hipR; case .legL: hipL }
}
func lower(for limb: FigureLimb) -> Hinge {
switch limb { case .armR: elbowR; case .armL: elbowL; case .legR: kneeR; case .legL: kneeL }
}
func ankle(for limb: FigureLimb) -> Hinge { limb == .legR ? ankleR : ankleL }
func point(for limb: FigureLimb) -> CGPoint {
switch limb {
case .armR: neck
case .armL: shoulderL
case .legR: pelvis
case .legL: hipL
}
mutating func setUpper(_ b: BallJoint, for limb: FigureLimb) {
switch limb { case .armR: shoulderR = b; case .armL: shoulderL = b; case .legR: hipR = b; case .legL: hipL = b }
}
mutating func setLower(_ h: Hinge, for limb: FigureLimb) {
switch limb { case .armR: elbowR = h; case .armL: elbowL = h; case .legR: kneeR = h; case .legL: kneeL = h }
}
}
/// A pose resolved to drawable points.
/// Within a limb pair, the member nearer the camera (`near`) draws dark and in front;
/// the far member draws light, behind, and nudged by the readability offset.
enum Shade { case near, far }
/// A pose resolved to drawable canvas points (plus the depth-sorted draw order and
/// per-limb shading the view needs).
struct FigureGeometry {
var headCenter: CGPoint
var headRadius: Double
/// Nil when the pose has no gaze (face-on view).
var noseStart: CGPoint?
var noseEnd: CGPoint?
/// Nil when the face points at (or away from) the camera no nose tick.
var noseStart, noseEnd: CGPoint?
/// Quadratic Bézier through pelvis mid neck (control = 2·mid (pelvis+neck)/2).
var spineStart: CGPoint
var spineControl: CGPoint
var spineEnd: CGPoint
/// Attachment elbow/knee extremity, per drawn limb.
var spineStart, spineControl, spineEnd: CGPoint
/// Attach elbow/knee hand (arms: 3 points); hip knee ankle toe (legs: 4).
var limbs: [FigureLimb: [CGPoint]]
/// Parts far-to-near, `"head"` always last (`"spine"`, `"arm_r"`, then `"head"`).
var order: [String]
var shade: [FigureLimb: Shade]
}
/// FK output in view space (x right, y up, z toward the camera; origin at the root).
struct FigurePose {
var pelvis, mid, neckB, head: Vec3
var shoulderR, shoulderL, hipR, hipL: Vec3
var limbs: [FigureLimb: [Vec3]]
var f2, fRoot: Mat3 // spine-top and root frames, for IK inversion
var noseDir: Vec3
/// How side-on the view is: 1 in profile (lateral axis is pure depth), 0 face-on.
var k: Double
func attach(for limb: FigureLimb) -> Vec3 {
switch limb { case .armR: shoulderR; case .armL: shoulderL; case .legR: hipR; case .legL: hipL }
}
}
// MARK: - Solver
enum MotionSolver {
/// Unit vector for a y-up angle, in y-down canvas coordinates.
private static let depthBucket = 3.0
private static let pairs: [(FigureLimb, FigureLimb)] = [(.armR, .armL), (.legR, .legL)]
/// Fixed draw rank breaking depth-bucket ties (far parts first, `spine` mid-stack).
private static let fixedRank: [String: Int] = ["arm_l": 0, "leg_l": 1, "spine": 2, "arm_r": 3, "leg_r": 4]
/// Unit vector for a y-up angle in y-down canvas coordinates (props' fixed angles).
static func direction(_ degrees: Double) -> CGVector {
let r = degrees * .pi / 180
return CGVector(dx: cos(r), dy: -sin(r))
}
/// Chain FK: `[start, joint1, joint2, ]` for per-bone absolute angles.
static func walk(from start: CGPoint, angles: [Double], lengths: [Double]) -> [CGPoint] {
var points = [start]
var current = start
for (angle, length) in zip(angles, lengths) {
let d = direction(angle)
current = CGPoint(x: current.x + d.dx * length, y: current.y + d.dy * length)
points.append(current)
}
return points
}
// MARK: Normalize / tween
/// Y-up world angle (degrees) of the segment ab.
static func angle(from a: CGPoint, to b: CGPoint) -> Double {
atan2(-(b.y - a.y), b.x - a.x) * 180 / .pi
}
/// Analytic 2-bone IK: `[upper, lower]` angles reaching from `start` toward
/// `target`, choosing the elbow/knee solution nearest the authored guess.
static func ik2(start: CGPoint, target: CGPoint, upper a: Double, lower b: Double, guess: [Double]) -> [Double] {
let dx = target.x - start.x
let dyUp = -(target.y - start.y)
// Clamp the reach inside the chain's annulus so acos stays defined.
let d = max(abs(a - b) + 0.5, min(a + b - 0.01, hypot(dx, dyUp)))
let base = atan2(dyUp, dx) * 180 / .pi
let alpha = acos((a * a + d * d - b * b) / (2 * a * d)) * 180 / .pi
let guessElbow = walk(from: start, angles: guess, lengths: [a, b])[1]
var best: (distance: Double, angles: [Double])?
for sign in [1.0, -1.0] {
let upperAngle = base + sign * alpha
let elbow = walk(from: start, angles: [upperAngle], lengths: [a])[1]
let distance = Double(hypot(elbow.x - guessElbow.x, elbow.y - guessElbow.y))
if best == nil || distance < best!.distance {
best = (distance, [upperAngle, angle(from: elbow, to: target)])
}
}
return best!.angles
}
static func attachments(root: CGPoint, spine: [Double], body: ExerciseBodyProfile) -> FigureAttachments {
let mid = walk(from: root, angles: [spine[0]], lengths: [body.spine1])[1]
let neck = walk(from: mid, angles: [spine[1]], lengths: [body.spine2])[1]
let ox = body.leftOffset.count > 0 ? body.leftOffset[0] : 6
let oy = body.leftOffset.count > 1 ? body.leftOffset[1] : 2
return FigureAttachments(
pelvis: root, mid: mid, neck: neck,
shoulderL: CGPoint(x: neck.x + ox, y: neck.y + oy),
hipL: CGPoint(x: root.x + ox, y: root.y + oy)
)
}
/// Resolve a key frame to pure angles: replace each pinned limb's authored angles
/// with its IK solution (the authored angles only pick the bend direction).
static func normalize(_ kf: MotionKeyFrame, body: ExerciseBodyProfile) -> FigurePose {
let root = CGPoint(x: kf.root[0], y: kf.root[1])
/// Expand a key frame to a `NormalizedFrame` with defaults filled in.
static func normalize(_ kf: MotionKeyFrame) -> NormalizedFrame {
let spineValues = kf.spine ?? [.scalar(0), .scalar(0)]
let spine = spineValues.map { SpineSeg(flexion: $0.flexion, lateral: $0.lateral, rotation: $0.rotation) }
func ball(_ v: JointValue?) -> BallJoint { BallJoint(flexion: v?.flexion ?? 0, abduction: v?.abduction ?? 0, rotation: v?.rotation ?? 0) }
func hinge(_ v: JointValue?) -> Hinge { Hinge(flexion: v?.flexion ?? 0) }
var pins: [String: CGPoint] = [:]
for (key, xy) in kf.pins ?? [:] where xy.count == 2 {
pins[key] = CGPoint(x: xy[0], y: xy[1])
}
let at = attachments(root: root, spine: kf.spine, body: body)
var limbs: [FigureLimb: [Double]] = [:]
for limb in FigureLimb.allCases {
guard let authored = limb.angles(in: kf) else { continue }
if let pin = pins[limb.pinKey] {
let lengths = limb.boneLengths(body)
limbs[limb] = ik2(start: at.point(for: limb), target: pin,
upper: lengths[0], lower: lengths[1], guess: authored)
} else {
limbs[limb] = authored
}
}
return FigurePose(root: root, spine: kf.spine, neck: kf.neck, gaze: kf.gaze,
limbs: limbs, pins: pins,
hold: kf.hold ?? 0.5, tween: kf.tween ?? 0.6)
for (key, xy) in kf.pins ?? [:] where xy.count == 2 { pins[key] = CGPoint(x: xy[0], y: xy[1]) }
return NormalizedFrame(
rootPos: CGPoint(x: kf.root.pos[0], y: kf.root.pos[1]),
yaw: kf.root.yaw ?? 0, pitch: kf.root.pitch ?? 0, roll: kf.root.roll ?? 0,
spine: spine,
neck: NeckJoint(flexion: kf.neck?.flexion ?? 0, rotation: kf.neck?.rotation ?? 0),
head: kf.head?.flexion ?? 0,
shoulderR: ball(kf.shoulderR), shoulderL: ball(kf.shoulderL),
hipR: ball(kf.hipR), hipL: ball(kf.hipL),
elbowR: hinge(kf.elbowR), elbowL: hinge(kf.elbowL),
kneeR: hinge(kf.kneeR), kneeL: hinge(kf.kneeL),
ankleR: hinge(kf.ankleR), ankleL: hinge(kf.ankleL),
pins: pins,
hold: kf.hold ?? 0.5, tween: kf.tween ?? 0.6)
}
/// Ease-in-out: 3t² 2t³.
static func ease(_ t: Double) -> Double {
3 * t * t - 2 * t * t * t
}
static func ease(_ t: Double) -> Double { 3 * t * t - 2 * t * t * t }
/// Shortest-path angular interpolation, so limbs swing in natural arcs.
static func lerpAngle(_ a: Double, _ b: Double, _ t: Double) -> Double {
var delta = (b - a + 180).truncatingRemainder(dividingBy: 360)
if delta < 0 { delta += 360 }
return a + (delta - 180) * t
}
/// Interpolate two normalized poses. A pin survives the tween only if planted in
/// BOTH neighboring key frames (so planted limbs hold exactly and releasing pins
/// release naturally); a limb absent from either side is dropped for the tween.
static func lerp(_ a: FigurePose, _ b: FigurePose, _ t: Double) -> FigurePose {
var limbs: [FigureLimb: [Double]] = [:]
for (limb, va) in a.limbs {
guard let vb = b.limbs[limb] else { continue }
limbs[limb] = zip(va, vb).map { lerpAngle($0, $1, t) }
/// Interpolate two resolved frames plain linear per degree of freedom, so limbs
/// swing in natural anatomical arcs. A pin survives the tween only when planted in
/// BOTH neighboring key frames (a one-sided pin releases naturally).
static func lerpFrames(_ a: NormalizedFrame, _ b: NormalizedFrame, _ t: Double) -> NormalizedFrame {
func n(_ x: Double, _ y: Double) -> Double { x + (y - x) * t }
func lerpBall(_ p: BallJoint, _ q: BallJoint) -> BallJoint {
BallJoint(flexion: n(p.flexion, q.flexion), abduction: n(p.abduction, q.abduction), rotation: n(p.rotation, q.rotation))
}
func lerpHinge(_ p: Hinge, _ q: Hinge) -> Hinge { Hinge(flexion: n(p.flexion, q.flexion)) }
var pins: [String: CGPoint] = [:]
for (key, pa) in a.pins {
guard let pb = b.pins[key] else { continue }
pins[key] = CGPoint(x: pa.x + (pb.x - pa.x) * t, y: pa.y + (pb.y - pa.y) * t)
pins[key] = CGPoint(x: n(pa.x, pb.x), y: n(pa.y, pb.y))
}
return FigurePose(
root: CGPoint(x: a.root.x + (b.root.x - a.root.x) * t,
y: a.root.y + (b.root.y - a.root.y) * t),
spine: zip(a.spine, b.spine).map { lerpAngle($0, $1, t) },
neck: lerpAngle(a.neck, b.neck, t),
gaze: (a.gaze != nil && b.gaze != nil) ? lerpAngle(a.gaze!, b.gaze!, t) : nil,
limbs: limbs, pins: pins, hold: a.hold, tween: a.tween
)
return NormalizedFrame(
rootPos: CGPoint(x: n(a.rootPos.x, b.rootPos.x), y: n(a.rootPos.y, b.rootPos.y)),
yaw: n(a.yaw, b.yaw), pitch: n(a.pitch, b.pitch), roll: n(a.roll, b.roll),
spine: zip(a.spine, b.spine).map { SpineSeg(flexion: n($0.flexion, $1.flexion), lateral: n($0.lateral, $1.lateral), rotation: n($0.rotation, $1.rotation)) },
neck: NeckJoint(flexion: n(a.neck.flexion, b.neck.flexion), rotation: n(a.neck.rotation, b.neck.rotation)),
head: n(a.head, b.head),
shoulderR: lerpBall(a.shoulderR, b.shoulderR), shoulderL: lerpBall(a.shoulderL, b.shoulderL),
hipR: lerpBall(a.hipR, b.hipR), hipL: lerpBall(a.hipL, b.hipL),
elbowR: lerpHinge(a.elbowR, b.elbowR), elbowL: lerpHinge(a.elbowL, b.elbowL),
kneeR: lerpHinge(a.kneeR, b.kneeR), kneeL: lerpHinge(a.kneeL, b.kneeL),
ankleR: lerpHinge(a.ankleR, b.ankleR), ankleL: lerpHinge(a.ankleL, b.ankleL),
pins: pins, hold: a.hold, tween: a.tween)
}
/// Normalized pose drawable points. Limbs with an active pin are re-solved so
/// planted hands/feet hold that point exactly through tweens.
static func geometry(of pose: FigurePose, body: ExerciseBodyProfile, hide: Set<String>) -> FigureGeometry {
let at = attachments(root: pose.root, spine: pose.spine, body: body)
let head = walk(from: at.neck, angles: [pose.neck], lengths: [body.neck])[1]
let r = body.headR
// Nose tick: 7pt outward from the head rim along the gaze direction
// (omitted for face-on poses without a gaze).
var noseStart: CGPoint?
var noseEnd: CGPoint?
if let gaze = pose.gaze {
let d = direction(gaze)
noseStart = CGPoint(x: head.x + d.dx * r, y: head.y + d.dy * r)
noseEnd = CGPoint(x: head.x + d.dx * (r + 7), y: head.y + d.dy * (r + 7))
// MARK: Forward kinematics
/// Local rotation of a ball joint (shoulder/hip) for side sign `sigma`.
private static func ballMatrix(_ j: BallJoint, _ sigma: Double) -> Mat3 {
chain(Mat3.rotZ(j.flexion), Mat3.rotX(-sigma * j.abduction), Mat3.rotY(-sigma * j.rotation))
}
/// FK one limb from its resolved attach point (arm: [shoulder, elbow, hand];
/// leg: [hip, knee, ankle, toe]).
private static func fkLimb(_ limb: FigureLimb, attach: Vec3, upper: BallJoint, lower: Hinge, ankle: Hinge, prof: SkeletonProfile, parent: Mat3) -> [Vec3] {
let fu = parent.times(ballMatrix(upper, limb.sigma))
if limb.isArm {
let elbow = attach + fu.apply(Vec3(0, -prof.upperArm, 0))
let fl = fu.times(Mat3.rotZ(lower.flexion))
let hand = elbow + fl.apply(Vec3(0, -prof.foreArm, 0))
return [attach, elbow, hand]
}
let knee = attach + fu.apply(Vec3(0, -prof.thigh, 0))
let fl = fu.times(Mat3.rotZ(-lower.flexion))
let ankleJoint = knee + fl.apply(Vec3(0, -prof.shin, 0))
let toe = ankleJoint + fl.times(Mat3.rotZ(ankle.flexion)).apply(Vec3(prof.foot, 0, 0))
return [attach, knee, ankleJoint, toe]
}
/// FK a normalized frame into view space through the camera yaw.
static func pose(_ nf: NormalizedFrame, prof: SkeletonProfile, cam: Double) -> FigurePose {
let fRoot = chain(Mat3.rotY(-cam), Mat3.rotY(nf.yaw), Mat3.rotZ(-nf.pitch), Mat3.rotX(nf.roll))
let origin = Vec3(0, 0, 0)
let s1 = nf.spine[0], s2 = nf.spine[1]
let f1 = chain(fRoot, Mat3.rotZ(-s1.flexion), Mat3.rotX(s1.lateral), Mat3.rotY(-s1.rotation))
let mid = origin + f1.apply(Vec3(0, prof.spine1, 0))
let f2 = chain(f1, Mat3.rotZ(-s2.flexion), Mat3.rotX(s2.lateral), Mat3.rotY(-s2.rotation))
let neckB = mid + f2.apply(Vec3(0, prof.spine2, 0))
let fn = chain(f2, Mat3.rotZ(-nf.neck.flexion), Mat3.rotY(-nf.neck.rotation))
let head = neckB + fn.apply(Vec3(0, prof.neck, 0))
let noseDir = fn.times(Mat3.rotZ(-nf.head)).apply(Vec3(1, 0, 0))
var p = FigurePose(
pelvis: origin, mid: mid, neckB: neckB, head: head,
shoulderR: neckB + f2.apply(Vec3(0, 0, prof.shoulderHalf)),
shoulderL: neckB + f2.apply(Vec3(0, 0, -prof.shoulderHalf)),
hipR: origin + fRoot.apply(Vec3(0, 0, prof.hipHalf)),
hipL: origin + fRoot.apply(Vec3(0, 0, -prof.hipHalf)),
limbs: [:], f2: f2, fRoot: fRoot, noseDir: noseDir,
k: abs(fRoot.apply(Vec3(0, 0, 1)).z))
for limb in FigureLimb.allCases {
let parent = limb.isArm ? f2 : fRoot
p.limbs[limb] = fkLimb(limb, attach: p.attach(for: limb), upper: nf.ball(for: limb),
lower: nf.lower(for: limb), ankle: nf.ankle(for: limb), prof: prof, parent: parent)
}
return p
}
// MARK: Inverse kinematics
/// Analytic two-bone IK in 3D: reach from `attach` toward `target` in the plane
/// picked by the authored (FK) mid joint, then convert back to anatomical angles.
private static func solveLimb(_ limb: FigureLimb, attach: Vec3, target: Vec3, guessMid: Vec3, lengths: (Double, Double), parent: Mat3) -> (BallJoint, Hinge) {
let (a, b) = lengths
let toTarget = target - attach
let d = clampUnit(toTarget.length, abs(a - b) + 0.5, a + b - 0.01)
let dirTarget = toTarget.length > 1e-9 ? toTarget.normalized : Vec3(0, -1, 0)
var normal = dirTarget.cross(guessMid - attach)
if normal.length < 1e-6 {
normal = dirTarget.cross(Vec3(0, 0, 1))
if normal.length < 1e-6 { normal = dirTarget.cross(Vec3(0, 1, 0)) }
}
normal = normal.normalized
let perp = normal.cross(dirTarget)
let along = (a * a + d * d - b * b) / (2 * d)
let h = max(a * a - along * along, 0).squareRoot()
var best: (distance: Double, mid: Vec3)?
for sign in [1.0, -1.0] {
let mid = attach + (dirTarget.scaled(along) + perp.scaled(sign * h))
let distance = (mid - guessMid).length
if best == nil || distance < best!.distance { best = (distance, mid) }
}
let mid = best!.mid
let end = mid + (target - mid).normalized.scaled(b)
return invertLimb(limb, attach: attach, mid: mid, end: end, parent: parent)
}
/// Recover anatomical angles from limb joint positions (the inverse of `fkLimb`,
/// ignoring the foot). Assumes |abduction| < 90; the leg's rotation sign flips
/// because knees hinge backward.
private static func invertLimb(_ limb: FigureLimb, attach: Vec3, mid: Vec3, end: Vec3, parent: Mat3) -> (BallJoint, Hinge) {
let sigma = limb.sigma
let parentT = parent.transposed
let u = parentT.apply(mid - attach).normalized
let abduction = asin(clampUnit(sigma * u.z)) * 180 / .pi
let flexion = atan2(u.x, -u.y) * 180 / .pi
let peel = chain(Mat3.rotZ(flexion), Mat3.rotX(-sigma * abduction)).transposed
let w = peel.apply(parentT.apply(end - mid)).normalized
let bend = acos(clampUnit(-w.y)) * 180 / .pi
let rotation: Double
if limb.isArm {
rotation = bend > 0.5 ? sigma * atan2(w.z, w.x) * 180 / .pi : 0
} else {
rotation = bend > 0.5 ? sigma * atan2(-w.z, -w.x) * 180 / .pi : 0
}
return (BallJoint(flexion: flexion, abduction: abduction, rotation: rotation), Hinge(flexion: bend))
}
private static func viewFromCanvas(_ pt: CGPoint, anchor: CGPoint, depth: Double) -> Vec3 {
Vec3(pt.x - anchor.x, anchor.y - pt.y, depth)
}
/// Pose a frame and apply pins: for each pinned limb, solve IK against the canvas
/// target (at the limb's FK depth), write the solved angles back, and re-pose.
private static func resolve(_ nf: NormalizedFrame, prof: SkeletonProfile, cam: Double) -> (NormalizedFrame, FigurePose) {
var frame = nf
var p = pose(frame, prof: prof, cam: cam)
let anchor = frame.rootPos
var solved = false
for limb in FigureLimb.allCases {
guard let pin = frame.pins[limb.pinKey], let chainPts = p.limbs[limb] else { continue }
let attach = chainPts[0]
let target = viewFromCanvas(pin, anchor: anchor, depth: chainPts[2].z)
let lengths: (Double, Double) = limb.isArm ? (prof.upperArm, prof.foreArm) : (prof.thigh, prof.shin)
let parent = limb.isArm ? p.f2 : p.fRoot
let (upper, lower) = solveLimb(limb, attach: attach, target: target, guessMid: chainPts[1], lengths: lengths, parent: parent)
frame.setUpper(upper, for: limb)
frame.setLower(lower, for: limb)
solved = true
}
if solved { p = pose(frame, prof: prof, cam: cam) }
return (frame, p)
}
// MARK: Frame geometry
/// Python's `round(x)` uses banker's rounding; match it so depth buckets agree.
private static func bucket(_ depth: Double) -> Int { Int((depth / depthBucket).rounded(.toNearestOrEven)) }
private static func chainDepth(_ pts: [Vec3]) -> Double { pts.reduce(0) { $0 + $1.z } / Double(pts.count) }
/// Resolve a normalized frame into drawable geometry. Returns the frame with
/// IK-resolved angles (for tweening) plus the canvas geometry: near/far shading
/// (the near pair member draws dark and in front; canvas-right wins depth-bucket
/// ties in face-on views), the readability offset applied to the far member of
/// each pair and subtracted from a far pin's target before IK (authored pins
/// restored afterward), the depth-sorted draw order (head last), the spine curve,
/// and the foreshortened gaze nose tick (hidden when the face points at/away from
/// the camera).
static func frameGeometry(_ nf: NormalizedFrame, prof: SkeletonProfile, cam: Double) -> (NormalizedFrame, FigureGeometry) {
let p0 = pose(nf, prof: prof, cam: cam)
var shade: [FigureLimb: Shade] = [:]
for (right, left) in pairs {
guard let rp = p0.limbs[right], let lp = p0.limbs[left] else { continue }
let dr = chainDepth(rp), dl = chainDepth(lp)
let near: FigureLimb
if bucket(dr) == bucket(dl) {
near = rp[0].x >= lp[0].x ? right : left // view x == canvas offset from anchor
} else {
near = dr > dl ? right : left
}
shade[right] = near == right ? .near : .far
shade[left] = near == left ? .near : .far
}
let fo = prof.farOffset
let off = CGPoint(x: (fo.first ?? 6) * p0.k, y: (fo.count > 1 ? fo[1] : 2) * p0.k)
var work = nf
for limb in FigureLimb.allCases where shade[limb] == .far {
if let pin = work.pins[limb.pinKey] {
work.pins[limb.pinKey] = CGPoint(x: pin.x - off.x, y: pin.y - off.y)
}
}
var (resolved, p) = resolve(work, prof: prof, cam: cam)
resolved.pins = nf.pins // keep authored pins; only angles resolved
let anchor = nf.rootPos
func scr(_ v: Vec3, _ limbOffset: CGPoint = .zero) -> CGPoint {
CGPoint(x: anchor.x + v.x + limbOffset.x, y: anchor.y - v.y + limbOffset.y)
}
let pelvis = scr(p.pelvis), mid = scr(p.mid), neckB = scr(p.neckB)
let control = CGPoint(x: 2 * mid.x - (pelvis.x + neckB.x) / 2, y: 2 * mid.y - (pelvis.y + neckB.y) / 2)
var limbs: [FigureLimb: [CGPoint]] = [:]
for (limb, authored) in pose.limbs where !hide.contains(limb.rawValue) {
let lengths = limb.boneLengths(body)
var angles = authored
if let pin = pose.pins[limb.pinKey] {
angles = ik2(start: at.point(for: limb), target: pin,
upper: lengths[0], lower: lengths[1], guess: angles)
}
limbs[limb] = walk(from: at.point(for: limb), angles: angles, lengths: lengths)
var depths: [String: Double] = ["spine": chainDepth([p.pelvis, p.mid, p.neckB])]
for limb in FigureLimb.allCases {
guard let pts = p.limbs[limb] else { continue }
let limbOffset = shade[limb] == .far ? off : .zero
limbs[limb] = pts.map { scr($0, limbOffset) }
depths[limb.rawValue] = chainDepth(pts)
}
return FigureGeometry(
headCenter: head,
headRadius: r,
noseStart: noseStart,
noseEnd: noseEnd,
spineStart: at.pelvis,
spineControl: CGPoint(x: 2 * at.mid.x - (at.pelvis.x + at.neck.x) / 2,
y: 2 * at.mid.y - (at.pelvis.y + at.neck.y) / 2),
spineEnd: at.neck,
limbs: limbs
)
let head = scr(p.head)
var noseStart: CGPoint?, noseEnd: CGPoint?
let nose = p.noseDir
let mag = (nose.x * nose.x + nose.y * nose.y).squareRoot()
if mag > 0.3 {
let ux = nose.x / mag, uy = -nose.y / mag, r = prof.headR
noseStart = CGPoint(x: head.x + ux * r, y: head.y + uy * r)
noseEnd = CGPoint(x: head.x + ux * (r + 7 * mag), y: head.y + uy * (r + 7 * mag))
}
let order = depths.keys.sorted { lhs, rhs in
let bl = bucket(depths[lhs]!), br = bucket(depths[rhs]!)
if bl != br { return bl < br }
return (fixedRank[lhs] ?? 0) < (fixedRank[rhs] ?? 0)
} + ["head"]
let geo = FigureGeometry(
headCenter: head, headRadius: prof.headR, noseStart: noseStart, noseEnd: noseEnd,
spineStart: pelvis, spineControl: control, spineEnd: neckB,
limbs: limbs, order: order, shade: shade)
return (resolved, geo)
}
}
/// The full looping animation for one motion: normalized key poses plus
/// continuous-time sampling hold at each key frame, then an eased angle-space tween
/// to the next; the last frame tweens back to the first.
/// The full looping animation for one motion: key frames resolved to anatomical angles,
/// then continuous-time sampling hold at each key frame, then an eased anatomical-space
/// tween to the next; the last frame tweens back to the first.
struct MotionTimeline {
let poses: [FigurePose]
let resolved: [NormalizedFrame]
let profile: SkeletonProfile
let cam: Double
let duration: Double
init?(motion: ExerciseMotion, body: ExerciseBodyProfile) {
let poses = motion.frames.map { MotionSolver.normalize($0, body: body) }
let duration = poses.reduce(0) { $0 + $1.hold + $1.tween }
guard !poses.isEmpty, duration > 0 else { return nil }
self.poses = poses
init?(motion: ExerciseMotion, profile: SkeletonProfile) {
let cam = motion.camera?.yaw ?? 0
let norms = motion.frames.map { MotionSolver.normalize($0) }
guard !norms.isEmpty else { return nil }
let resolved = norms.map { MotionSolver.frameGeometry($0, prof: profile, cam: cam).0 }
let duration = resolved.reduce(0) { $0 + $1.hold + $1.tween }
guard duration > 0 else { return nil }
self.resolved = resolved
self.profile = profile
self.cam = cam
self.duration = duration
}
/// The pose at wall-clock `time`, looping every `duration` seconds.
func pose(at time: Double) -> FigurePose {
/// The resolved frame (or eased tween) at wall-clock `time`, looping every
/// `duration` seconds.
func frame(at time: Double) -> NormalizedFrame {
var t = time.truncatingRemainder(dividingBy: duration)
if t < 0 { t += duration }
for (i, pose) in poses.enumerated() {
if t < pose.hold { return pose }
t -= pose.hold
if t < pose.tween {
let next = poses[(i + 1) % poses.count]
return MotionSolver.lerp(pose, next, MotionSolver.ease(t / pose.tween))
for (i, f) in resolved.enumerated() {
if t < f.hold { return f }
t -= f.hold
if t < f.tween {
let next = resolved[(i + 1) % resolved.count]
return MotionSolver.lerpFrames(f, next, MotionSolver.ease(t / f.tween))
}
t -= pose.tween
t -= f.tween
}
return poses[0]
return resolved[0]
}
/// The drawable geometry at wall-clock `time`.
func geometry(at time: Double) -> FigureGeometry {
MotionSolver.frameGeometry(frame(at: time), prof: profile, cam: cam).1
}
}
@@ -2,7 +2,6 @@
"name": "Abdominal",
"primary": 2,
"working": ["spine"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[120, 128], [114, 62]], "w": 8},
@@ -16,18 +15,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
"root": [128, 126],
"spine": [94, 90], "neck": 82, "gaze": -8,
"arm_r": [-12, -52], "arm_l": [-12, -52],
"leg_r": [-20, -90], "leg_l": [-20, -90],
"root": {"pos": [128, 126], "pitch": -4},
"spine": [0, 4],
"neck": 8,
"shoulder_r": 78, "elbow_r": -40,
"shoulder_l": 78, "elbow_l": -40,
"hip_r": 66, "knee_r": 70,
"hip_l": 66, "knee_l": 70,
"pins": {"hand_r": [155, 62], "hand_l": [161, 64], "foot_r": [168, 150], "foot_l": [172, 150]}
},
{
"hold": 0.4, "tween": 1.0,
"root": [128, 126],
"spine": [80, 40], "neck": 22, "gaze": -42,
"arm_r": [-12, -52], "arm_l": [-12, -52],
"leg_r": [-20, -90], "leg_l": [-20, -90],
"root": {"pos": [128, 126], "pitch": 10},
"spine": [0, 40],
"neck": 18, "head": -26,
"shoulder_r": 128, "elbow_r": -40,
"shoulder_l": 128, "elbow_l": -40,
"hip_r": 80, "knee_r": 70,
"hip_l": 80, "knee_l": 70,
"pins": {"hand_r": [190, 76], "hand_l": [196, 78], "foot_r": [168, 150], "foot_l": [172, 150]}
}
]
@@ -1,9 +1,8 @@
{
"name": "Abductor",
"primary": 2,
"figure": "frontal",
"camera": {"yaw": 90},
"working": ["leg_r", "leg_l"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "rect", "x": 152, "y": 56, "w": 16, "h": 72, "r": 5},
@@ -16,17 +15,23 @@
"frames": [
{
"hold": 0.5, "tween": 1.1,
"root": [160, 116],
"spine": [90, 90], "neck": 90,
"arm_r": [-48, -82], "arm_l": [-132, -98],
"leg_r": [-75, -90], "leg_l": [-105, -90]
"root": {"pos": [160, 116]},
"spine": [0, 0],
"neck": 0,
"shoulder_r": {"flexion": 10, "abduction": 18}, "elbow_r": 25,
"shoulder_l": {"flexion": 10, "abduction": 18}, "elbow_l": 25,
"hip_r": {"flexion": 78, "abduction": 7}, "knee_r": 62,
"hip_l": {"flexion": 78, "abduction": 7}, "knee_l": 62
},
{
"hold": 0.5, "tween": 1.1,
"root": [160, 116],
"spine": [90, 90], "neck": 90,
"arm_r": [-48, -82], "arm_l": [-132, -98],
"leg_r": [-30, -90], "leg_l": [-150, -90]
"root": {"pos": [160, 116]},
"spine": [0, 0],
"neck": 0,
"shoulder_r": {"flexion": 10, "abduction": 18}, "elbow_r": 25,
"shoulder_l": {"flexion": 10, "abduction": 18}, "elbow_l": 25,
"hip_r": {"flexion": 78, "abduction": 30}, "knee_r": 62,
"hip_l": {"flexion": 78, "abduction": 30}, "knee_l": 62
}
]
}
@@ -1,9 +1,8 @@
{
"name": "Adductor",
"primary": 2,
"figure": "frontal",
"camera": {"yaw": 90},
"working": ["leg_r", "leg_l"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "rect", "x": 152, "y": 56, "w": 16, "h": 72, "r": 5},
@@ -16,17 +15,23 @@
"frames": [
{
"hold": 0.5, "tween": 1.1,
"root": [160, 116],
"spine": [90, 90], "neck": 90,
"arm_r": [-48, -82], "arm_l": [-132, -98],
"leg_r": [-30, -90], "leg_l": [-150, -90]
"root": {"pos": [160, 116]},
"spine": [0, 0],
"neck": 0,
"shoulder_r": {"flexion": 10, "abduction": 18}, "elbow_r": 25,
"shoulder_l": {"flexion": 10, "abduction": 18}, "elbow_l": 25,
"hip_r": {"flexion": 78, "abduction": 30}, "knee_r": 62,
"hip_l": {"flexion": 78, "abduction": 30}, "knee_l": 62
},
{
"hold": 0.5, "tween": 1.1,
"root": [160, 116],
"spine": [90, 90], "neck": 90,
"arm_r": [-48, -82], "arm_l": [-132, -98],
"leg_r": [-75, -90], "leg_l": [-105, -90]
"root": {"pos": [160, 116]},
"spine": [0, 0],
"neck": 0,
"shoulder_r": {"flexion": 10, "abduction": 18}, "elbow_r": 25,
"shoulder_l": {"flexion": 10, "abduction": 18}, "elbow_l": 25,
"hip_r": {"flexion": 78, "abduction": 7}, "knee_r": 62,
"hip_l": {"flexion": 78, "abduction": 7}, "knee_l": 62
}
]
}
@@ -2,7 +2,6 @@
"name": "Arm Curl",
"primary": 2,
"working": ["arm_r", "arm_l"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[116, 134], [150, 134]], "w": 8},
@@ -14,18 +13,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
"root": [130, 124],
"spine": [88, 86], "neck": 82, "gaze": -18,
"arm_r": [-35, -58], "arm_l": [-35, -58],
"leg_r": [-8, -72], "leg_l": [-8, -72],
"root": {"pos": [130, 124], "pitch": 2},
"spine": [0, 2],
"neck": 4, "head": 10,
"shoulder_r": 59, "elbow_r": -23,
"shoulder_l": 59, "elbow_l": -23,
"hip_r": 84, "knee_r": 64,
"hip_l": 84, "knee_l": 64,
"pins": {"foot_r": [176, 150], "foot_l": [180, 151]}
},
{
"hold": 0.4, "tween": 1.2,
"root": [130, 124],
"spine": [88, 86], "neck": 82, "gaze": -18,
"arm_r": [-35, 50], "arm_l": [-35, 50],
"leg_r": [-8, -72], "leg_l": [-8, -72],
"root": {"pos": [130, 124], "pitch": 2},
"spine": [0, 2],
"neck": 4, "head": 10,
"shoulder_r": 59, "elbow_r": 85,
"shoulder_l": 59, "elbow_l": 85,
"hip_r": 84, "knee_r": 64,
"hip_l": 84, "knee_l": 64,
"pins": {"foot_r": [176, 150], "foot_l": [180, 151]}
}
]
@@ -1,39 +1,51 @@
{
"name": "Bird Dog",
"primary": 2,
"working": ["arm_r", "leg_l", "arm_l", "leg_r"],
"working": ["arm_l", "leg_r", "arm_r", "leg_l"],
"frames": [
{
"hold": 0.5, "tween": 0.8,
"root": [190, 106],
"spine": [171, 171], "neck": 187, "gaze": 205,
"arm_r": [-90, -90], "arm_l": [-90, -90],
"leg_r": [-83, 0], "leg_l": [-83, 0],
"pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
"root": {"pos": [190, 106], "yaw": 180, "pitch": 81},
"spine": [0, 0],
"neck": 16, "head": -72,
"shoulder_r": 81, "elbow_r": 0,
"shoulder_l": 81, "elbow_l": 0,
"hip_r": 74, "knee_r": 83, "ankle_r": -55,
"hip_l": 74, "knee_l": 83, "ankle_l": -55,
"pins": {"hand_l": [105, 152], "hand_r": [111, 154]}
},
{
"hold": 1.4, "tween": 0.8,
"root": [190, 106],
"spine": [171, 171], "neck": 187, "gaze": 195,
"arm_r": [161, 161], "arm_l": [-90, -90],
"leg_r": [-83, 0], "leg_l": [15, 15],
"pins": {"hand_l": [111, 154]}
"root": {"pos": [190, 106], "yaw": 180, "pitch": 81},
"spine": [0, 0],
"neck": 16, "head": -82,
"shoulder_r": 81, "elbow_r": 0,
"shoulder_l": 190, "elbow_l": 0,
"hip_r": -24, "knee_r": 0, "ankle_r": -25,
"hip_l": 74, "knee_l": 83, "ankle_l": -55,
"pins": {"hand_r": [111, 154]}
},
{
"hold": 0.5, "tween": 0.8,
"root": [190, 106],
"spine": [171, 171], "neck": 187, "gaze": 205,
"arm_r": [-90, -90], "arm_l": [-90, -90],
"leg_r": [-83, 0], "leg_l": [-83, 0],
"pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
"root": {"pos": [190, 106], "yaw": 180, "pitch": 81},
"spine": [0, 0],
"neck": 16, "head": -72,
"shoulder_r": 81, "elbow_r": 0,
"shoulder_l": 81, "elbow_l": 0,
"hip_r": 74, "knee_r": 83, "ankle_r": -55,
"hip_l": 74, "knee_l": 83, "ankle_l": -55,
"pins": {"hand_l": [105, 152], "hand_r": [111, 154]}
},
{
"hold": 1.4, "tween": 0.8,
"root": [190, 106],
"spine": [171, 171], "neck": 187, "gaze": 195,
"arm_r": [-90, -90], "arm_l": [161, 161],
"leg_r": [15, 15], "leg_l": [-83, 0],
"pins": {"hand_r": [105, 152]}
"root": {"pos": [190, 106], "yaw": 180, "pitch": 81},
"spine": [0, 0],
"neck": 16, "head": -82,
"shoulder_r": 190, "elbow_r": 0,
"shoulder_l": 81, "elbow_l": 0,
"hip_r": 74, "knee_r": 83, "ankle_r": -55,
"hip_l": -24, "knee_l": 0, "ankle_l": -25,
"pins": {"hand_l": [105, 152]}
}
]
}
@@ -2,7 +2,6 @@
"name": "Calfs",
"primary": 2,
"working": ["leg_r", "leg_l"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[124, 126], [158, 124]], "w": 9},
@@ -18,18 +17,24 @@
"frames": [
{
"hold": 0.4, "tween": 0.9,
"root": [140, 118],
"spine": [100, 96], "neck": 80, "gaze": -10,
"arm_r": [-40, -15], "arm_l": [-40, -15],
"leg_r": [-18, -8], "leg_l": [-18, -8],
"root": {"pos": [140, 118], "pitch": -10},
"spine": [0, 4],
"neck": 16,
"shoulder_r": 44, "elbow_r": 25,
"shoulder_l": 44, "elbow_l": 25,
"hip_r": 62, "knee_r": -10, "ankle_r": 12,
"hip_l": 62, "knee_l": -10, "ankle_l": 12,
"pins": {"hand_r": [166, 74], "hand_l": [170, 76], "foot_r": [212, 148], "foot_l": [216, 150]}
},
{
"hold": 0.4, "tween": 1.2,
"root": [140, 118],
"spine": [100, 96], "neck": 80, "gaze": -10,
"arm_r": [-40, -15], "arm_l": [-40, -15],
"leg_r": [-14, 4], "leg_l": [-14, 4],
"root": {"pos": [140, 118], "pitch": -10},
"spine": [0, 4],
"neck": 16,
"shoulder_r": 44, "elbow_r": 25,
"shoulder_l": 44, "elbow_l": 25,
"hip_r": 66, "knee_r": -18, "ankle_r": -18,
"hip_l": 66, "knee_l": -18, "ankle_l": -18,
"pins": {"hand_r": [166, 74], "hand_l": [170, 76], "foot_r": [212, 139], "foot_l": [216, 141]}
}
]
@@ -5,19 +5,25 @@
"frames": [
{
"hold": 0.8, "tween": 0.9,
"root": [190, 106],
"spine": [188, 155], "neck": 185, "gaze": 164,
"arm_r": [-90, -90], "arm_l": [-90, -90],
"leg_r": [-83, 0], "leg_l": [-83, 0],
"pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
"root": {"pos": [190, 106], "yaw": 180, "pitch": 98},
"spine": [0, -33],
"neck": 30, "head": -111,
"shoulder_r": 65, "elbow_r": 0,
"shoulder_l": 65, "elbow_l": 0,
"hip_r": 91, "knee_r": 83, "ankle_r": -55,
"hip_l": 91, "knee_l": 83, "ankle_l": -55,
"pins": {"hand_l": [105, 152], "hand_r": [111, 154]}
},
{
"hold": 0.8, "tween": 0.9,
"root": [190, 106],
"spine": [155, 192], "neck": 215, "gaze": 225,
"arm_r": [-90, -90], "arm_l": [-90, -90],
"leg_r": [-83, 0], "leg_l": [-83, 0],
"pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
"root": {"pos": [190, 106], "yaw": 180, "pitch": 65},
"spine": [0, 37],
"neck": 23, "head": -80,
"shoulder_r": 102, "elbow_r": 0,
"shoulder_l": 102, "elbow_l": 0,
"hip_r": 58, "knee_r": 83, "ankle_r": -55,
"hip_l": 58, "knee_l": 83, "ankle_l": -55,
"pins": {"hand_l": [105, 152], "hand_r": [111, 154]}
}
]
}
@@ -2,7 +2,6 @@
"name": "Chest Press",
"primary": 2,
"working": ["arm_r", "arm_l"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[114, 132], [86, 40]], "w": 8},
@@ -15,18 +14,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.1,
"root": [116, 122],
"spine": [104, 100], "neck": 84, "gaze": 5,
"arm_r": [-100, 40], "arm_l": [-100, 40],
"leg_r": [-22, -72], "leg_l": [-22, -72],
"root": {"pos": [116, 122], "pitch": -14},
"spine": [0, 4],
"neck": 16, "head": -11,
"shoulder_r": -20, "elbow_r": 140,
"shoulder_l": -20, "elbow_l": 140,
"hip_r": 54, "knee_r": 50,
"hip_l": 54, "knee_l": 50,
"pins": {"foot_r": [158, 151], "foot_l": [162, 152]}
},
{
"hold": 0.4, "tween": 1.1,
"root": [116, 122],
"spine": [104, 100], "neck": 84, "gaze": 5,
"arm_r": [-6, -14], "arm_l": [-6, -14],
"leg_r": [-22, -72], "leg_l": [-22, -72],
"root": {"pos": [116, 122], "pitch": -14},
"spine": [0, 4],
"neck": 16, "head": -11,
"shoulder_r": 74, "elbow_r": -8,
"shoulder_l": 74, "elbow_l": -8,
"hip_r": 54, "knee_r": 50,
"hip_l": 54, "knee_l": 50,
"pins": {"foot_r": [158, 151], "foot_l": [162, 152]}
}
]
@@ -5,31 +5,43 @@
"frames": [
{
"hold": 0.5, "tween": 0.8,
"root": [185, 148],
"spine": [180, 180], "neck": 168, "gaze": 90,
"arm_r": [90, 90], "arm_l": [90, 90],
"leg_r": [64, -10], "leg_l": [64, -10]
"root": {"pos": [185, 148], "pitch": -90},
"spine": [0, 0],
"neck": 12, "head": -12,
"shoulder_r": 90, "elbow_r": 0,
"shoulder_l": 90, "elbow_l": 0,
"hip_r": 64, "knee_r": 74,
"hip_l": 64, "knee_l": 74
},
{
"hold": 0.9, "tween": 0.8,
"root": [185, 148],
"spine": [180, 180], "neck": 168, "gaze": 90,
"arm_r": [135, 135], "arm_l": [90, 90],
"leg_r": [64, -10], "leg_l": [16, 16]
"root": {"pos": [185, 148], "pitch": -90},
"spine": [0, 0],
"neck": 12, "head": -12,
"shoulder_r": 135, "elbow_r": 0,
"shoulder_l": 90, "elbow_l": 0,
"hip_r": 64, "knee_r": 74,
"hip_l": 16, "knee_l": 0
},
{
"hold": 0.5, "tween": 0.8,
"root": [185, 148],
"spine": [180, 180], "neck": 168, "gaze": 90,
"arm_r": [90, 90], "arm_l": [90, 90],
"leg_r": [64, -10], "leg_l": [64, -10]
"root": {"pos": [185, 148], "pitch": -90},
"spine": [0, 0],
"neck": 12, "head": -12,
"shoulder_r": 90, "elbow_r": 0,
"shoulder_l": 90, "elbow_l": 0,
"hip_r": 64, "knee_r": 74,
"hip_l": 64, "knee_l": 74
},
{
"hold": 0.9, "tween": 0.8,
"root": [185, 148],
"spine": [180, 180], "neck": 168, "gaze": 90,
"arm_r": [90, 90], "arm_l": [135, 135],
"leg_r": [16, 16], "leg_l": [64, -10]
"root": {"pos": [185, 148], "pitch": -90},
"spine": [0, 0],
"neck": 12, "head": -12,
"shoulder_r": 90, "elbow_r": 0,
"shoulder_l": 135, "elbow_l": 0,
"hip_r": 16, "knee_r": 0,
"hip_l": 64, "knee_l": 74
}
]
}
@@ -6,17 +6,21 @@
"frames": [
{
"hold": 0.4, "tween": 0.8,
"root": [185, 148],
"spine": [180, 180], "neck": 168, "gaze": 90,
"arm_r": [180, 180],
"leg_r": [0, 0], "leg_l": [0, 0]
"root": {"pos": [185, 148], "pitch": -90},
"spine": [0, 0],
"neck": 12, "head": -12,
"shoulder_r": 180, "elbow_r": 0,
"hip_r": 0, "knee_r": 0,
"hip_l": 0, "knee_l": 0
},
{
"hold": 1.8, "tween": 0.8,
"root": [185, 148],
"spine": [174, 160], "neck": 140, "gaze": 50,
"arm_r": [155, 155],
"leg_r": [12, 12], "leg_l": [12, 12]
"root": {"pos": [185, 148], "pitch": -84},
"spine": [0, 14],
"neck": 20,
"shoulder_r": 175, "elbow_r": 0,
"hip_r": 18, "knee_r": 0,
"hip_l": 18, "knee_l": 0
}
]
}
@@ -2,7 +2,6 @@
"name": "Lat Pull Down",
"primary": 2,
"working": ["arm_r", "arm_l"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[126, 130], [162, 130]], "w": 8},
@@ -15,21 +14,25 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
"root": [150, 122],
"spine": [93, 90], "neck": 85, "gaze": 55,
"arm_r": [35, 35], "arm_l": [35, 35],
"leg_r": [-12, -80], "leg_l": [-12, -80],
"pins": {"hand_r": [194, 1], "hand_l": [198, 3],
"foot_r": [193, 150], "foot_l": [197, 151]}
"root": {"pos": [150, 122], "pitch": -3},
"spine": [0, 3],
"neck": 5, "head": -60,
"shoulder_r": 125, "elbow_r": 0,
"shoulder_l": 125, "elbow_l": 0,
"hip_r": 75, "knee_r": 68,
"hip_l": 75, "knee_l": 68,
"pins": {"hand_r": [194, 1], "hand_l": [198, 3], "foot_r": [193, 150], "foot_l": [197, 151]}
},
{
"hold": 0.4, "tween": 1.2,
"root": [150, 122],
"spine": [93, 90], "neck": 85, "gaze": 30,
"arm_r": [-38, 38], "arm_l": [-38, 38],
"leg_r": [-12, -80], "leg_l": [-12, -80],
"pins": {"hand_r": [194, 35], "hand_l": [198, 37],
"foot_r": [193, 150], "foot_l": [197, 151]}
"root": {"pos": [150, 122], "pitch": -3},
"spine": [0, 3],
"neck": 5, "head": -35,
"shoulder_r": 52, "elbow_r": 76,
"shoulder_l": 52, "elbow_l": 76,
"hip_r": 75, "knee_r": 68,
"hip_l": 75, "knee_l": 68,
"pins": {"hand_r": [194, 35], "hand_l": [198, 37], "foot_r": [193, 150], "foot_l": [197, 151]}
}
]
}
@@ -2,7 +2,6 @@
"name": "Leg Curl",
"primary": 2,
"working": ["leg_r", "leg_l"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[110, 122], [86, 48]], "w": 9},
@@ -18,17 +17,23 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
"root": [118, 116],
"spine": [110, 106], "neck": 74, "gaze": -20,
"arm_r": [-75, -60], "arm_l": [-75, -60],
"leg_r": [10, 4], "leg_l": [10, 4]
"root": {"pos": [118, 116], "pitch": -20},
"spine": [0, 4],
"neck": 32, "head": 4,
"shoulder_r": -1, "elbow_r": 15,
"shoulder_l": -1, "elbow_l": 15,
"hip_r": 80, "knee_r": 6,
"hip_l": 80, "knee_l": 6
},
{
"hold": 0.4, "tween": 1.2,
"root": [118, 116],
"spine": [110, 106], "neck": 74, "gaze": -20,
"arm_r": [-75, -60], "arm_l": [-75, -60],
"leg_r": [10, -95], "leg_l": [10, -95]
"root": {"pos": [118, 116], "pitch": -20},
"spine": [0, 4],
"neck": 32, "head": 4,
"shoulder_r": -1, "elbow_r": 15,
"shoulder_l": -1, "elbow_l": 15,
"hip_r": 80, "knee_r": 105,
"hip_l": 80, "knee_l": 105
}
]
}
@@ -2,7 +2,6 @@
"name": "Leg Extension",
"primary": 2,
"working": ["leg_r", "leg_l"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[110, 122], [86, 48]], "w": 9},
@@ -17,17 +16,23 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
"root": [118, 116],
"spine": [110, 106], "neck": 74, "gaze": -20,
"arm_r": [-75, -60], "arm_l": [-75, -60],
"leg_r": [10, -83], "leg_l": [10, -83]
"root": {"pos": [118, 116], "pitch": -20},
"spine": [0, 4],
"neck": 32, "head": 4,
"shoulder_r": -1, "elbow_r": 15,
"shoulder_l": -1, "elbow_l": 15,
"hip_r": 80, "knee_r": 93,
"hip_l": 80, "knee_l": 93
},
{
"hold": 0.4, "tween": 1.2,
"root": [118, 116],
"spine": [110, 106], "neck": 74, "gaze": -20,
"arm_r": [-75, -60], "arm_l": [-75, -60],
"leg_r": [10, 4], "leg_l": [10, 4]
"root": {"pos": [118, 116], "pitch": -20},
"spine": [0, 4],
"neck": 32, "head": 4,
"shoulder_r": -1, "elbow_r": 15,
"shoulder_l": -1, "elbow_l": 15,
"hip_r": 80, "knee_r": 6,
"hip_l": 80, "knee_l": 6
}
]
}
@@ -2,7 +2,6 @@
"name": "Leg Press",
"primary": 2,
"working": ["leg_r", "leg_l"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[134, 123], [96, 36]], "w": 9},
@@ -17,18 +16,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
"root": [140, 116],
"spine": [116, 112], "neck": 75, "gaze": -15,
"arm_r": [-75, -20], "arm_l": [-75, -20],
"leg_r": [40, -58], "leg_l": [40, -58],
"root": {"pos": [140, 116], "pitch": -26},
"spine": [0, 4],
"neck": 37,
"shoulder_r": -7, "elbow_r": 55,
"shoulder_l": -7, "elbow_l": 55,
"hip_r": 104, "knee_r": 98,
"hip_l": 104, "knee_l": 98,
"pins": {"foot_r": [185, 102], "foot_l": [189, 104]}
},
{
"hold": 0.4, "tween": 1.2,
"root": [140, 116],
"spine": [116, 112], "neck": 75, "gaze": -15,
"arm_r": [-75, -20], "arm_l": [-75, -20],
"leg_r": [20, -5], "leg_l": [20, -5],
"root": {"pos": [140, 116], "pitch": -26},
"spine": [0, 4],
"neck": 37,
"shoulder_r": -7, "elbow_r": 55,
"shoulder_l": -7, "elbow_l": 55,
"hip_r": 84, "knee_r": 25,
"hip_l": 84, "knee_l": 25,
"pins": {"foot_r": [220, 101], "foot_l": [224, 103]}
}
]
@@ -6,17 +6,21 @@
"frames": [
{
"hold": 0.5, "tween": 1.4,
"root": [185, 148],
"spine": [180, 180], "neck": 168, "gaze": 90,
"arm_l": [-2, -2],
"leg_r": [60, 60], "leg_l": [60, 60]
"root": {"pos": [185, 148], "pitch": -90},
"spine": [0, 0],
"neck": 12, "head": -12,
"shoulder_l": -2, "elbow_l": 0,
"hip_r": 60, "knee_r": 0,
"hip_l": 60, "knee_l": 0
},
{
"hold": 0.3, "tween": 0.6,
"root": [185, 148],
"spine": [180, 180], "neck": 168, "gaze": 90,
"arm_l": [-2, -2],
"leg_r": [8, 8], "leg_l": [8, 8]
"root": {"pos": [185, 148], "pitch": -90},
"spine": [0, 0],
"neck": 12, "head": -12,
"shoulder_l": -2, "elbow_l": 0,
"hip_r": 8, "knee_r": 0,
"hip_l": 8, "knee_l": 0
}
]
}
@@ -5,17 +5,23 @@
"frames": [
{
"hold": 0.4, "tween": 0.7,
"root": [172, 114],
"spine": [185, 185], "neck": 185, "gaze": 210,
"arm_r": [-90, 180], "arm_l": [-90, 180],
"leg_r": [-55, 0], "leg_l": [-55, 0]
"root": {"pos": [172, 114], "yaw": 180, "pitch": 95},
"spine": [0, 0],
"neck": 0, "head": -65,
"shoulder_r": 95, "elbow_r": 90,
"shoulder_l": 95, "elbow_l": 90,
"hip_r": 60, "knee_r": 55,
"hip_l": 60, "knee_l": 55
},
{
"hold": 1.8, "tween": 0.7,
"root": [168, 137],
"spine": [170, 170], "neck": 170, "gaze": 210,
"arm_r": [-90, 180], "arm_l": [-90, 180],
"leg_r": [-10, -10], "leg_l": [-10, -10]
"root": {"pos": [168, 137], "yaw": 180, "pitch": 80},
"spine": [0, 0],
"neck": 0, "head": -50,
"shoulder_r": 80, "elbow_r": 90,
"shoulder_l": 80, "elbow_l": 90,
"hip_r": 0, "knee_r": 0,
"hip_l": 0, "knee_l": 0
}
]
}
@@ -6,17 +6,21 @@
"frames": [
{
"hold": 0.5, "tween": 0.7,
"root": [172, 148],
"spine": [180, 180], "neck": 168, "gaze": 90,
"arm_l": [-2, -2],
"leg_r": [100, -20], "leg_l": [100, -20]
"root": {"pos": [172, 148], "pitch": -90},
"spine": [0, 0],
"neck": 12, "head": -12,
"shoulder_l": -2, "elbow_l": 0,
"hip_r": 100, "knee_r": 120,
"hip_l": 100, "knee_l": 120
},
{
"hold": 0.8, "tween": 0.9,
"root": [166, 136],
"spine": [195, 175], "neck": 182, "gaze": 90,
"arm_l": [-2, -2],
"leg_r": [115, -10], "leg_l": [115, -10]
"root": {"pos": [166, 136], "pitch": -105},
"spine": [0, 20],
"neck": -7, "head": 2,
"shoulder_l": 3, "elbow_l": 0,
"hip_r": 100, "knee_r": 125,
"hip_l": 100, "knee_l": 125
}
]
}
@@ -1,8 +1,8 @@
{
"name": "Rotary",
"primary": 1,
"camera": {"yaw": 90},
"working": ["spine"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[138, 130], [182, 130]], "w": 8},
@@ -14,19 +14,23 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
"root": [160, 122],
"spine": [104, 109], "neck": 109,
"arm_r": [-50, -85], "arm_l": [-55, -90],
"leg_r": [-62, -82], "leg_l": [-118, -98],
"pins": {"hand_r": [112, 72], "hand_l": [118, 74], "foot_r": [190, 150], "foot_l": [130, 150]}
"root": {"pos": [160, 122]},
"spine": [{"rotation": -28}, {"rotation": -22}],
"neck": {"rotation": 18},
"shoulder_r": 70, "elbow_r": 10,
"shoulder_l": 70, "elbow_l": 10,
"hip_r": {"flexion": 82, "abduction": 16}, "knee_r": 70,
"hip_l": {"flexion": 82, "abduction": 16}, "knee_l": 70
},
{
"hold": 0.4, "tween": 1.0,
"root": [160, 122],
"spine": [76, 71], "neck": 71,
"arm_r": [-125, -95], "arm_l": [-130, -90],
"leg_r": [-62, -82], "leg_l": [-118, -98],
"pins": {"hand_r": [208, 72], "hand_l": [202, 74], "foot_r": [190, 150], "foot_l": [130, 150]}
"root": {"pos": [160, 122]},
"spine": [{"rotation": 28}, {"rotation": 22}],
"neck": {"rotation": -18},
"shoulder_r": 70, "elbow_r": 10,
"shoulder_l": 70, "elbow_l": 10,
"hip_r": {"flexion": 82, "abduction": 16}, "knee_r": 70,
"hip_l": {"flexion": 82, "abduction": 16}, "knee_l": 70
}
]
}
@@ -2,7 +2,6 @@
"name": "Seated Row",
"primary": 2,
"working": ["arm_r", "arm_l"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[122, 132], [156, 132]], "w": 8},
@@ -15,21 +14,25 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
"root": [140, 122],
"spine": [85, 82], "neck": 80, "gaze": 5,
"arm_r": [-40, -8], "arm_l": [-40, -8],
"leg_r": [-8, -75], "leg_l": [-8, -75],
"pins": {"hand_r": [200, 62], "hand_l": [204, 64],
"foot_r": [188, 148], "foot_l": [192, 149]}
"root": {"pos": [140, 122], "pitch": 5},
"spine": [0, 3],
"neck": 2, "head": -15,
"shoulder_r": 58, "elbow_r": 32,
"shoulder_l": 58, "elbow_l": 32,
"hip_r": 87, "knee_r": 67,
"hip_l": 87, "knee_l": 67,
"pins": {"hand_r": [200, 62], "hand_l": [204, 64], "foot_r": [188, 148], "foot_l": [192, 149]}
},
{
"hold": 0.4, "tween": 1.2,
"root": [140, 122],
"spine": [85, 82], "neck": 80, "gaze": 5,
"arm_r": [-120, -35], "arm_l": [-120, -35],
"leg_r": [-8, -75], "leg_l": [-8, -75],
"pins": {"hand_r": [158, 72], "hand_l": [162, 74],
"foot_r": [188, 148], "foot_l": [192, 149]}
"root": {"pos": [140, 122], "pitch": 5},
"spine": [0, 3],
"neck": 2, "head": -15,
"shoulder_r": -22, "elbow_r": 85,
"shoulder_l": -22, "elbow_l": 85,
"hip_r": 87, "knee_r": 67,
"hip_l": 87, "knee_l": 67,
"pins": {"hand_r": [158, 72], "hand_l": [162, 74], "foot_r": [188, 148], "foot_l": [192, 149]}
}
]
}
@@ -2,7 +2,6 @@
"name": "Shoulder Press",
"primary": 2,
"working": ["arm_r", "arm_l"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[116, 140], [88, 50]], "w": 8},
@@ -15,18 +14,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.1,
"root": [120, 130],
"spine": [110, 104], "neck": 80, "gaze": 8,
"arm_r": [-60, 90], "arm_l": [-60, 90],
"leg_r": [-25, -72], "leg_l": [-25, -72],
"root": {"pos": [120, 130], "pitch": -20},
"spine": [0, 6],
"neck": 24, "head": -18,
"shoulder_r": 16, "elbow_r": 150,
"shoulder_l": 16, "elbow_l": 150,
"hip_r": 45, "knee_r": 47,
"hip_l": 45, "knee_l": 47,
"pins": {"foot_r": [164, 151], "foot_l": [168, 152]}
},
{
"hold": 0.4, "tween": 1.1,
"root": [120, 130],
"spine": [110, 104], "neck": 80, "gaze": 8,
"arm_r": [54, 36], "arm_l": [54, 36],
"leg_r": [-25, -72], "leg_l": [-25, -72],
"root": {"pos": [120, 130], "pitch": -20},
"spine": [0, 6],
"neck": 24, "head": -18,
"shoulder_r": 130, "elbow_r": -18,
"shoulder_l": 130, "elbow_l": -18,
"hip_r": 45, "knee_r": 47,
"hip_l": 45, "knee_l": 47,
"pins": {"foot_r": [164, 151], "foot_l": [168, 152]}
}
]
@@ -5,19 +5,25 @@
"frames": [
{
"hold": 0.4, "tween": 0.8,
"root": [174, 150],
"spine": [162, 162], "neck": 162, "gaze": 175,
"arm_r": [-90, 180], "arm_l": [-20, -15],
"leg_r": [-1, -1], "leg_l": [-1, -1],
"pins": {"hand_r": [60, 152]}
"root": {"pos": [174, 150], "yaw": 180, "pitch": 72},
"spine": [0, 0],
"neck": 0, "head": -77,
"shoulder_r": 2, "elbow_r": -5,
"shoulder_l": 72, "elbow_l": 90,
"hip_r": -17, "knee_r": 0,
"hip_l": -17, "knee_l": 0,
"pins": {"hand_l": [60, 152]}
},
{
"hold": 1.8, "tween": 0.8,
"root": [174, 137],
"spine": [170, 170], "neck": 170, "gaze": 180,
"arm_r": [-90, 180], "arm_l": [80, 80],
"leg_r": [-10, -10], "leg_l": [-10, -10],
"pins": {"hand_r": [60, 152]}
"root": {"pos": [174, 137], "yaw": 180, "pitch": 80},
"spine": [0, 0],
"neck": 0, "head": -80,
"shoulder_r": -90, "elbow_r": 0,
"shoulder_l": 80, "elbow_l": 90,
"hip_r": 0, "knee_r": 0,
"hip_l": 0, "knee_l": 0,
"pins": {"hand_l": [60, 152]}
}
]
}
@@ -2,7 +2,6 @@
"name": "Tricep Press",
"primary": 2,
"working": ["arm_r", "arm_l"],
"hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[116, 134], [92, 42]], "w": 8},
@@ -15,21 +14,25 @@
"frames": [
{
"hold": 0.4, "tween": 1.1,
"root": [118, 124],
"spine": [100, 98], "neck": 86, "gaze": 6,
"arm_r": [-108, 12], "arm_l": [-108, 12],
"leg_r": [-24, -74], "leg_l": [-24, -74],
"pins": {"hand_r": [124, 62], "hand_l": [128, 64],
"foot_r": [156, 151], "foot_l": [160, 152]}
"root": {"pos": [118, 124], "pitch": -10},
"spine": [0, 2],
"neck": 12, "head": -10,
"shoulder_r": -26, "elbow_r": 120,
"shoulder_l": -26, "elbow_l": 120,
"hip_r": 56, "knee_r": 50,
"hip_l": 56, "knee_l": 50,
"pins": {"hand_r": [124, 62], "hand_l": [128, 64], "foot_r": [156, 151], "foot_l": [160, 152]}
},
{
"hold": 0.4, "tween": 1.1,
"root": [118, 124],
"spine": [100, 98], "neck": 86, "gaze": 6,
"arm_r": [-81, -61], "arm_l": [-81, -61],
"leg_r": [-24, -74], "leg_l": [-24, -74],
"pins": {"hand_r": [124, 96], "hand_l": [128, 98],
"foot_r": [156, 151], "foot_l": [160, 152]}
"root": {"pos": [118, 124], "pitch": -10},
"spine": [0, 2],
"neck": 12, "head": -10,
"shoulder_r": 1, "elbow_r": 20,
"shoulder_l": 1, "elbow_l": 20,
"hip_r": 56, "knee_r": 50,
"hip_l": 56, "knee_l": 50,
"pins": {"hand_r": [124, 96], "hand_l": [128, 98], "foot_r": [156, 151], "foot_l": [160, 152]}
}
]
}
@@ -1,35 +0,0 @@
{
"neutral": {
"headR": 10,
"neck": 20,
"spine1": 43,
"spine2": 42,
"upperArm": 30,
"foreArm": 30,
"thigh": 46,
"shin": 40,
"leftOffset": [6, 2]
},
"frontal": {
"headR": 10,
"neck": 20,
"spine1": 43,
"spine2": 42,
"upperArm": 30,
"foreArm": 30,
"thigh": 22,
"shin": 26,
"leftOffset": [6, 2]
},
"female": {
"headR": 9,
"neck": 19,
"spine1": 40,
"spine2": 39,
"upperArm": 27,
"foreArm": 27,
"thigh": 47,
"shin": 41,
"leftOffset": [6, 2]
}
}
@@ -0,0 +1,56 @@
{
"profiles": {
"neutral": {
"headR": 10,
"neck": 20,
"spine1": 43,
"spine2": 42,
"upperArm": 30,
"foreArm": 30,
"thigh": 46,
"shin": 40,
"foot": 11,
"shoulderHalf": 7,
"hipHalf": 5,
"farOffset": [6, 2]
},
"female": {
"headR": 9,
"neck": 19,
"spine1": 40,
"spine2": 39,
"upperArm": 27,
"foreArm": 27,
"thigh": 47,
"shin": 41,
"foot": 10,
"shoulderHalf": 5.5,
"hipHalf": 6.5,
"farOffset": [6, 2]
},
"male": {
"headR": 10,
"neck": 20,
"spine1": 44,
"spine2": 43,
"upperArm": 31,
"foreArm": 31,
"thigh": 47,
"shin": 41,
"foot": 11.5,
"shoulderHalf": 9,
"hipHalf": 4.5,
"farOffset": [6, 2]
}
},
"joints": {
"spine": {"flexion": [-40, 55], "lateral": [-35, 35], "rotation": [-55, 55]},
"neck": {"flexion": [-55, 55], "rotation": [-80, 80]},
"head": {"flexion": [-120, 120]},
"shoulder": {"flexion": [-70, 195], "abduction": [-40, 185], "rotation": [-95, 95]},
"elbow": {"flexion": [-10, 155]},
"hip": {"flexion": [-30, 130], "abduction": [-40, 55], "rotation": [-50, 50]},
"knee": {"flexion": [-10, 150]},
"ankle": {"flexion": [-60, 30]}
}
}