The rig is ~211 canvas units tall standing but the canvas has 152 above
the ground line - every motion to date is seated or on the floor. A
per-motion "camera": {"zoom": ...} now scales the drawn output (figure,
props, mat, stroke widths) about the ground-center anchor in both the
reference renderer and the in-app view. Pure view transform: pins, prop
coordinates, and the Swift-solver fixtures stay in full-size authored
units; zoom 1 is byte-identical to before.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
226 lines
13 KiB
Markdown
226 lines
13 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** — 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.
|