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
262 lines
16 KiB
Markdown
262 lines
16 KiB
Markdown
# 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:
|
||
|
||
```json
|
||
{
|
||
"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 (`tween` 1.4 s down, 0.6 s up).
|
||
- **The camera** is orthographic and per-exercise: `yaw` 0 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. `head` adds 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 `#0d9488` when 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 sits
|
||
`SHADE_SPAN_FRAC` of 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 `farOffset` so 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 `cable`s 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.
|
||
|
||
```json
|
||
"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 width `w`) and
|
||
`circle` (`fill: false` for 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 anchor `from` (`[x, y]` or
|
||
`[x, y, z]`) to a moving joint `to`; the machine's pulley line.
|
||
- **`bar` / `dumbbell` / `pad`** — a segment centered on the joint(s) in
|
||
`at` (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"]`).
|
||
`bar` lies at a fixed authored-view `angle` (default 0 = horizontal);
|
||
`dumbbell` and `pad` default to perpendicular to the lower bone
|
||
(forearm/shin), or take an explicit `angle`. Under orbit the segment
|
||
rotates with the scene and foreshortens naturally. `plateR` puts 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"`
|
||
(superseding `angle`): 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-space `angle` can'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 in `at`, on the `side` (+1/−1) of the bone
|
||
it presses — a leg extension's instep roller (`side: 1`), a leg curl's
|
||
heel roller (`side: -1`). `r` is the radius, `back` slides it along the
|
||
bone away from the joint.
|
||
|
||
## Rendering
|
||
|
||
```sh
|
||
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.
|