Files
workouts/EXERCISE-LIBRARY.md
T
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

13 KiB
Raw Blame History

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-editedrender.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.

# 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:

{
  "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:

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.swiftExerciseMotionLibrary — 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.swiftExerciseInfoLibraryExerciseInfo.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.swiftExerciseFigureSlot(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.mdArchitecture → Starter Data → Exercise library — the one-paragraph summary.