Add Warm-Up and Stretching activity types

Adds WorkoutActivityType.warmUp (HealthKit .preparationAndRecovery) and
.stretching (.flexibility), and retags the six starter splits that were all
mislabeled as Functional Strength:

- Warm-Up:    Upper Body Warm-Up, Lower Body Warm-Up, Morning Wake-Up
- Stretching: Morning Mobility, Full Body Stretch, Evening Stretch

The split editor's activity picker surfaces them automatically (CaseIterable).
Older app versions decode the new raw values as the default type — additive and
not schema-gated, so no quarantine.
This commit is contained in:
2026-07-09 15:45:31 -04:00
parent f01e149269
commit e12fe31152
308 changed files with 5594 additions and 990 deletions
+280
View File
@@ -0,0 +1,280 @@
# 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 **64 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, 64 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 ← 64
└── <Exercise Name>.info.md ← 64
│ 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 64 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.