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:
@@ -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 × 6–8
|
||||
- **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.
|
||||
Reference in New Issue
Block a user