Hollow Body Hold, Leg Raises, and Reverse Crunch hid the far arm because it was never authored - a leftover from the planar rig. Both arms are now posed, so the far arm reads as the standard light member behind the near one, the same visual language every other exercise uses, and the figures stay truthful from any viewpoint. With no remaining users, the hide mechanism is deleted from both renderers, the motion schema, and the docs. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
214 lines
12 KiB
Markdown
214 lines
12 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 can also orbit while the motion loops (`--orbit`).
|
||
- **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** — the rule that never bends: within each limb pair the
|
||
member **nearer the camera** draws dark (`#3a3f4b`) and in front, the far
|
||
member light (`#a9afba`) and behind. Working limbs keep the split: near =
|
||
teal `#0d9488`, far = light teal `#86cfc5`. Opposite-limb moves (bird dog,
|
||
dead bug) read as visibly opposite: one dark-teal limb, one light-teal
|
||
limb. When a view leaves depths tied (face-on machines), the canvas-right
|
||
member takes the dark ink. Draw order is by camera depth (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 — in
|
||
side view a two-handed bar is drawn horizontal by convention); `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).
|
||
- **`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 --export # bake app resources into Workouts/Resources/ExerciseMotions
|
||
python3 render.py --fixtures # regenerate WorkoutsTests/Fixtures/figure-fixtures.json
|
||
```
|
||
|
||
`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 both the iOS and watch targets)
|
||
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.
|