Exercise Library/ holds per-exercise reference docs (setup, cues, mistakes, progressions) with SVG visuals and a Python-rendered motion pipeline; Workouts/ExerciseFigure renders the bundled *.motion.json rigs as animated stick figures on the exercise screen. Exercises gain a warm-up/main-circuit category, timed exercises display hold time via planSummary, and a completed exercise reopens to a check screen instead of its timers.
98 lines
4.9 KiB
Markdown
98 lines
4.9 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.
|
||
- **`<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
|
||
"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).
|
||
- 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`. Every render embeds a
|
||
small `R —` / `L —` legend, so opposite-limb moves (bird dog, dead bug) are
|
||
visibly opposite: one dark-teal limb, one light-teal limb.
|
||
- **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 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` files in `Workouts/Resources/ExerciseMotions/`, consumed
|
||
by the in-app SwiftUI renderer (`Workouts/ExerciseFigure/`). Re-run
|
||
`python3 render.py --export` after editing any motion; the library stays the
|
||
source of truth.
|