Library detail screens now lead with the user's recorded machine settings (per-split when they disagree, empty-state card for machine-based entries) and append the weight progression chart. Starter seeds mark machine exercises with an empty machineSettings list so the settings UI lights up before first use. The figure rig gains a frontal body profile for face-on machines, props that can ride mid joints (knees/elbows), and an alternating four-frame Bird Dog loop. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
152 lines
7.8 KiB
Markdown
152 lines
7.8 KiB
Markdown
# Exercise Visual System
|
||
|
||
Exercise visuals are produced by an **articulated 2D rig**: one shared stick
|
||
body posed per exercise by joint angles. Nothing is drawn by hand — a body
|
||
profile plus a motion script resolve through forward kinematics into every
|
||
frame, so figures are always in proportion, and the whole library can be
|
||
re-proportioned (male/female), flipped, rotated in-plane, or re-themed by
|
||
changing data, never artwork.
|
||
|
||
## The rig
|
||
|
||
- **`body.json`** — proportion profiles. Each profile is a table of bone
|
||
lengths: `headR`, `neck`, `spine1`/`spine2` (two chained segments so the
|
||
spine can curve), `upperArm`/`foreArm`, `thigh`/`shin`, plus `leftOffset`
|
||
(the small offset that separates left-limb attachments visually).
|
||
`neutral` is the default; add profiles to add figures. `frontal` shortens
|
||
the legs for face-on seated machines (thighs point mostly at the viewer, so
|
||
they render foreshortened); a motion opts in with a top-level
|
||
`"figure": "frontal"`.
|
||
- **`<Exercise>/motion.json`** — the exercise script: key frames of
|
||
**absolute joint angles** (degrees, y-up: `0`=forward/right, `90`=up,
|
||
`180`=back/left, `-90`=down), a `root` pelvis position, and timing.
|
||
|
||
```json
|
||
{
|
||
"name": "Bird Dog",
|
||
"primary": 2, // 1-based frame used for visual.svg
|
||
"figure": "frontal", // optional profile override (default: neutral)
|
||
"working": ["arm_r", "leg_l"], // parts drawn in the accent color
|
||
"hide": [], // limbs fully occluded in this view
|
||
"frames": [
|
||
{
|
||
"hold": 0.5, // seconds held at this key frame
|
||
"tween": 0.8, // seconds animating to the NEXT frame
|
||
"root": [190, 106], // pelvis, canvas coords
|
||
"spine": [171, 171], // pelvis→mid, mid→neck angles
|
||
"neck": 187, "gaze": 205, // head direction; nose tick direction
|
||
"arm_r": [-90, -90], // upper-arm, forearm angles
|
||
"arm_l": [-90, -90],
|
||
"leg_r": [-83, 0], // thigh, shin angles
|
||
"leg_l": [-83, 0],
|
||
"pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
- **Pins (IK)** — a planted hand/foot names a target point
|
||
(`hand_r`/`hand_l`/`foot_r`/`foot_l`); the renderer solves the two-bone
|
||
chain analytically so the extremity holds that point exactly, using the
|
||
authored angles only to pick the elbow/knee bend direction. A pin active in
|
||
two consecutive key frames stays planted *throughout the tween* (plank
|
||
forearms, side-plank support arm); a pin present in only one frame releases
|
||
naturally (bird-dog arm lifting off).
|
||
- **Tweening** happens in angle space (shortest path), so limbs swing in
|
||
natural arcs and bone lengths never distort. 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).
|
||
- **Face-on figures** — `gaze` is optional: a frame without it faces the
|
||
viewer and draws no nose tick. Used by exercises whose motion is lateral
|
||
(abductor/adductor), where a side view would hide the movement.
|
||
|
||
## 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},
|
||
{"kind": "rect", "x": 54, "y": 104, "w": 40, "h": 8, "r": 3},
|
||
{"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}
|
||
]
|
||
```
|
||
|
||
- **`scene`** — static shapes in canvas coordinates: `line` (polyline, stroke
|
||
width `w`), `circle` (`fill: false` for an outline), `rect` (filled, corner
|
||
radius `r`). 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` 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 world `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`. `plateR` puts filled discs on both ends (dumbbells default
|
||
to 4.5). A prop whose limb is hidden that frame simply isn't drawn.
|
||
- The same math is the plan for the app: a small SwiftUI renderer consumes
|
||
`body.json` + `motion.json` and tweens angles on the lower half of the
|
||
exercise screen (the paged timer flow occupies only the top half).
|
||
|
||
## The visual language
|
||
|
||
- **Right vs left limb** — the one rule that never bends: the figure's
|
||
**right**-side limbs are dark (`#3a3f4b`), its **left**-side limbs are light
|
||
(`#a9afba`) and drawn *behind* the body. Working limbs keep the split:
|
||
right = teal `#0d9488`, left = light teal `#86cfc5`. Opposite-limb moves
|
||
(bird dog, dead bug) read as visibly opposite: one dark-teal limb, one
|
||
light-teal limb. `render.py` reference renders embed a small `R —` / `L —`
|
||
legend for the rig author; the in-app renderer deliberately omits it — the
|
||
labels are only anatomically true for right-facing figures, and the
|
||
contrast alone carries the meaning.
|
||
- **Facing / front-of-torso** — the head carries a **nose tick** (`gaze`
|
||
angle); the belly is on that side. Prone noses point at the floor, supine
|
||
at the ceiling. The head is drawn last, filled opaque, so overhead arms
|
||
pass behind the face.
|
||
- **Spine** — rendered as a smooth curve through pelvis → mid → neck; teal
|
||
when the trunk is the working part.
|
||
- Canvas 320×180, ground line at y = 152. Limbs listed in `hide` are fully
|
||
occluded in this view and not drawn.
|
||
|
||
## 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: profile / flip / theme variants
|
||
python3 render.py --figure=female # render with another body profile
|
||
python3 render.py --flip # mirror the figure (faces the other way)
|
||
python3 render.py --export # copy body.json + <Name>.motion.json + <Name>.info.md app resources
|
||
```
|
||
|
||
`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: `body.json` plus uniquely-named
|
||
`<Name>.motion.json` and `<Name>.info.md` files in
|
||
`Workouts/Resources/ExerciseMotions/`, consumed by the in-app SwiftUI renderer
|
||
(`Workouts/ExerciseFigure/`) and the exercise-library reference screen
|
||
(`ExerciseInfo.swift` parses the info pages). Re-run
|
||
`python3 render.py --export` after editing any motion or info page; the
|
||
library stays the source of truth.
|