Files
workouts/EXERCISE-LIBRARY.md
rzen d04526826c Add a Running exercise with an animated stride figure
Stance and flight keyframes with single-frame foot pins for the strikes,
forward lean, opposite-arm drive, and daylight under both feet in flight —
distinct from Cardio's high-knee march, which stays as is.

Claude-Session: https://claude.ai/code/session_01PKptrgbx74peTwHGRxBojv
2026-07-12 00:37:23 -04:00

281 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Exercise Library
How the exercise library is organized, end to end — from the hand-authored
source at the repo root, through the render/export pipeline, to the resources
the app bundles and the Swift code that consumes them.
There are **two** locations, and it matters which is which:
| | Path | Role |
|---|---|---|
| **Source** | `Exercise Library/` (repo root, outside every app target) | Authored + generated reference material. The source of truth. |
| **Bundle** | `Workouts/Resources/ExerciseMotions/` | Verbatim export of just the two authored files per exercise (plus `skeleton.json`). The only part that ships. |
Data flows one way: you edit the **source**, run `render.py --export`, and the
**bundle** is overwritten. Never hand-edit anything under
`Workouts/Resources/ExerciseMotions/` — it is regenerated.
As of this writing the catalog is **65 exercises**.
```
Exercise Library/ ← authoring source (repo root)
├── README.md SYSTEM.md COVERAGE.md ← the three governing docs
├── skeleton.json ← shared rig: bone lengths + joint ROM
├── render.py kinematics.py ← the render pipeline + the FK math
├── contact-sheet.png demo-sheet.png ← generated QA sheets
└── <Exercise Name>/ ← one folder per exercise, 65 of them
├── info.md ← AUTHORED: the reference page
├── motion.json ← AUTHORED: the rig script (canonical visual source)
├── frames/frame-N.svg ← generated: one SVG per key frame
├── visual.svg ← generated: the primary frame (static contexts)
├── preview.gif ← generated: the tweened, looping animation
└── orbit.gif ← generated (on demand): camera sweeps 360°
│ python3 render.py --export
Workouts/Resources/ExerciseMotions/ ← bundle (flat, unique basenames)
├── skeleton.json
├── <Exercise Name>.motion.json ← 65
└── <Exercise Name>.info.md ← 65
│ Bundle.main lookups
Workouts/ExerciseFigure/ ← app-side consumers (iOS only)
├── ExerciseMotion.swift → ExerciseMotionLibrary (decodes rigs; catalog list)
├── ExerciseInfo.swift → ExerciseInfoLibrary (parses info.md)
├── MotionSolver.swift → port of kinematics.py
└── ExerciseFigureView.swift → ExerciseFigureSlot (the animated figure)
```
## The authoring source (`Exercise Library/`)
The library lives at the repo root, **deliberately outside** the app targets'
source folders. Every exercise folder holds same-named files (`info.md`,
`visual.svg`) that would collide in Xcode's flat resource copy, so the library
directory itself is never added to the app — only the uniquely-renamed
`--export` copies ship.
### The three governing docs
Read these first; they are the authority on their topics and this file does not
duplicate their depth:
- **`README.md`** — the short index: what each per-exercise file is, and the
authoring order for `info.md`.
- **`SYSTEM.md`** — the visual system: the anatomical 3D rig, the joint-angle
conventions (flexion/abduction/rotation, ISB coordinate frame), the full
`motion.json` schema (frames, pins, camera, zoom, props), the visual language
(near/far shading, working-part teal, nose tick, mat), and the `render.py`
command reference. This is the big one.
- **`COVERAGE.md`** — the **closed-catalog** model: the movement-pattern ×
modality matrix and the gym-floor census that together define which exercises
the library *must* contain, plus the considered exclusions. Judge every
addition or removal against it.
### One folder per exercise
Folder names are exact app-facing exercise names (`Bench Press`, `Cat-Cow`,
`Child's Pose`). Each folder has two **authored** files and several
**generated** ones:
| File | Authored? | What it is |
|---|---|---|
| `info.md` | ✅ hand-written | The reference page (summary, facts, instructions). Parsed by the app. |
| `motion.json` | ✅ hand-written | The rig script: key frames of joint angles. **Canonical source for all visuals** and the in-app animation. |
| `frames/frame-N.svg` | generated | One SVG per key frame. |
| `visual.svg` | generated | The `primary` frame, for static contexts. |
| `preview.gif` | generated | The tweened, looping animation. |
| `orbit.gif` | generated (`--orbit`) | Camera sweeps 360° while the motion loops. |
The generated files are **never hand-edited**`render.py` rewrites them from
`motion.json`. They exist in-repo as browsable reference/QA; only `visual.svg`
and the GIFs are for humans, and none of them are exported to the app (the app
renders live from the rig instead — see below).
### Shared / library-level files
- **`skeleton.json`** — the shared rig. Named bone-length **profiles**
(`neutral`, `female`, `male`) plus each joint's degrees of freedom and its
physiological **range of motion (ROM)**. The app renders `neutral` only.
Because motions are authored against joint *names* in anatomical
coordinates, swapping a profile never touches a motion script.
*(Note: `README.md` still calls this `body.json` — a stale name; the file is
`skeleton.json`, and `--export` actively deletes any legacy `body.json`.)*
- **`render.py`** — the renderer (see pipeline section). ~910 lines; needs only
Pillow for the GIFs/sheets (SVGs are dependency-free).
- **`kinematics.py`** — the forward-kinematics + projection math, imported by
`render.py`. The in-app `MotionSolver.swift` is a line-for-line port of this.
- **`contact-sheet.png`** / **`demo-sheet.png`** — generated QA sheets
(`--sheet` / `--demo`): every key frame, and rig customizations (profiles,
flipped camera, theme).
## The two authored inputs, in detail
### `info.md` — the reference page
Uniform hand-authored Markdown in a fixed shape (the app parses the *known
shape*, not general Markdown). Order: `# Title`, a one-line summary paragraph,
metadata bullets, then instructional sections.
```markdown
# Bench Press
The barbell press — lying on the flat bench, lower the bar to the chest…
- **Category:** Main circuit
- **Type:** Free-weight horizontal press
- **Targets:** Chest, front delts, triceps
- **Prescription:** 4 × 68
- **Defaults:** 4 × 6 weighted
## Setup
## Execution (numbered steps)
## Cues (bullets)
## Common Mistakes (bullets)
## Progression (easier → harder)
```
Two metadata bullets carry app behavior beyond display:
- **`Type:`** — if it starts "Machine-based", `ExerciseInfo.isMachineBased` is
true, which gates the machine-settings UI.
- **`Defaults:`** — the suggested starting plan (`4 × 6 weighted`,
`3 × 12 bodyweight`, `3 × 30 s`) parsed into sets/reps/loadType/duration for
an exercise added outside a split. Unrecognized → `nil` (no guessing).
### `motion.json` — the rig script
The exercise scripted as key frames of anatomical joint angles on the shared
rig, with IK pins for planted hands/feet, per-exercise camera, and an optional
equipment `props` layer. **`SYSTEM.md` is the schema authority** — the shape in
brief:
```json
{
"name": "Bench Press",
"primary": 1, // 1-based frame for visual.svg
"camera": {"yaw": 0, "zoom": 0.7}, // side view; zoom fits tall standing motions
"working": ["arm_r", "arm_l"], // parts drawn in accent teal
"props": [ /* scene / cable / bar / dumbbell / pad / roller */ ],
"frames": [
{ "hold": 0.4, "tween": 0.9,
"root": {"pos": [150, 116], "pitch": -90}, // pelvis anchor + trunk orientation
"spine": [0, 0], "neck": 8, "head": 0,
"shoulder_r": 15, "elbow_r": 100, // flexion shorthand, or a {…} dict
"hip_r": 78, "knee_r": 82, "ankle_r": 5,
"pins": {"foot_r": [196, 148], "hand_r": [93, 100]} } // IK targets
]
}
```
Angles are degrees from the neutral standing pose (flexion forward, abduction
from the midline, rotation external). Tweening happens in angle space so limbs
swing in natural arcs; the last frame loops back to the first. Props have
world-space 3D form and orbit with the figure. Canvas is 320×180, ground line
at y = 152.
## The render pipeline
`render.py` (+ `kinematics.py` + `skeleton.json`) resolves each `motion.json`
through 3D forward kinematics and orthographic projection into the generated
files. Run from inside the library dir:
```sh
cd "Exercise Library"
python3 render.py # all exercises → frames/*.svg, preview.gif, visual.svg
python3 render.py "Bird Dog" # just one exercise
python3 render.py --sheet # + contact-sheet.png (every key frame)
python3 render.py --demo # + demo-sheet.png (profiles / flipped / theme)
python3 render.py --orbit "Bird Dog" # orbit.gif: 360° camera sweep
python3 render.py --figure=female # render with another skeleton profile
python3 render.py --flip # view from the other side
python3 render.py --strict # fail on any ROM violation, listing each
python3 render.py --export # bake app resources (see below)
python3 render.py --fixtures # regenerate the Swift-solver fixtures (see below)
```
Every key frame is validated against the skeleton ROM; an impossible pose is
caught mechanically (a warning count, or a hard fail under `--strict`).
## The export step
`render.py --export` writes `Workouts/Resources/ExerciseMotions/` — and **only**
three kinds of file:
- `skeleton.json` (verbatim copy),
- `<Exercise Name>.motion.json` (one per folder, renamed to a unique basename),
- `<Exercise Name>.info.md` (one per folder, likewise).
The generated SVGs and GIFs do **not** ship — the app renders the figure live
from the rig instead. The rename to `<Name>.motion.json` / `<Name>.info.md` is
what lets Xcode's flat resource copy hold all 65 without collision.
## The app-side consumers (`Workouts/ExerciseFigure/`)
Compiled into the **iOS target only** — commit b53381e dropped the
`ExerciseFigure` sources *and* the `ExerciseMotions` resources from the watch
target, which now shows a timer-only run screen (its own figure-less
`Workouts Watch App/Views/ExerciseProgressView.swift`). All lookups are
exact-name `Bundle.main` matches against the exported basenames.
- **`ExerciseMotion.swift``ExerciseMotionLibrary`** — Codable mirror of the
rig data; decodes `<Name>.motion.json` + `skeleton.json`.
- `ExerciseMotionLibrary.exerciseNames` enumerates every bundled
`*.motion.json`, strips the suffix, and sorts — this is **the picker's
entire list** and thus the app's catalog.
- `resources(for:)` returns the motion + neutral profile for one exercise, or
`nil` if none is bundled.
- **`ExerciseInfo.swift``ExerciseInfoLibrary`** — `ExerciseInfo.parse(...)`
turns the `info.md` shape into `summary` / `category` / `type` / `targets`
(split for chips) / `sections` / `defaults`. `ExerciseInfoLibrary.info(for:)`
loads the bundled page.
- **`MotionSolver.swift`** — the FK + projection solver, a line-for-line port of
`kinematics.py`. It is held to the Python pipeline by
**`WorkoutsTests/Fixtures/figure-fixtures.json`** (projected-geometry
snapshots the Swift solver must reproduce); regenerate with
`render.py --fixtures` alongside any pipeline change.
- **`ExerciseFigureView.swift``ExerciseFigureSlot(exerciseName:)`** — the
public view. Renders the looping, slowly-orbiting figure with `Canvas` +
`TimelineView`, or empty space when no motion matches. Used by the run
screen's bottom half (`ExerciseProgressView`) and the reference screen
(`ExerciseLibraryView`).
Other views that surface the catalog: `ExercisePickerView` and
`ExerciseLibraryView` (Settings → Library) both list
`ExerciseMotionLibrary.exerciseNames`.
### The closed-catalog rule
The picker lists **exactly** the bundled names with **no free-text fallback**.
So the library is the app's closed catalog: a movement pattern or gym station
with no entry is a hard wall for logging, not a cosmetic gap. That is why
`COVERAGE.md` exists and why additions are judged against it.
## Making a change
Adding or editing an exercise is a source-only edit plus a re-export:
1. **Add/edit the folder** under `Exercise Library/<Exercise Name>/`: write or
revise `info.md` and `motion.json`. For a new exercise, first check it against
`COVERAGE.md` (does a real program train this pattern/station?).
2. **Render** to preview and validate: `python3 render.py "<Exercise Name>"`
(or bare `render.py` for all). Fix any ROM warnings; `--strict` to enforce.
3. **Export** the bundle: `python3 render.py --export`. This is what the app
sees; the picker picks up a new name automatically (no code change).
4. **If you changed the pipeline** (`render.py`/`kinematics.py`/`skeleton.json`,
or anything affecting projected geometry), regenerate the Swift-solver
fixtures: `python3 render.py --fixtures`, and run `WorkoutsTests` so
`MotionSolver` stays in lockstep with the Python renderer.
A persisted new field in `info.md` or `motion.json` that the app must read also
needs the matching Codable/parse update in `ExerciseMotion.swift` /
`ExerciseInfo.swift`.
## See also
- `Exercise Library/SYSTEM.md` — the rig, joint conventions, full `motion.json`
schema, visual language, and render command reference.
- `Exercise Library/COVERAGE.md` — the closed-catalog coverage model.
- `Exercise Library/README.md` — the per-file index and `info.md` authoring order.
- `CLAUDE.md`*Architecture → Starter Data → Exercise library* — the one-paragraph summary.