Three solver defects made limbs teleport, twist, or windmill: write-back angles wrapped at ±180 and lerped the long way around; branch flips landed on configurations the anatomical write-back cannot represent, silently pulling pinned extremities off their pins; and the degenerate straight-limb bend plane fell back to the camera axis instead of the anatomical anterior. solve_limb now verifies each branch reproduces the solved end before accepting it, resolve unwraps written-back angles toward the pose they replace, and the degenerate plane comes from the parent's anterior axis. render.py --check replays every exercise's full tween loop and fails hard on six invariants (pin fidelity, continuity, wraps, authored-vs-resolved drift, ground penetration, resolved ROM); --export refuses to ship a failing exercise. All 66 motions re-authored or retouched to pass: honest authored angles where pins used to override them silently, grounded feet on the seated machines, a vertical bench-press bar path, straight-armed child's pose, a butterfly stretch seated on the mat, and FK arms where pins forced impossible reaches. MotionSolver.swift mirrors the solver changes line for line, held by regenerated fixtures. Claude-Session: https://claude.ai/code/session_01PKptrgbx74peTwHGRxBojv
16 KiB
Exercise Visual System
Exercise visuals are produced by an anatomical 3D rig: one shared skeleton posed per exercise by real joint angles, projected orthographically onto the canvas. Nothing is drawn by hand — a skeleton profile plus a motion script resolve through 3D forward kinematics into every frame, so figures are always in proportion and anatomically plausible, and the whole library can be re-proportioned (male/female), viewed from any side, orbited while animating, or re-themed by changing data, never artwork.
The rig
Model space follows the biomechanics (ISB) convention: X anterior (the
figure's facing direction), Y up, Z toward the figure's anatomical
right. Every joint angle is measured in degrees from the neutral standing
pose — upright, arms hanging, legs straight, toes forward. The math lives in
kinematics.py; the conventions are:
- flexion — forward positive, everywhere: shoulder flexion raises the arm forward/overhead (0 hanging, 90 horizontal, 180 overhead — normalized flexion-dominant, so an arm past vertical is 190, not extension −170), hip flexion raises the thigh, elbow/knee flexion bends the hinge (knees hinge backward automatically), spine/neck flexion curls forward, negative is extension. Ankle flexion is dorsiflexion (toes up), negative points them.
- abduction — away from the midline positive, for shoulders and hips.
- rotation — external (lateral) positive for shoulders and hips; for the spine and neck, turning to the right positive.
- A bare number is shorthand for
{"flexion": n}.
skeleton.json holds the bone-length profiles — neutral, female,
male: head, neck, two spine segments, arms, legs, plus foot,
shoulderHalf/hipHalf (real shoulder and pelvis half-widths) and
farOffset (below) — and each joint type's degrees of freedom with its
physiological range of motion (ROM). Rendering validates every key frame
against the ROM and prints a warning count (--strict lists and fails):
an impossible pose is a data bug, caught mechanically. Because motions are
authored in anatomical coordinates against joint names, swapping profiles
never touches a motion script — proportions are the skeleton's problem.
<Exercise>/motion.json — the exercise script:
{
"name": "Bird Dog",
"primary": 2, // 1-based frame used for visual.svg
"camera": {"yaw": 0}, // 0 = side view (default), 90 = face-on
"working": ["arm_l", "leg_r"], // parts drawn in the accent color
"frames": [
{
"hold": 0.5, "tween": 0.8, // seconds held / animating to the next frame
"root": {"pos": [190, 106], // pelvis canvas anchor
"yaw": 180, // trunk orientation: facing (0 = canvas-right)
"pitch": 81}, // forward bow; "roll": side-lean (both optional)
"spine": [0, 0], // two chained segments: flexion, or a dict
// {"flexion", "lateral", "rotation"}
"neck": 16, "head": -72, // neck flexion (+rotation); head = extra gaze pitch
"shoulder_l": 190, "elbow_l": 0, // flexion shorthand, or
"hip_r": {"flexion": -24}, // {"flexion", "abduction", "rotation"}
"knee_r": 0, "ankle_r": -25, // hinge flexion; ankle = dorsiflexion
"pins": {"hand_r": [111, 154]} // IK: this hand holds that canvas point
}
]
}
- Pins (IK) — a planted hand/foot names a canvas target
(
hand_r/hand_l/foot_r/foot_l); the renderer solves the two-bone chain analytically in 3D — in the plane picked by the authored elbow/knee — so the extremity holds that point exactly, and writes the solution back as anatomical angles. A pin active in two consecutive key frames stays planted throughout the tween; a pin present in only one frame releases naturally. - Tweening happens in anatomical angle space, so limbs swing in natural
arcs, bone lengths never distort, and interpolated poses stay plausible.
The last frame tweens back to the first (looping). Asymmetric timing
carries technique: leg raises lower slowly (
tween1.4 s down, 0.6 s up). - The camera is orthographic and per-exercise:
yaw0 is the classic side view, 90 views the figure face-on — real foreshortening, not faked proportions. Face-on machines (abductor/adductor, rotary torso) author genuine abduction or spine rotation and let projection do the rest. The camera also orbits while the motion loops (--orbit; the in-app renderer slowly orbits every exercise, machines included — props have world-space 3D form and turn with the figure). - Zoom — the skeleton is ~211 canvas units tall standing but the canvas
has only 152 above the ground line, so standing motions author full-size
anatomy (coordinates may run past the canvas top) and set
"camera": {"zoom": 0.7}to fit. Zoom is presentation-only: a uniform scale of the drawn output (geometry, props, mat, stroke widths) about the ground-center anchor(160, 152), applied after all solving — pins, prop coordinates, and the Swift-solver fixtures stay in full-size authored units, and the ground line maps to itself so planted feet stay planted. Typical values: ~0.7 standing tall, ~0.75–0.85 hanging or seated with arms overhead; omit it (1) for lying, kneeling, seated, and bent-over motions. - Elevation & the mat — the default viewpoint pitches down 10°
(
CAMERA_PITCH; override per motion via"camera": {"pitch": ...}), and the ground is drawn as an exercise mat: a world-space quad sized to the motion's footprint (mat_bounds) that rotates with the camera about the figure — a long rectangle in profile, a parallelogram mid-orbit, end-on when face-on. Near/far contacts straddle it. Elevation is pure presentation: pins solve in the flat authored view and the posed body tilts, so authored canvas targets never go out of reach. - Feet — each leg ends in a foot bone off the ankle. Dorsiflexion 0 keeps the foot perpendicular to the shin (right for standing, seated machines, planks); kneeling poses trail it back (−55), raises point it.
- The nose tick rides the head's anterior axis: it foreshortens with the
view and disappears when the face points at (or away from) the camera, so
face-on figures need no special casing.
headadds gaze pitch on top of the neck.
The visual language
- Near vs far — a smooth depth gradient, resolved per joint: every drawn
joint is toned by its own camera depth between the near, dark end (
#3a3f4b; teal#0d9488when working) and the far, light end (#a9afba; light teal#86cfc5), and each bone is stroked with a gradient between its two joints' tones, so the ink flows along a limb that reaches toward or away from the camera instead of the whole bone snapping to one flat tone; the stroke width tracks it too (near 6 → far 5). A joint reaches full contrast once it sitsSHADE_SPAN_FRACof the shoulder/pelvis half-width in front of (or behind) its limb pair's central depth plane, so a straight limb in a side view is still fully dark (near) or light (far) — the per-joint average matches the old flat per-limb tone — while as the two members cross (mid-orbit, or face-on machines) they fade to a shared mid-tone instead of the ink snapping. Opposite-limb moves (bird dog, dead bug) still read as opposite: one leaning dark-teal, one light-teal. The binary near/far pick (canvas-right wins a depth tie) stays underneath, driving the far offset and draw order — depth- sorted, far parts first, so a twist genuinely passes the near arm in front of the chest; the head paints last, filled opaque, so overhead arms pass behind the face. - The far offset — in profile views both members of a pair project onto
the same line; the far member is nudged by the profile's
farOffsetso it stays distinguishable. The nudge scales with how side-on the view is and vanishes face-on, where the skeleton's real shoulder/pelvis widths take over — one continuous rule across the whole orbit. - Spine — rendered as a smooth curve through pelvis → mid → neck, with the shoulder girdle and pelvis drawn as bars across the attach points (near-full width face-on, a shoulder/hip nub in profile); teal when the trunk is the working part.
- Canvas 320×180, ground line at y = 152. Every limb is always drawn — a supine figure's far arm reads as the light far member, never a hidden one, so any viewpoint (and the orbit) stays truthful.
The props layer
Machines and free weights are data too: an optional top-level "props" array
adds an equipment layer around the figure. scene shapes and cables draw
behind the figure in a recessive gray; joint-attached items (bar,
dumbbell, pad) draw over the limbs in a darker gray and follow the
resolved hand/foot positions every frame — a pinned foot pressing a pad
carries the platform with it through the tween for free. The figure stays the
hero: props are schematic silhouettes (a seat, a backrest, one handle), never
scale drawings of the machine.
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[134, 123], [96, 36]], "w": 9, "depth": 8},
{"kind": "line", "pts": [[160, 64], [160, 120]], "w": 16, "z": -14},
{"kind": "circle", "c": [142, 77], "r": 3.5, "fill": true, "color": "prop"}
]},
{"type": "cable", "from": [190, 8], "to": ["hand_r", "hand_l"]},
{"type": "bar", "at": ["hand_r", "hand_l"], "halfLen": 26, "plateR": 0},
{"type": "dumbbell", "at": "hand_r"},
{"type": "pad", "at": ["foot_r", "foot_l"], "angle": 88, "halfLen": 20, "w": 6}
]
Props are authored in the authored view but have world-space 3D form: under an orbiting camera the whole equipment layer rotates about the world-vertical axis through the root anchor, exactly like the figure and the mat. Everything resolves in the authored view — scene points, cable anchors, bar angles, pad perpendiculars, roller offsets — and the resolved constructs are then rotated; joint-attached items keep following the rotated figure's hands and feet, so equipment stays welded to the body. At the authored yaw nothing moves, so the authored look is exact.
scene— static shapes:line(polyline, stroke widthw) andcircle(fill: falsefor an outline). Points are canvas[x, y](or[x, y, z]where needed); a shape-level"z"sets its depth plane (positive toward the camera), and a line with"depth"is a slab — extruded that half-width through its plane, it stays a plain line edge-on and opens into a swept quad as the camera orbits (seats, backrests, platforms). A shape may set"color": "prop"to use the darker attached-item gray (e.g. a fixed handle the hands rest on).cable— a thin line from a fixed anchorfrom([x, y]or[x, y, z]) to a moving jointto; the machine's pulley line.bar/dumbbell/pad— a segment centered on the joint(s) inat(a single joint, or the midpoint of a list). Joints are the extremities (hand_r,foot_l, …) plus the mid joints (elbow_r,knee_l, …), so a machine pad can ride a knee (["knee_r", "knee_l"]) or span a shin (["knee_r", "foot_r"]).barlies at a fixed authored-viewangle(default 0 = horizontal);dumbbellandpaddefault to perpendicular to the lower bone (forearm/shin), or take an explicitangle. Under orbit the segment rotates with the scene and foreshortens naturally.plateRputs filled discs on both ends (dumbbells default to 4.5). A cross-body rod — a barbell, pull-up bar, or any grip that really runs left-right through both hands — should instead set"axis": "z"(supersedingangle): its world-space direction projects through the camera elevation like the floor quad, so in a profile view it reads end-on (plates nearly concentric, seen from slightly above), opens to its full span face-on, and swings with the figure in between. A fixed screen-spaceanglecan't do this — it keeps the rod glued to the authored direction while the body turns under it. Vertical handles (angle: 90) don't need it: a world-vertical rod is unchanged by a yaw orbit.roller— a machine roller pad seen end-on: a filled disc riding the limb's lower bone near the joint inat, on theside(+1/−1) of the bone it presses — a leg extension's instep roller (side: 1), a leg curl's heel roller (side: -1).ris the radius,backslides it along the bone away from the joint.
Rendering
cd "Exercise Library"
python3 render.py # all exercises: frames/*.svg, preview.gif, visual.svg
python3 render.py "Bird Dog" # one exercise
python3 render.py --sheet # + contact-sheet.png of every key frame
python3 render.py --demo # + demo-sheet.png: profiles / flipped camera / theme
python3 render.py --orbit "Bird Dog" # orbit.gif: camera sweeps 360° while looping
python3 render.py --figure=female # render with another skeleton profile
python3 render.py --flip # view from the other side (camera + 180°)
python3 render.py --strict # fail on any ROM violation, listing each
python3 render.py --check # simulate every tick; fail hard on solver inconsistencies
python3 render.py --export # bake app resources into Workouts/Resources/ExerciseMotions
python3 render.py --fixtures # regenerate WorkoutsTests/Fixtures/figure-fixtures.json
--check goes further than --strict's authored-frame ROM gate: it's compute-only (no
frames/GIFs written) and replays the exact pipeline render_exercise drives — resolve
every key frame, build the tween timeline, re-resolve every tick — at the flat, unpitched
camera with no mat (elevation and the mat are presentation-only), watching for the ways
the solver has historically swallowed inconsistencies: pin fidelity (a pinned hand or
foot drifting off its canvas target as it tweens), pin-unreachable (an authored pin
farther than the limb can physically reach, reported as its own authoring-error class),
continuity (any drawn joint teleporting between adjacent ticks), wrap (an angle
DoF swinging more than 180° between adjacent key frames, including the loop closure —
an unnaturally long lerp arc), authored-vs-resolved drift (a pin silently overriding
the authored pose by more than 45°), and resolved ROM (the ROM gate re-run on
post-IK angles, with slack). Violations print per exercise, grouped and capped to a
worst-instance-plus-count per class/joint, ending in a pass/fail tally; --export
always runs the full --check across the folders it's about to copy first and aborts
if anything fails — there's no override, bad geometry must never ship.
render.py needs only Pillow (for GIFs/sheets; the SVGs have no dependency).
The library lives at the repo root, outside every target's source folders —
same-named files per entry (info.md, visual.svg) would collide in Xcode's
flat resource copy, so the library itself never enters the app bundle. Only
the --export copies ship: skeleton.json plus uniquely-named
<Name>.motion.json and <Name>.info.md files in
Workouts/Resources/ExerciseMotions/, consumed by the in-app renderer
(Workouts/ExerciseFigure/, compiled into the iOS target only — the watch's
run screen is timer-only, so its figure sources and these resources were
dropped from the watch target) and the exercise-library reference screen
(ExerciseInfo.swift parses the info pages). The in-app solver is a line-for-line port of kinematics.py,
held to it by WorkoutsTests/Fixtures/figure-fixtures.json — projected
geometry snapshots (figure and prop primitives, including orbit-presentation
samples) the Swift solver must reproduce; regenerate with
python3 render.py --fixtures alongside any pipeline change. Re-run
python3 render.py --export after editing any motion or info page; the
library stays the source of truth.