diff --git a/.gitignore b/.gitignore
index 99effee..b4bc4f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,7 @@ Package.resolved
## Misc
*.log
+
+## Python (Exercise Library render tooling)
+__pycache__/
+*.pyc
diff --git a/Exercise Library/Bird Dog/frames/frame-1.svg b/Exercise Library/Bird Dog/frames/frame-1.svg
new file mode 100644
index 0000000..d6800eb
--- /dev/null
+++ b/Exercise Library/Bird Dog/frames/frame-1.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Bird Dog/frames/frame-2.svg b/Exercise Library/Bird Dog/frames/frame-2.svg
new file mode 100644
index 0000000..3a6a308
--- /dev/null
+++ b/Exercise Library/Bird Dog/frames/frame-2.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Bird Dog/info.md b/Exercise Library/Bird Dog/info.md
new file mode 100644
index 0000000..a8b8514
--- /dev/null
+++ b/Exercise Library/Bird Dog/info.md
@@ -0,0 +1,38 @@
+# Bird Dog
+
+Anti-rotation drill — extend opposite limbs while the hips and spine refuse to
+twist.
+
+- **Category:** Warm-up and main circuit
+- **Type:** Anti-rotation
+- **Targets:** Spinal erectors, glutes, deep core, shoulder stability
+- **Prescription:** Warm-up 8 per side; main circuit 3 × 8–10 per side, slow
+
+## Setup
+
+On all fours: hands under shoulders, knees under hips, spine neutral, gaze at
+the floor.
+
+## Execution
+
+1. Brace, then reach one arm forward and the *opposite* leg back until both are
+ in line with the torso.
+2. Pause for a beat at full extension — hips stay square to the floor.
+3. Return under control and switch sides.
+
+## Cues
+
+- Imagine a cup of water balanced on the lower back — nothing spills.
+- Reach *long*, not high: heel pushes back, knuckles push forward.
+- Slow, no momentum; the pause is where the work happens.
+
+## Common Mistakes
+
+- Hips rotating open as the leg lifts.
+- Lifting the leg above hip height and arching the lower back.
+- Racing through reps.
+
+## Progression
+
+Single limb only (arm *or* leg) → opposite arm + leg → paused 3-count hold →
+elbow-to-knee touch under the body between reps → hands on an unstable surface
diff --git a/Exercise Library/Bird Dog/motion.json b/Exercise Library/Bird Dog/motion.json
new file mode 100644
index 0000000..4c04ef0
--- /dev/null
+++ b/Exercise Library/Bird Dog/motion.json
@@ -0,0 +1,23 @@
+{
+ "name": "Bird Dog",
+ "primary": 2,
+ "working": ["arm_r", "leg_l"],
+ "frames": [
+ {
+ "hold": 0.5, "tween": 0.8,
+ "root": [190, 106],
+ "spine": [171, 171], "neck": 187, "gaze": 205,
+ "arm_r": [-90, -90], "arm_l": [-90, -90],
+ "leg_r": [-83, 0], "leg_l": [-83, 0],
+ "pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
+ },
+ {
+ "hold": 1.4, "tween": 0.8,
+ "root": [190, 106],
+ "spine": [171, 171], "neck": 187, "gaze": 195,
+ "arm_r": [161, 161], "arm_l": [-90, -90],
+ "leg_r": [-83, 0], "leg_l": [15, 15],
+ "pins": {"hand_l": [111, 154]}
+ }
+ ]
+}
diff --git a/Exercise Library/Bird Dog/preview.gif b/Exercise Library/Bird Dog/preview.gif
new file mode 100644
index 0000000..a151f67
Binary files /dev/null and b/Exercise Library/Bird Dog/preview.gif differ
diff --git a/Exercise Library/Bird Dog/visual.svg b/Exercise Library/Bird Dog/visual.svg
new file mode 100644
index 0000000..3a6a308
--- /dev/null
+++ b/Exercise Library/Bird Dog/visual.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Cat-Cow/frames/frame-1.svg b/Exercise Library/Cat-Cow/frames/frame-1.svg
new file mode 100644
index 0000000..ab4d9db
--- /dev/null
+++ b/Exercise Library/Cat-Cow/frames/frame-1.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Cat-Cow/frames/frame-2.svg b/Exercise Library/Cat-Cow/frames/frame-2.svg
new file mode 100644
index 0000000..33d04de
--- /dev/null
+++ b/Exercise Library/Cat-Cow/frames/frame-2.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Cat-Cow/info.md b/Exercise Library/Cat-Cow/info.md
new file mode 100644
index 0000000..86b29ef
--- /dev/null
+++ b/Exercise Library/Cat-Cow/info.md
@@ -0,0 +1,37 @@
+# Cat-Cow
+
+Gentle spinal mobility drill that wakes up the whole trunk before core work.
+
+- **Category:** Warm-up
+- **Type:** Spinal mobility
+- **Targets:** Thoracic and lumbar spine, deep core awareness
+- **Prescription:** 10 slow reps (one rep = cat + cow)
+
+## Setup
+
+On all fours: hands under shoulders, knees under hips, spine neutral, gaze at
+the floor.
+
+## Execution
+
+1. **Cat** — exhale, press the floor away, round the whole spine up toward the
+ ceiling and tuck chin and tailbone.
+2. **Cow** — inhale, let the belly drop, lift the chest and tailbone, gaze
+ slightly forward.
+3. Flow between the two, one breath per position.
+
+## Cues
+
+- Move segment by segment, not as one stiff plank.
+- Let the breath set the tempo — no rushing.
+- Reach the mid-back toward the ceiling in cat; most people only move the neck.
+
+## Common Mistakes
+
+- All the motion coming from the neck and hips, none from the mid-back.
+- Collapsing into the shoulders instead of pressing the floor away.
+
+## Progression
+
+Smaller pain-free range → full range → slower tempo with a pause at each end →
+segmental rolls (peel one vertebra at a time)
diff --git a/Exercise Library/Cat-Cow/motion.json b/Exercise Library/Cat-Cow/motion.json
new file mode 100644
index 0000000..699e99d
--- /dev/null
+++ b/Exercise Library/Cat-Cow/motion.json
@@ -0,0 +1,23 @@
+{
+ "name": "Cat-Cow",
+ "primary": 1,
+ "working": ["spine"],
+ "frames": [
+ {
+ "hold": 0.8, "tween": 0.9,
+ "root": [190, 106],
+ "spine": [188, 155], "neck": 185, "gaze": 164,
+ "arm_r": [-90, -90], "arm_l": [-90, -90],
+ "leg_r": [-83, 0], "leg_l": [-83, 0],
+ "pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
+ },
+ {
+ "hold": 0.8, "tween": 0.9,
+ "root": [190, 106],
+ "spine": [155, 192], "neck": 215, "gaze": 225,
+ "arm_r": [-90, -90], "arm_l": [-90, -90],
+ "leg_r": [-83, 0], "leg_l": [-83, 0],
+ "pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
+ }
+ ]
+}
diff --git a/Exercise Library/Cat-Cow/preview.gif b/Exercise Library/Cat-Cow/preview.gif
new file mode 100644
index 0000000..97e7dfe
Binary files /dev/null and b/Exercise Library/Cat-Cow/preview.gif differ
diff --git a/Exercise Library/Cat-Cow/visual.svg b/Exercise Library/Cat-Cow/visual.svg
new file mode 100644
index 0000000..ab4d9db
--- /dev/null
+++ b/Exercise Library/Cat-Cow/visual.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Dead Bug/frames/frame-1.svg b/Exercise Library/Dead Bug/frames/frame-1.svg
new file mode 100644
index 0000000..57ef8cc
--- /dev/null
+++ b/Exercise Library/Dead Bug/frames/frame-1.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Dead Bug/frames/frame-2.svg b/Exercise Library/Dead Bug/frames/frame-2.svg
new file mode 100644
index 0000000..8fe67f6
--- /dev/null
+++ b/Exercise Library/Dead Bug/frames/frame-2.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Dead Bug/info.md b/Exercise Library/Dead Bug/info.md
new file mode 100644
index 0000000..d581080
--- /dev/null
+++ b/Exercise Library/Dead Bug/info.md
@@ -0,0 +1,37 @@
+# Dead Bug
+
+Anti-extension drill — the limbs move while the trunk stays perfectly still.
+
+- **Category:** Warm-up
+- **Type:** Anti-extension
+- **Targets:** Deep core (transverse abdominis), hip flexor control
+- **Prescription:** 8 per side, slow and controlled
+
+## Setup
+
+Lie on your back. Arms straight up over the shoulders, hips and knees bent to
+90°, lower back pressed into the floor.
+
+## Execution
+
+1. Brace and exhale as you lower one arm overhead and the *opposite* leg toward
+ the floor.
+2. Stop just before the lower back peels off the floor.
+3. Return under control and switch sides.
+
+## Cues
+
+- Lower back glued to the floor the entire time — that contact *is* the exercise.
+- Slow negatives; the reach out should take 2–3 seconds.
+- Ribs down, no flaring as the arm goes overhead.
+
+## Common Mistakes
+
+- Arching the lower back as the leg lowers.
+- Moving arm and leg on the same side.
+- Rushing reps and turning it into a flutter kick.
+
+## Progression
+
+Heels sliding on floor → bent-knee reach → straight-leg reach → both legs
+lowering together → light weight held overhead
diff --git a/Exercise Library/Dead Bug/motion.json b/Exercise Library/Dead Bug/motion.json
new file mode 100644
index 0000000..974d769
--- /dev/null
+++ b/Exercise Library/Dead Bug/motion.json
@@ -0,0 +1,21 @@
+{
+ "name": "Dead Bug",
+ "primary": 2,
+ "working": ["arm_r", "leg_l"],
+ "frames": [
+ {
+ "hold": 0.5, "tween": 0.8,
+ "root": [185, 148],
+ "spine": [180, 180], "neck": 168, "gaze": 90,
+ "arm_r": [90, 90], "arm_l": [90, 90],
+ "leg_r": [64, -10], "leg_l": [64, -10]
+ },
+ {
+ "hold": 0.9, "tween": 0.8,
+ "root": [185, 148],
+ "spine": [180, 180], "neck": 168, "gaze": 90,
+ "arm_r": [135, 135], "arm_l": [90, 90],
+ "leg_r": [64, -10], "leg_l": [16, 16]
+ }
+ ]
+}
diff --git a/Exercise Library/Dead Bug/preview.gif b/Exercise Library/Dead Bug/preview.gif
new file mode 100644
index 0000000..5bec5b6
Binary files /dev/null and b/Exercise Library/Dead Bug/preview.gif differ
diff --git a/Exercise Library/Dead Bug/visual.svg b/Exercise Library/Dead Bug/visual.svg
new file mode 100644
index 0000000..8fe67f6
--- /dev/null
+++ b/Exercise Library/Dead Bug/visual.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Hollow Body Hold/frames/frame-1.svg b/Exercise Library/Hollow Body Hold/frames/frame-1.svg
new file mode 100644
index 0000000..c5da853
--- /dev/null
+++ b/Exercise Library/Hollow Body Hold/frames/frame-1.svg
@@ -0,0 +1,16 @@
+
diff --git a/Exercise Library/Hollow Body Hold/frames/frame-2.svg b/Exercise Library/Hollow Body Hold/frames/frame-2.svg
new file mode 100644
index 0000000..7440d75
--- /dev/null
+++ b/Exercise Library/Hollow Body Hold/frames/frame-2.svg
@@ -0,0 +1,16 @@
+
diff --git a/Exercise Library/Hollow Body Hold/info.md b/Exercise Library/Hollow Body Hold/info.md
new file mode 100644
index 0000000..e098364
--- /dev/null
+++ b/Exercise Library/Hollow Body Hold/info.md
@@ -0,0 +1,37 @@
+# Hollow Body Hold
+
+The gold standard for deep core tension — a shallow, rigid banana shape.
+
+- **Category:** Main circuit
+- **Type:** Anti-extension (isometric hold)
+- **Targets:** Rectus abdominis, deep core, hip flexors
+- **Prescription:** 3 × 20–40 s holds
+
+## Setup
+
+Lie on your back, arms extended overhead beside the ears, legs straight and
+together.
+
+## Execution
+
+1. Press the lower back *into* the floor and hold it there — this never releases.
+2. Lift shoulder blades and legs a few inches off the floor into a shallow
+ banana shape.
+3. Reach long through fingers and toes and hold.
+
+## Cues
+
+- The lower back stays welded to the floor; if it lifts, shorten the shape.
+- Chin gently tucked — look at your thighs, not the ceiling.
+- Lower = harder. Height is not the goal, tension is.
+
+## Common Mistakes
+
+- Lower back arching off the floor (the hold has failed at that point).
+- Folding at the hips into a V-sit.
+- Neck cranked forward doing all the "work".
+
+## Progression
+
+Tuck hold (knees in) → one leg extended → full extension, arms overhead →
+hollow rocks
diff --git a/Exercise Library/Hollow Body Hold/motion.json b/Exercise Library/Hollow Body Hold/motion.json
new file mode 100644
index 0000000..8ed3760
--- /dev/null
+++ b/Exercise Library/Hollow Body Hold/motion.json
@@ -0,0 +1,22 @@
+{
+ "name": "Hollow Body Hold",
+ "primary": 2,
+ "working": ["spine"],
+ "hide": ["arm_l"],
+ "frames": [
+ {
+ "hold": 0.4, "tween": 0.8,
+ "root": [185, 148],
+ "spine": [180, 180], "neck": 168, "gaze": 90,
+ "arm_r": [180, 180],
+ "leg_r": [0, 0], "leg_l": [0, 0]
+ },
+ {
+ "hold": 1.8, "tween": 0.8,
+ "root": [185, 148],
+ "spine": [174, 160], "neck": 140, "gaze": 50,
+ "arm_r": [155, 155],
+ "leg_r": [12, 12], "leg_l": [12, 12]
+ }
+ ]
+}
diff --git a/Exercise Library/Hollow Body Hold/preview.gif b/Exercise Library/Hollow Body Hold/preview.gif
new file mode 100644
index 0000000..2e6b0ee
Binary files /dev/null and b/Exercise Library/Hollow Body Hold/preview.gif differ
diff --git a/Exercise Library/Hollow Body Hold/visual.svg b/Exercise Library/Hollow Body Hold/visual.svg
new file mode 100644
index 0000000..7440d75
--- /dev/null
+++ b/Exercise Library/Hollow Body Hold/visual.svg
@@ -0,0 +1,16 @@
+
diff --git a/Exercise Library/Leg Raises/frames/frame-1.svg b/Exercise Library/Leg Raises/frames/frame-1.svg
new file mode 100644
index 0000000..5dfd35f
--- /dev/null
+++ b/Exercise Library/Leg Raises/frames/frame-1.svg
@@ -0,0 +1,16 @@
+
diff --git a/Exercise Library/Leg Raises/frames/frame-2.svg b/Exercise Library/Leg Raises/frames/frame-2.svg
new file mode 100644
index 0000000..f88ee55
--- /dev/null
+++ b/Exercise Library/Leg Raises/frames/frame-2.svg
@@ -0,0 +1,16 @@
+
diff --git a/Exercise Library/Leg Raises/info.md b/Exercise Library/Leg Raises/info.md
new file mode 100644
index 0000000..af527a2
--- /dev/null
+++ b/Exercise Library/Leg Raises/info.md
@@ -0,0 +1,37 @@
+# Leg Raises
+
+Lower-ab builder — the slow lowering is the exercise.
+
+- **Category:** Main circuit
+- **Type:** Anti-extension (dynamic)
+- **Targets:** Lower rectus abdominis, hip flexors, deep core
+- **Prescription:** 3 × 8–15 reps, slow negatives
+
+## Setup
+
+**Lying:** flat on your back, legs straight, hands at your sides (or under the
+tailbone to start). **Hanging:** dead hang from a bar, shoulders packed.
+
+## Execution
+
+1. Press the lower back into the floor and brace.
+2. Raise the legs to vertical without bending the knees more than slightly.
+3. Lower *slowly* — 3 seconds or more — stopping before the lower back arches
+ off the floor.
+
+## Cues
+
+- Lower back flat against the floor on lying versions, always.
+- The negative is the rep: lower slower than you lift.
+- Exhale on the way down to keep the ribs from flaring.
+
+## Common Mistakes
+
+- Lower back arching off the floor near the bottom.
+- Dropping the legs instead of lowering them.
+- Using momentum (swinging) on hanging versions.
+
+## Progression
+
+Bent-knee lying raise → straight-leg lying raise → hanging knee raise →
+hanging straight-leg raise → toes-to-bar
diff --git a/Exercise Library/Leg Raises/motion.json b/Exercise Library/Leg Raises/motion.json
new file mode 100644
index 0000000..3a26817
--- /dev/null
+++ b/Exercise Library/Leg Raises/motion.json
@@ -0,0 +1,22 @@
+{
+ "name": "Leg Raises",
+ "primary": 1,
+ "working": ["leg_r", "leg_l"],
+ "hide": ["arm_r"],
+ "frames": [
+ {
+ "hold": 0.5, "tween": 1.4,
+ "root": [185, 148],
+ "spine": [180, 180], "neck": 168, "gaze": 90,
+ "arm_l": [-2, -2],
+ "leg_r": [60, 60], "leg_l": [60, 60]
+ },
+ {
+ "hold": 0.3, "tween": 0.6,
+ "root": [185, 148],
+ "spine": [180, 180], "neck": 168, "gaze": 90,
+ "arm_l": [-2, -2],
+ "leg_r": [8, 8], "leg_l": [8, 8]
+ }
+ ]
+}
diff --git a/Exercise Library/Leg Raises/preview.gif b/Exercise Library/Leg Raises/preview.gif
new file mode 100644
index 0000000..191c447
Binary files /dev/null and b/Exercise Library/Leg Raises/preview.gif differ
diff --git a/Exercise Library/Leg Raises/visual.svg b/Exercise Library/Leg Raises/visual.svg
new file mode 100644
index 0000000..5dfd35f
--- /dev/null
+++ b/Exercise Library/Leg Raises/visual.svg
@@ -0,0 +1,16 @@
+
diff --git a/Exercise Library/Plank/frames/frame-1.svg b/Exercise Library/Plank/frames/frame-1.svg
new file mode 100644
index 0000000..033b765
--- /dev/null
+++ b/Exercise Library/Plank/frames/frame-1.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Plank/frames/frame-2.svg b/Exercise Library/Plank/frames/frame-2.svg
new file mode 100644
index 0000000..566d799
--- /dev/null
+++ b/Exercise Library/Plank/frames/frame-2.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Plank/info.md b/Exercise Library/Plank/info.md
new file mode 100644
index 0000000..6e738d6
--- /dev/null
+++ b/Exercise Library/Plank/info.md
@@ -0,0 +1,36 @@
+# Plank
+
+The baseline anti-extension hold — a rigid line from head to heels.
+
+- **Category:** Main circuit
+- **Type:** Anti-extension (isometric hold)
+- **Targets:** Rectus abdominis, deep core, glutes, shoulders
+- **Prescription:** 3 × 30–60 s holds
+
+## Setup
+
+Forearms on the floor, elbows under shoulders, feet together behind you. One
+straight line from the back of the head to the heels.
+
+## Execution
+
+1. Squeeze glutes and quads hard, tuck ribs down toward the pelvis.
+2. Press the floor away through the forearms — no sagging between the shoulders.
+3. Breathe shallowly behind the brace and hold.
+
+## Cues
+
+- Glutes and quads *tight* — a plank fails at the hips first.
+- Ribs down: think "short front, long back".
+- If the hips sag or the lower back takes over, the set is done.
+
+## Common Mistakes
+
+- Hips sagging (lower back extension — the exact thing to resist).
+- Hips piked high to make the hold easier.
+- Holding the breath entirely.
+
+## Progression
+
+Knee plank → full plank → feet elevated → RKC plank (maximum-tension, shorter
+holds) → weighted plank
diff --git a/Exercise Library/Plank/motion.json b/Exercise Library/Plank/motion.json
new file mode 100644
index 0000000..8810dec
--- /dev/null
+++ b/Exercise Library/Plank/motion.json
@@ -0,0 +1,21 @@
+{
+ "name": "Plank",
+ "primary": 2,
+ "working": ["spine"],
+ "frames": [
+ {
+ "hold": 0.4, "tween": 0.7,
+ "root": [172, 114],
+ "spine": [185, 185], "neck": 185, "gaze": 210,
+ "arm_r": [-90, 180], "arm_l": [-90, 180],
+ "leg_r": [-55, 0], "leg_l": [-55, 0]
+ },
+ {
+ "hold": 1.8, "tween": 0.7,
+ "root": [168, 137],
+ "spine": [170, 170], "neck": 170, "gaze": 210,
+ "arm_r": [-90, 180], "arm_l": [-90, 180],
+ "leg_r": [-10, -10], "leg_l": [-10, -10]
+ }
+ ]
+}
diff --git a/Exercise Library/Plank/preview.gif b/Exercise Library/Plank/preview.gif
new file mode 100644
index 0000000..2081a71
Binary files /dev/null and b/Exercise Library/Plank/preview.gif differ
diff --git a/Exercise Library/Plank/visual.svg b/Exercise Library/Plank/visual.svg
new file mode 100644
index 0000000..566d799
--- /dev/null
+++ b/Exercise Library/Plank/visual.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/README.md b/Exercise Library/README.md
new file mode 100644
index 0000000..60662ba
--- /dev/null
+++ b/Exercise Library/README.md
@@ -0,0 +1,29 @@
+# Exercise Library
+
+Reference material for exercises the app knows about — one folder per exercise,
+named exactly as the exercise appears in the app.
+
+Each entry contains:
+
+- `info.md` — the details, always in this order: a one-line summary, quick facts
+ (**Category**, **Type**, **Targets**, **Prescription**), then **Setup**,
+ **Execution**, **Cues**, **Common Mistakes**, and **Progression**
+ (easier → harder — get strong by making the move harder, not by endless reps).
+- `motion.json` — the exercise scripted as key frames of joint angles on the
+ shared rig (proportions in the library-level `body.json`), with IK pins for
+ planted hands/feet and tween timings. This is the **canonical source** for
+ all visuals and for the in-app animation planned for the lower half of the
+ exercise screen.
+- Generated from it by `render.py` (never hand-edited):
+ `frames/frame-N.svg` (one per key frame), `preview.gif` (the tweened loop),
+ and `visual.svg` (the primary frame, for static contexts).
+
+The rig and the visual language — right limbs dark / left limbs light with an
+embedded R/L legend, nose tick for facing, teal on the working parts, timing
+that encodes tempo, plus figure profiles, flipping, and theming — are defined
+in `SYSTEM.md`, along with the `motion.json` schema and rendering
+instructions.
+
+The library lives at the repo root, deliberately outside the app targets'
+source folders, and is not bundled into the app yet — the in-app presentation
+format is still being worked out.
diff --git a/Exercise Library/Reverse Crunch/frames/frame-1.svg b/Exercise Library/Reverse Crunch/frames/frame-1.svg
new file mode 100644
index 0000000..7353699
--- /dev/null
+++ b/Exercise Library/Reverse Crunch/frames/frame-1.svg
@@ -0,0 +1,16 @@
+
diff --git a/Exercise Library/Reverse Crunch/frames/frame-2.svg b/Exercise Library/Reverse Crunch/frames/frame-2.svg
new file mode 100644
index 0000000..63351ff
--- /dev/null
+++ b/Exercise Library/Reverse Crunch/frames/frame-2.svg
@@ -0,0 +1,16 @@
+
diff --git a/Exercise Library/Reverse Crunch/info.md b/Exercise Library/Reverse Crunch/info.md
new file mode 100644
index 0000000..a0ba037
--- /dev/null
+++ b/Exercise Library/Reverse Crunch/info.md
@@ -0,0 +1,38 @@
+# Reverse Crunch
+
+Curl the pelvis toward the ribs — lower-ab flexion without neck strain.
+
+- **Category:** Main circuit
+- **Type:** Spinal flexion (lower)
+- **Targets:** Lower rectus abdominis, deep core
+- **Prescription:** 3 × 12–15 reps, controlled negative
+
+## Setup
+
+Lie on your back, knees bent toward the chest, shins roughly parallel to the
+floor, arms along your sides pressing lightly into the floor.
+
+## Execution
+
+1. Exhale and curl the pelvis up and toward the ribs, lifting the hips off the
+ floor — knees travel toward the chin.
+2. Pause briefly at the top.
+3. Lower the hips back down slowly, one vertebra at a time.
+
+## Cues
+
+- Curl, don't kick: the knees drifting up should come from the pelvis tilting,
+ not from leg swing.
+- Controlled negative — resist gravity all the way down.
+- Keep the head and shoulders relaxed on the floor.
+
+## Common Mistakes
+
+- Swinging the legs and lifting with momentum.
+- Pushing hard through the arms instead of the abs.
+- Letting the hips drop back down in one piece.
+
+## Progression
+
+Feet-elevated pelvic tilts → reverse crunch → slow 3-count negatives →
+incline (head-up) reverse crunch → dragonflag negatives
diff --git a/Exercise Library/Reverse Crunch/motion.json b/Exercise Library/Reverse Crunch/motion.json
new file mode 100644
index 0000000..7be8997
--- /dev/null
+++ b/Exercise Library/Reverse Crunch/motion.json
@@ -0,0 +1,22 @@
+{
+ "name": "Reverse Crunch",
+ "primary": 2,
+ "working": ["spine"],
+ "hide": ["arm_r"],
+ "frames": [
+ {
+ "hold": 0.5, "tween": 0.7,
+ "root": [172, 148],
+ "spine": [180, 180], "neck": 168, "gaze": 90,
+ "arm_l": [-2, -2],
+ "leg_r": [100, -20], "leg_l": [100, -20]
+ },
+ {
+ "hold": 0.8, "tween": 0.9,
+ "root": [166, 136],
+ "spine": [195, 175], "neck": 182, "gaze": 90,
+ "arm_l": [-2, -2],
+ "leg_r": [115, -10], "leg_l": [115, -10]
+ }
+ ]
+}
diff --git a/Exercise Library/Reverse Crunch/preview.gif b/Exercise Library/Reverse Crunch/preview.gif
new file mode 100644
index 0000000..e5dd457
Binary files /dev/null and b/Exercise Library/Reverse Crunch/preview.gif differ
diff --git a/Exercise Library/Reverse Crunch/visual.svg b/Exercise Library/Reverse Crunch/visual.svg
new file mode 100644
index 0000000..63351ff
--- /dev/null
+++ b/Exercise Library/Reverse Crunch/visual.svg
@@ -0,0 +1,16 @@
+
diff --git a/Exercise Library/SYSTEM.md b/Exercise Library/SYSTEM.md
new file mode 100644
index 0000000..3adc340
--- /dev/null
+++ b/Exercise Library/SYSTEM.md
@@ -0,0 +1,97 @@
+# 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.
+- **`/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 + .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
+`.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.
diff --git a/Exercise Library/Side Plank/frames/frame-1.svg b/Exercise Library/Side Plank/frames/frame-1.svg
new file mode 100644
index 0000000..37757ef
--- /dev/null
+++ b/Exercise Library/Side Plank/frames/frame-1.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Side Plank/frames/frame-2.svg b/Exercise Library/Side Plank/frames/frame-2.svg
new file mode 100644
index 0000000..f674207
--- /dev/null
+++ b/Exercise Library/Side Plank/frames/frame-2.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/Side Plank/info.md b/Exercise Library/Side Plank/info.md
new file mode 100644
index 0000000..c1126cf
--- /dev/null
+++ b/Exercise Library/Side Plank/info.md
@@ -0,0 +1,36 @@
+# Side Plank
+
+Anti-lateral-flexion hold — resist the hips dropping toward the floor.
+
+- **Category:** Main circuit
+- **Type:** Anti-lateral flexion (isometric hold)
+- **Targets:** Obliques, quadratus lumborum, glute medius, shoulder stability
+- **Prescription:** 3 × 20–45 s per side
+
+## Setup
+
+Lie on one side, elbow directly under the shoulder, forearm across the floor.
+Feet stacked (or staggered), body in one straight line.
+
+## Execution
+
+1. Drive the hips up until shoulders, hips, and ankles form a straight line.
+2. Stack the hips vertically — don't let the top hip roll forward or back.
+3. Hold, then switch sides.
+
+## Cues
+
+- Hips *high*: most people sag an inch without noticing.
+- Push the floor away through the forearm — no collapsing into the shoulder.
+- Squeeze the bottom-side obliques and the glutes the whole hold.
+
+## Common Mistakes
+
+- Hips dropping toward the floor (the exact motion to resist).
+- Rolling the chest toward the ceiling or the floor.
+- Elbow drifting away from under the shoulder.
+
+## Progression
+
+Knees-down side plank → full side plank → top leg raised → reach-through
+(thread the top arm under the body and back up)
diff --git a/Exercise Library/Side Plank/motion.json b/Exercise Library/Side Plank/motion.json
new file mode 100644
index 0000000..6a2209a
--- /dev/null
+++ b/Exercise Library/Side Plank/motion.json
@@ -0,0 +1,23 @@
+{
+ "name": "Side Plank",
+ "primary": 2,
+ "working": ["spine"],
+ "frames": [
+ {
+ "hold": 0.4, "tween": 0.8,
+ "root": [174, 150],
+ "spine": [162, 162], "neck": 162, "gaze": 175,
+ "arm_r": [-90, 180], "arm_l": [-20, -15],
+ "leg_r": [-1, -1], "leg_l": [-1, -1],
+ "pins": {"hand_r": [60, 152]}
+ },
+ {
+ "hold": 1.8, "tween": 0.8,
+ "root": [174, 137],
+ "spine": [170, 170], "neck": 170, "gaze": 180,
+ "arm_r": [-90, 180], "arm_l": [80, 80],
+ "leg_r": [-10, -10], "leg_l": [-10, -10],
+ "pins": {"hand_r": [60, 152]}
+ }
+ ]
+}
diff --git a/Exercise Library/Side Plank/preview.gif b/Exercise Library/Side Plank/preview.gif
new file mode 100644
index 0000000..aeb8a29
Binary files /dev/null and b/Exercise Library/Side Plank/preview.gif differ
diff --git a/Exercise Library/Side Plank/visual.svg b/Exercise Library/Side Plank/visual.svg
new file mode 100644
index 0000000..f674207
--- /dev/null
+++ b/Exercise Library/Side Plank/visual.svg
@@ -0,0 +1,17 @@
+
diff --git a/Exercise Library/body.json b/Exercise Library/body.json
new file mode 100644
index 0000000..e19cb7f
--- /dev/null
+++ b/Exercise Library/body.json
@@ -0,0 +1,24 @@
+{
+ "neutral": {
+ "headR": 10,
+ "neck": 20,
+ "spine1": 43,
+ "spine2": 42,
+ "upperArm": 30,
+ "foreArm": 30,
+ "thigh": 46,
+ "shin": 40,
+ "leftOffset": [6, 2]
+ },
+ "female": {
+ "headR": 9,
+ "neck": 19,
+ "spine1": 40,
+ "spine2": 39,
+ "upperArm": 27,
+ "foreArm": 27,
+ "thigh": 47,
+ "shin": 41,
+ "leftOffset": [6, 2]
+ }
+}
diff --git a/Exercise Library/contact-sheet.png b/Exercise Library/contact-sheet.png
new file mode 100644
index 0000000..362701f
Binary files /dev/null and b/Exercise Library/contact-sheet.png differ
diff --git a/Exercise Library/demo-sheet.png b/Exercise Library/demo-sheet.png
new file mode 100644
index 0000000..420f883
Binary files /dev/null and b/Exercise Library/demo-sheet.png differ
diff --git a/Exercise Library/render.py b/Exercise Library/render.py
new file mode 100644
index 0000000..5ad2f8c
--- /dev/null
+++ b/Exercise Library/render.py
@@ -0,0 +1,442 @@
+#!/usr/bin/env python3
+"""Render Exercise Library visuals from an articulated 2D rig.
+
+A body profile (body.json: bone lengths) plus a per-exercise motion script
+(motion.json: key frames of absolute joint angles, root position, optional
+IK pins, timing) resolve through forward kinematics into stick-figure frames.
+See SYSTEM.md for the format and the visual language.
+
+Outputs per exercise: frames/frame-N.svg, preview.gif (tweened, looping),
+visual.svg (the primary frame). `--sheet` writes contact-sheet.png of every
+key frame; `--demo` writes demo-sheet.png showing rig customizations (figure
+profile, flip, theme). `--export` instead copies body.json and each entry's
+motion.json (as `.motion.json`) into Workouts/Resources/ExerciseMotions
+for the in-app SwiftUI renderer. SVGs need no dependencies; GIFs/sheets need
+Pillow.
+
+Angles are absolute world angles in degrees, y-up: 0 = toward +x (right),
+90 = straight up, 180 = toward -x (left), -90 = straight down.
+"""
+
+import json
+import math
+import sys
+from pathlib import Path
+
+LIB = Path(__file__).parent
+CANVAS = (320, 180)
+GROUND_Y = 152
+
+PALETTES = {
+ "default": {
+ "right": "#3a3f4b", "left": "#a9afba",
+ "right_working": "#0d9488", "left_working": "#86cfc5",
+ "ground": "#b9bec9", "legend_text": "#6b7180", "head_fill": "white",
+ },
+ "indigo": {
+ "right": "#3a3f4b", "left": "#a9afba",
+ "right_working": "#4f46e5", "left_working": "#a5b4fc",
+ "ground": "#b9bec9", "legend_text": "#6b7180", "head_fill": "white",
+ },
+}
+WIDTHS = {"right": 6, "left": 5, "spine": 6, "head": 6, "nose": 4}
+
+# Draw order: left limbs behind, spine, right limbs, then the head on top —
+# the head is filled opaque so limbs passing it (arms overhead beside the
+# ears) are occluded instead of crossing through the face.
+PART_ORDER = ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"]
+
+LIMBS = { # limb -> (attach joint, [bone length keys], pin key)
+ "arm_r": ("shoulder_r", ["upperArm", "foreArm"], "hand_r"),
+ "arm_l": ("shoulder_l", ["upperArm", "foreArm"], "hand_l"),
+ "leg_r": ("hip_r", ["thigh", "shin"], "foot_r"),
+ "leg_l": ("hip_l", ["thigh", "shin"], "foot_l"),
+}
+
+ANGLE_KEYS = ["spine", "neck", "gaze", "arm_r", "arm_l", "leg_r", "leg_l"]
+
+
+def bodies():
+ return json.loads((LIB / "body.json").read_text())
+
+
+def dirv(deg):
+ """Unit vector for a y-up angle, in y-down canvas coordinates."""
+ r = math.radians(deg)
+ return (math.cos(r), -math.sin(r))
+
+
+def walk(start, angles, lengths):
+ """Chain FK: [start, joint1, joint2, ...] for per-bone absolute angles."""
+ pts = [start]
+ x, y = start
+ for a, ln in zip(angles, lengths):
+ dx, dy = dirv(a)
+ x, y = x + dx * ln, y + dy * ln
+ pts.append((x, y))
+ return pts
+
+
+def angle_of(a, b):
+ """Y-up world angle of the segment a→b."""
+ return math.degrees(math.atan2(-(b[1] - a[1]), b[0] - a[0]))
+
+
+def ik2(start, target, a, b, guess_angles):
+ """Analytic 2-bone IK: angles [upper, lower] reaching from start toward
+ target, choosing the elbow solution nearest the authored guess."""
+ dx, dy_up = target[0] - start[0], -(target[1] - start[1])
+ d = max(abs(a - b) + 0.5, min(a + b - 0.01, math.hypot(dx, dy_up)))
+ base = math.degrees(math.atan2(dy_up, dx))
+ alpha = math.degrees(math.acos((a * a + d * d - b * b) / (2 * a * d)))
+ guess_elbow = walk(start, guess_angles, [a, b])[1]
+ best = None
+ for sign in (1, -1):
+ upper = base + sign * alpha
+ elbow = walk(start, [upper], [a])[1]
+ dist = math.dist(elbow, guess_elbow)
+ if best is None or dist < best[0]:
+ best = (dist, [upper, angle_of(elbow, target)])
+ return best[1]
+
+
+# ------------------------------------------------------------------- solver
+
+def flip_frame(kf, width):
+ """Mirror a key frame horizontally: x -> width - x, angle -> 180 - angle."""
+ out = dict(kf)
+ out["root"] = [width - kf["root"][0], kf["root"][1]]
+ for key in ANGLE_KEYS:
+ if key not in kf:
+ continue
+ v = kf[key]
+ out[key] = [180 - a for a in v] if isinstance(v, list) else 180 - v
+ if "pins" in kf:
+ out["pins"] = {k: [width - x, y] for k, (x, y) in kf["pins"].items()}
+ return out
+
+
+def normalize(kf, body, flip=False):
+ """Resolve a key frame to pure angles: apply the mirror, then replace each
+ pinned limb's authored angles with its IK solution. Normalized frames
+ interpolate cleanly (angle space), and geometry() re-pins tween frames."""
+ if flip:
+ kf = flip_frame(kf, CANVAS[0])
+ out = {"root": list(kf["root"]), "pins": dict(kf.get("pins", {})),
+ "hold": kf.get("hold", 0.5), "tween": kf.get("tween", 0.6)}
+ for key in ANGLE_KEYS:
+ if key in kf:
+ out[key] = list(kf[key]) if isinstance(kf[key], list) else kf[key]
+ attach = attach_points(out, body, flip)
+ for limb, (joint, bone_keys, pin) in LIMBS.items():
+ if limb in out and pin in out["pins"]:
+ lengths = [body[k] for k in bone_keys]
+ out[limb] = ik2(attach[joint], out["pins"][pin], *lengths, out[limb])
+ return out
+
+
+def attach_points(frame, body, flip=False):
+ """FK for the trunk: pelvis, spine mid, neck joint, and limb attachments."""
+ pelvis = tuple(frame["root"])
+ mid = walk(pelvis, [frame["spine"][0]], [body["spine1"]])[1]
+ neck = walk(mid, [frame["spine"][1]], [body["spine2"]])[1]
+ ox, oy = body.get("leftOffset", [6, 2])
+ if flip:
+ ox = -ox
+ return {"pelvis": pelvis, "mid": mid, "neck": neck,
+ "shoulder_r": neck, "shoulder_l": (neck[0] + ox, neck[1] + oy),
+ "hip_r": pelvis, "hip_l": (pelvis[0] + ox, pelvis[1] + oy)}
+
+
+def geometry(frame, body, hide=(), flip=False):
+ """Normalized frame -> drawable points. Limbs with an active pin are
+ re-solved so planted hands/feet hold exactly through tweens."""
+ at = attach_points(frame, body, flip)
+ head = walk(at["neck"], [frame["neck"]], [body["neck"]])[1]
+ nose_tip = walk(head, [frame["gaze"]], [body["headR"] + 7])[1]
+ geo = {"head": head, "nose": nose_tip, "headR": body["headR"],
+ "spine": [at["pelvis"],
+ (2 * at["mid"][0] - (at["pelvis"][0] + at["neck"][0]) / 2,
+ 2 * at["mid"][1] - (at["pelvis"][1] + at["neck"][1]) / 2),
+ at["neck"]]}
+ for limb, (joint, bone_keys, pin) in LIMBS.items():
+ if limb not in frame or limb in hide:
+ continue
+ lengths = [body[k] for k in bone_keys]
+ angles = frame[limb]
+ if pin in frame.get("pins", {}):
+ angles = ik2(at[joint], frame["pins"][pin], *lengths, angles)
+ geo[limb] = walk(at[joint], angles, lengths)
+ return geo
+
+
+# ------------------------------------------------------------------ tweening
+
+def ease(t):
+ return 3 * t * t - 2 * t * t * t
+
+
+def lerp_angle(a, b, t):
+ return a + ((b - a + 180) % 360 - 180) * t
+
+
+def lerp_norm(a, b, t):
+ out = {"root": [a["root"][0] + (b["root"][0] - a["root"][0]) * t,
+ a["root"][1] + (b["root"][1] - a["root"][1]) * t]}
+ for key in ANGLE_KEYS:
+ if key not in a or key not in b:
+ continue
+ va, vb = a[key], b[key]
+ out[key] = ([lerp_angle(x, y, t) for x, y in zip(va, vb)]
+ if isinstance(va, list) else lerp_angle(va, vb, t))
+ # A pin survives a tween only if planted in both neighboring key frames.
+ out["pins"] = {k: [a["pins"][k][0] + (b["pins"][k][0] - a["pins"][k][0]) * t,
+ a["pins"][k][1] + (b["pins"][k][1] - a["pins"][k][1]) * t]
+ for k in a["pins"] if k in b["pins"]}
+ return out
+
+
+def timeline(norms, fps=20):
+ frames = []
+ for i, kf in enumerate(norms):
+ frames += [kf] * max(1, round(kf["hold"] * fps))
+ nxt = norms[(i + 1) % len(norms)]
+ steps = max(1, round(kf["tween"] * fps))
+ frames += [lerp_norm(kf, nxt, ease(s / steps)) for s in range(1, steps)]
+ return frames
+
+
+# ------------------------------------------------------------------- drawing
+
+def part_style(part, working, colors):
+ side = "left" if part.endswith("_l") else "right"
+ color = colors[f"{side}_working"] if part in working else colors[side]
+ width = WIDTHS["spine"] if part == "spine" else WIDTHS[side]
+ return color, width
+
+
+def quad_points(p0, ctrl, p2, n=24):
+ pts = []
+ for i in range(n + 1):
+ t = i / n
+ pts.append(((1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * ctrl[0] + t ** 2 * p2[0],
+ (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * ctrl[1] + t ** 2 * p2[1]))
+ return pts
+
+
+def nose_segment(geo):
+ hx, hy = geo["head"]
+ nx, ny = geo["nose"]
+ d = math.hypot(nx - hx, ny - hy) or 1.0
+ ux, uy = (nx - hx) / d, (ny - hy) / d
+ r = geo["headR"]
+ return (hx + ux * r, hy + uy * r), (hx + ux * (r + 7), hy + uy * (r + 7))
+
+
+def svg_for_frame(name, geo, working, colors):
+ w, h = CANVAS
+ parts = [f'')
+ return "\n".join(parts) + "\n"
+
+
+def draw_geo(geo, working, colors, scale=2, font=None):
+ from PIL import Image, ImageDraw
+
+ w, h = CANVAS[0] * scale, CANVAS[1] * scale
+ img = Image.new("RGB", (w, h), "white")
+ d = ImageDraw.Draw(img)
+
+ def line(pts, color, width):
+ pts = [(x * scale, y * scale) for x, y in pts]
+ d.line(pts, fill=color, width=width * scale, joint="curve")
+ r = width * scale / 2
+ for x, y in (pts[0], pts[-1]):
+ d.ellipse([x - r, y - r, x + r, y + r], fill=color)
+
+ line([(16, GROUND_Y + 4), (CANVAS[0] - 16, GROUND_Y + 4)], colors["ground"], 3)
+ for part in PART_ORDER:
+ if part == "head":
+ hx, hy = geo["head"]
+ r, sw = geo["headR"] * scale, WIDTHS["head"] * scale
+ d.ellipse([hx * scale - r, hy * scale - r, hx * scale + r, hy * scale + r],
+ fill=colors["head_fill"], outline=colors["right"], width=sw)
+ line(list(nose_segment(geo)), colors["right"], WIDTHS["nose"])
+ continue
+ if part not in geo:
+ continue
+ color, width = part_style(part, working, colors)
+ pts = quad_points(*geo["spine"]) if part == "spine" else geo[part]
+ line(pts, color, width)
+
+ lx = CANVAS[0] - 78
+ line([(lx, 16), (lx + 16, 16)], colors["right"], 4)
+ line([(lx + 40, 16), (lx + 56, 16)], colors["left"], 4)
+ if font:
+ d.text((lx * scale + 22 * scale, 16 * scale - 11 * scale / 2 - 2), "R",
+ fill=colors["legend_text"], font=font)
+ d.text((lx * scale + 62 * scale, 16 * scale - 11 * scale / 2 - 2), "L",
+ fill=colors["legend_text"], font=font)
+ return img.resize(CANVAS, Image.LANCZOS)
+
+
+def legend_font(scale=2):
+ from PIL import ImageFont
+ try:
+ return ImageFont.load_default(size=11 * scale)
+ except TypeError:
+ return ImageFont.load_default()
+
+
+# --------------------------------------------------------------------- main
+
+def load_motion(folder):
+ return json.loads((folder / "motion.json").read_text())
+
+
+def export_app_resources(folders):
+ """Write the app's bundled copies: body.json plus one `.motion.json`
+ per library entry. Unique basenames — Xcode copies resources flat, so the
+ per-entry motion.json files can't ship under their own folder names."""
+ out = LIB.parent / "Workouts" / "Resources" / "ExerciseMotions"
+ out.mkdir(parents=True, exist_ok=True)
+ (out / "body.json").write_text((LIB / "body.json").read_text())
+ for folder in folders:
+ motion = load_motion(folder)
+ (out / f"{motion['name']}.motion.json").write_text(
+ (folder / "motion.json").read_text())
+ print(f" exported {motion['name']}.motion.json")
+ print(f" exported body.json -> {out}")
+
+
+def render_exercise(folder, figure="neutral", flip=False):
+ motion = load_motion(folder)
+ body = bodies()[figure]
+ working = set(motion.get("working", []))
+ hide = set(motion.get("hide", []))
+ norms = [normalize(kf, body, flip) for kf in motion["frames"]]
+ geos = [geometry(n, body, hide, flip) for n in norms]
+
+ frames_dir = folder / "frames"
+ frames_dir.mkdir(exist_ok=True)
+ for old in frames_dir.glob("frame-*.svg"):
+ old.unlink()
+ colors = PALETTES["default"]
+ svgs = [svg_for_frame(motion["name"], g, working, colors) for g in geos]
+ for i, svg in enumerate(svgs, start=1):
+ (frames_dir / f"frame-{i}.svg").write_text(svg)
+ (folder / "visual.svg").write_text(svgs[motion.get("primary", 1) - 1])
+
+ try:
+ font = legend_font()
+ imgs = [draw_geo(geometry(n, body, hide, flip), working, colors, font=font)
+ for n in timeline(norms)]
+ imgs[0].save(folder / "preview.gif", save_all=True, append_images=imgs[1:],
+ duration=50, loop=0)
+ print(f" {motion['name']}: {len(svgs)} frames, preview.gif")
+ except ImportError:
+ print(f" {motion['name']}: Pillow missing — SVGs written, preview.gif skipped")
+
+
+def contact_sheet(folders, figure="neutral"):
+ from PIL import Image, ImageDraw
+
+ font = legend_font()
+ body = bodies()[figure]
+ cells = []
+ for folder in folders:
+ motion = load_motion(folder)
+ working, hide = set(motion.get("working", [])), set(motion.get("hide", []))
+ for i, kf in enumerate(motion["frames"], start=1):
+ geo = geometry(normalize(kf, body), body, hide)
+ cells.append((f"{motion['name']} {i}/{len(motion['frames'])}",
+ draw_geo(geo, working, PALETTES["default"], font=font)))
+ save_sheet(cells, LIB / "contact-sheet.png")
+
+
+def demo_sheet(folder):
+ """One exercise's primary frame rendered four ways — the doors the rig
+ opens: neutral, female profile, flipped, alternate theme."""
+ motion = load_motion(folder)
+ working, hide = set(motion.get("working", [])), set(motion.get("hide", []))
+ kf = motion["frames"][motion.get("primary", 1) - 1]
+ font = legend_font()
+ variants = [("neutral", "neutral", False, "default"),
+ ("female profile", "female", False, "default"),
+ ("flipped", "neutral", True, "default"),
+ ("themed (indigo)", "neutral", False, "indigo")]
+ cells = []
+ for label, figure, flip, palette in variants:
+ body = bodies()[figure]
+ geo = geometry(normalize(kf, body, flip), body, hide, flip)
+ cells.append((f"{motion['name']} — {label}",
+ draw_geo(geo, working, PALETTES[palette], font=font)))
+ save_sheet(cells, LIB / "demo-sheet.png", cols=2)
+
+
+def save_sheet(cells, path, cols=4):
+ from PIL import Image, ImageDraw
+
+ rows = (len(cells) + cols - 1) // cols
+ cw, ch, cap = CANVAS[0], CANVAS[1], 22
+ sheet = Image.new("RGB", (cols * (cw + 8) + 8, rows * (ch + cap + 8) + 8), "white")
+ d = ImageDraw.Draw(sheet)
+ for i, (label, img) in enumerate(cells):
+ x, y = 8 + (i % cols) * (cw + 8), 8 + (i // cols) * (ch + cap + 8)
+ sheet.paste(img, (x, y))
+ d.text((x + 6, y + ch + 3), label, fill=PALETTES["default"]["right"])
+ sheet.save(path)
+ print(f" {path.name} ({len(cells)} cells)")
+
+
+def main():
+ flags = [a for a in sys.argv[1:] if a.startswith("--")]
+ names = [a for a in sys.argv[1:] if not a.startswith("--")]
+ figure = "neutral"
+ for f in flags:
+ if f.startswith("--figure="):
+ figure = f.split("=", 1)[1]
+ folders = ([LIB / n for n in names] if names
+ else sorted(p.parent for p in LIB.glob("*/motion.json")))
+ if "--export" in flags:
+ export_app_resources(folders)
+ return
+ for folder in folders:
+ render_exercise(folder, figure=figure, flip="--flip" in flags)
+ if "--sheet" in flags:
+ contact_sheet(folders, figure=figure)
+ if "--demo" in flags:
+ demo_sheet(folders[0] if names else LIB / "Bird Dog")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Shared/Model/Documents.swift b/Shared/Model/Documents.swift
index dbd4185..f15370f 100644
--- a/Shared/Model/Documents.swift
+++ b/Shared/Model/Documents.swift
@@ -46,6 +46,10 @@ struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable {
var durationSeconds: Int // total seconds (0 when not a timed exercise)
var weightLastUpdated: Date?
var weightReminderWeeks: Int
+ /// Raw `ExerciseCategory`; nil → main circuit. Like `activityType`, deliberately
+ /// NOT schema-bumped: it only affects how the split screen groups its list, so an
+ /// older app dropping it on rewrite beats quarantining the routine.
+ var category: Int?
}
// MARK: - Workout
diff --git a/Shared/Model/Entities.swift b/Shared/Model/Entities.swift
index cec8f6f..407eb25 100644
--- a/Shared/Model/Entities.swift
+++ b/Shared/Model/Entities.swift
@@ -64,12 +64,13 @@ final class Exercise {
var durationTotalSeconds: Int = 0
var weightLastUpdated: Date?
var weightReminderTimeIntervalWeeks: Int = 2
+ var categoryRaw: Int = ExerciseCategory.main.rawValue
var split: Split?
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int,
loadType: Int, durationTotalSeconds: Int, weightLastUpdated: Date?,
- weightReminderTimeIntervalWeeks: Int) {
+ weightReminderTimeIntervalWeeks: Int, categoryRaw: Int = ExerciseCategory.main.rawValue) {
self.id = id
self.name = name
self.order = order
@@ -80,6 +81,7 @@ final class Exercise {
self.durationTotalSeconds = durationTotalSeconds
self.weightLastUpdated = weightLastUpdated
self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks
+ self.categoryRaw = categoryRaw
}
var loadTypeEnum: LoadType {
@@ -87,6 +89,27 @@ final class Exercise {
set { loadType = newValue.rawValue }
}
+ var categoryEnum: ExerciseCategory {
+ get { ExerciseCategory(rawValue: categoryRaw) ?? .main }
+ set { categoryRaw = newValue.rawValue }
+ }
+
+ /// One-line plan summary for list rows — duration exercises show their hold time
+ /// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones
+ /// keep the full "3 × 10 × 40 lb".
+ func planSummary(weightUnit: WeightUnit) -> String {
+ switch loadTypeEnum {
+ case .duration:
+ let m = durationMinutes, s = durationSeconds
+ let hold = m > 0 && s > 0 ? "\(m)m \(s)s" : (m > 0 ? "\(m) min" : "\(s) sec")
+ return "\(sets) × \(hold)"
+ case .none:
+ return "\(sets) × \(reps) reps"
+ case .weight:
+ return "\(sets) × \(reps) × \(weightUnit.format(weight))"
+ }
+ }
+
/// Minutes component of the total duration (for min:sec pickers).
var durationMinutes: Int {
get { durationTotalSeconds / 60 }
diff --git a/Shared/Model/Enums.swift b/Shared/Model/Enums.swift
index 92e12ff..5d108b5 100644
--- a/Shared/Model/Enums.swift
+++ b/Shared/Model/Enums.swift
@@ -35,6 +35,24 @@ enum LoadType: Int, CaseIterable, Codable, Sendable {
}
}
+/// Which segment of a split an exercise belongs to — the split screen groups its
+/// exercise list by this. Persisted as its raw `Int` value; `main` is the default
+/// (and what older documents without the field decode to).
+enum ExerciseCategory: Int, CaseIterable, Codable, Sendable {
+ case main = 0
+ case warmup = 1
+
+ /// Order the categories appear on screen: warm-up first, then the main circuit.
+ static let displayOrder: [ExerciseCategory] = [.warmup, .main]
+
+ var displayName: String {
+ switch self {
+ case .main: "Main Circuit"
+ case .warmup: "Warm-up"
+ }
+ }
+}
+
/// The kind of training a split represents — used to tag the Apple Health workout
/// so it lands in the right category (and credits the rings correctly), and to pick
/// a MET value when the phone has to estimate calories without watch sensor data.
diff --git a/Shared/Model/Mappers.swift b/Shared/Model/Mappers.swift
index f3589cf..c779029 100644
--- a/Shared/Model/Mappers.swift
+++ b/Shared/Model/Mappers.swift
@@ -18,7 +18,8 @@ extension ExerciseDocument {
self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps,
weight: e.weight, loadType: e.loadType, durationSeconds: e.durationTotalSeconds,
weightLastUpdated: e.weightLastUpdated,
- weightReminderWeeks: e.weightReminderTimeIntervalWeeks)
+ weightReminderWeeks: e.weightReminderTimeIntervalWeeks,
+ category: e.categoryRaw)
}
}
@@ -108,7 +109,8 @@ enum CacheMapper {
Exercise(id: d.id, name: d.name, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight,
loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
weightLastUpdated: d.weightLastUpdated,
- weightReminderTimeIntervalWeeks: d.weightReminderWeeks)
+ weightReminderTimeIntervalWeeks: d.weightReminderWeeks,
+ categoryRaw: d.category ?? ExerciseCategory.main.rawValue)
}
private static func apply(_ d: ExerciseDocument, to e: Exercise) {
@@ -121,6 +123,7 @@ enum CacheMapper {
e.durationTotalSeconds = d.durationSeconds
e.weightLastUpdated = d.weightLastUpdated
e.weightReminderTimeIntervalWeeks = d.weightReminderWeeks
+ e.categoryRaw = d.category ?? ExerciseCategory.main.rawValue
}
// MARK: Workout
diff --git a/Shared/Persistence/WorkoutsModelContainer.swift b/Shared/Persistence/WorkoutsModelContainer.swift
index 30ca774..59dd611 100644
--- a/Shared/Persistence/WorkoutsModelContainer.swift
+++ b/Shared/Persistence/WorkoutsModelContainer.swift
@@ -7,7 +7,8 @@ import SwiftData
enum WorkoutsModelContainer {
/// Bump whenever the cache schema changes — wipes and rebuilds from files.
/// 2: added health-metric columns to `Workout` and `activityTypeRaw` to `Split`.
- static let currentSchemaVersion = 2
+ /// 3: added `categoryRaw` to `Exercise`.
+ static let currentSchemaVersion = 3
private static let schemaVersionKey = "Workouts.persistenceSchemaVersion"
private static let identityTokenKey = "Workouts.iCloudIdentityToken"
diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift
index 85330ac..cf5123e 100644
--- a/Workouts Watch App/Views/ExerciseProgressView.swift
+++ b/Workouts Watch App/Views/ExerciseProgressView.swift
@@ -79,6 +79,11 @@ struct ExerciseProgressView: View {
/// transient TabView snap-to-0, so it isn't reset on open.
@State private var startsResumed: Bool
+ /// True when the exercise was already completed when this screen opened — it shows a
+ /// static Completed page instead of dropping back into the timer flow. Fixed at init
+ /// so completing the exercise from inside the flow doesn't swap the page mid-dismiss.
+ @State private var startsCompleted: Bool
+
/// Forces the starting page (used only by the DEBUG screenshot host). When set it
/// also suppresses the Ready page so the index is a plain work/rest cycle offset.
private let debugInitialPage: Int?
@@ -102,6 +107,7 @@ struct ExerciseProgressView: View {
// set and re-asserts that page past the TabView's snap-to-0 on first layout.
let ready = debugInitialPage == nil
_startsResumed = State(initialValue: ready && !notStarted)
+ _startsCompleted = State(initialValue: ready && log?.status == WorkoutStatus.completed.rawValue)
let base = ready ? 1 : 0
// Resume on the first unfinished set's work page (clamped to the last set).
@@ -182,6 +188,14 @@ struct ExerciseProgressView: View {
}
var body: some View {
+ if startsCompleted {
+ CompletedPhaseView()
+ } else {
+ flowBody
+ }
+ }
+
+ private var flowBody: some View {
TabView(selection: $currentPage) {
ForEach(0..
+ /// Limbs fully occluded in this view — never drawn.
+ let hide: Set
+ let bodyProfile: ExerciseBodyProfile
+
+ init?(exerciseName: String) {
+ guard
+ let resources = ExerciseMotionLibrary.resources(for: exerciseName),
+ let timeline = MotionTimeline(motion: resources.motion, body: resources.body)
+ else { return nil }
+ self.timeline = timeline
+ self.working = Set(resources.motion.working ?? [])
+ self.hide = Set(resources.motion.hide ?? [])
+ self.bodyProfile = resources.body
+ }
+
+ func geometry(at time: Double) -> FigureGeometry {
+ MotionSolver.geometry(of: timeline.pose(at: time), body: bodyProfile, hide: hide)
+ }
+}
+
+/// Bottom-half slot for the run screen: the looping figure when a bundled motion
+/// matches the exercise name, or empty space (the pre-figure layout) when none does.
+struct ExerciseFigureSlot: View {
+ let exerciseName: String
+
+ @State private var figure: FigureAnimation?
+
+ var body: some View {
+ ZStack {
+ if let figure {
+ ExerciseFigureView(figure: figure)
+ } else {
+ Color.clear
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .task(id: exerciseName) {
+ figure = FigureAnimation(exerciseName: exerciseName)
+ }
+ }
+}
+
+/// Draws one `FigureAnimation`, looping forever. The 320×180 design canvas is scaled
+/// uniformly to fit the available space (stroke widths scale with it).
+struct ExerciseFigureView: View {
+ let figure: FigureAnimation
+
+ /// Design-canvas metrics, shared with the reference renderer.
+ private static let designSize = CGSize(width: 320, height: 180)
+ private static let groundY: CGFloat = 152
+
+ var body: some View {
+ TimelineView(.animation) { context in
+ Canvas { graphics, size in
+ var ctx = graphics
+ draw(&ctx, size: size, time: context.date.timeIntervalSinceReferenceDate)
+ }
+ }
+ .accessibilityLabel("Animated form guide")
+ .accessibilityHidden(false)
+ }
+
+ private func draw(_ ctx: inout GraphicsContext, size: CGSize, time: Double) {
+ let scale = min(size.width / Self.designSize.width, size.height / Self.designSize.height)
+ guard scale > 0 else { return }
+ ctx.translateBy(x: (size.width - Self.designSize.width * scale) / 2,
+ y: (size.height - Self.designSize.height * scale) / 2)
+ ctx.scaleBy(x: scale, y: scale)
+
+ let geo = figure.geometry(at: time)
+
+ // Ground line.
+ stroke(&ctx, [CGPoint(x: 16, y: Self.groundY + 4),
+ CGPoint(x: Self.designSize.width - 16, y: Self.groundY + 4)],
+ color: .figureGround, width: 3)
+
+ // Left limbs (behind), spine, right limbs.
+ drawLimb(&ctx, geo, .armL)
+ drawLimb(&ctx, geo, .legL)
+ drawSpine(&ctx, geo)
+ drawLimb(&ctx, geo, .armR)
+ drawLimb(&ctx, geo, .legR)
+
+ // Head last, filled with the background color so limbs pass behind the face.
+ let r = geo.headRadius
+ let headRect = CGRect(x: geo.headCenter.x - r, y: geo.headCenter.y - r,
+ width: 2 * r, height: 2 * r)
+ let headPath = Path(ellipseIn: headRect)
+ ctx.fill(headPath, with: .color(.figureHeadFill))
+ ctx.stroke(headPath, with: .color(.figureRight), lineWidth: 6)
+ stroke(&ctx, [geo.noseStart, geo.noseEnd], color: .figureRight, width: 4)
+
+ drawLegend(&ctx)
+ }
+
+ private func drawLimb(_ ctx: inout GraphicsContext, _ geo: FigureGeometry, _ limb: FigureLimb) {
+ guard let points = geo.limbs[limb] else { return }
+ stroke(&ctx, points, color: color(for: limb.rawValue, isLeft: limb.isLeft),
+ width: limb.isLeft ? 5 : 6)
+ }
+
+ private func drawSpine(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
+ var path = Path()
+ path.move(to: geo.spineStart)
+ path.addQuadCurve(to: geo.spineEnd, control: geo.spineControl)
+ ctx.stroke(path, with: .color(color(for: "spine", isLeft: false)),
+ style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round))
+ }
+
+ /// Small `R — / L —` legend in the top-right corner, so opposite-limb moves are
+ /// visibly opposite.
+ private func drawLegend(_ ctx: inout GraphicsContext) {
+ let lx = Self.designSize.width - 78
+ stroke(&ctx, [CGPoint(x: lx, y: 16), CGPoint(x: lx + 16, y: 16)],
+ color: .figureRight, width: 4)
+ stroke(&ctx, [CGPoint(x: lx + 40, y: 16), CGPoint(x: lx + 56, y: 16)],
+ color: .figureLeft, width: 4)
+ let legendFont = Font.system(size: 11)
+ ctx.draw(Text("R").font(legendFont).foregroundStyle(.secondary),
+ at: CGPoint(x: lx + 22, y: 16), anchor: .leading)
+ ctx.draw(Text("L").font(legendFont).foregroundStyle(.secondary),
+ at: CGPoint(x: lx + 62, y: 16), anchor: .leading)
+ }
+
+ private func color(for part: String, isLeft: Bool) -> Color {
+ if figure.working.contains(part) {
+ return isLeft ? .figureLeftWorking : .figureRightWorking
+ }
+ return isLeft ? .figureLeft : .figureRight
+ }
+
+ private func stroke(_ ctx: inout GraphicsContext, _ points: [CGPoint], color: Color, width: CGFloat) {
+ var path = Path()
+ path.addLines(points)
+ ctx.stroke(path, with: .color(color),
+ style: StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round))
+ }
+}
+
+// MARK: - Figure Palette
+
+/// The reference palette (`render.py`), made dark-mode adaptive: the prominent right
+/// side stays strong (near-black → light gray), the recessive left side stays muted,
+/// and the working teals brighten/desaturate so they read on black.
+private extension Color {
+ /// Right-side limbs, head, nose — the prominent stroke (`#3a3f4b`).
+ static let figureRight = Color(UIColor { traits in
+ traits.userInterfaceStyle == .dark
+ ? UIColor(red: 0.76, green: 0.79, blue: 0.83, alpha: 1)
+ : UIColor(red: 0.23, green: 0.25, blue: 0.29, alpha: 1)
+ })
+
+ /// Left-side limbs, drawn behind — the recessive stroke (`#a9afba`).
+ static let figureLeft = Color(UIColor { traits in
+ traits.userInterfaceStyle == .dark
+ ? UIColor(red: 0.36, green: 0.39, blue: 0.43, alpha: 1)
+ : UIColor(red: 0.66, green: 0.69, blue: 0.73, alpha: 1)
+ })
+
+ /// Working right-side parts — teal accent (`#0d9488`).
+ static let figureRightWorking = Color(UIColor { traits in
+ traits.userInterfaceStyle == .dark
+ ? UIColor(red: 0.18, green: 0.83, blue: 0.75, alpha: 1)
+ : UIColor(red: 0.05, green: 0.58, blue: 0.53, alpha: 1)
+ })
+
+ /// Working left-side parts — light teal (`#86cfc5`), kept more muted than the
+ /// right so the R/L hierarchy holds in both modes.
+ static let figureLeftWorking = Color(UIColor { traits in
+ traits.userInterfaceStyle == .dark
+ ? UIColor(red: 0.31, green: 0.56, blue: 0.52, alpha: 1)
+ : UIColor(red: 0.53, green: 0.81, blue: 0.77, alpha: 1)
+ })
+
+ /// Ground line (`#b9bec9`).
+ static let figureGround = Color(UIColor { traits in
+ traits.userInterfaceStyle == .dark
+ ? UIColor(red: 0.29, green: 0.31, blue: 0.36, alpha: 1)
+ : UIColor(red: 0.73, green: 0.75, blue: 0.79, alpha: 1)
+ })
+
+ /// Opaque head fill — the screen background, so limbs pass behind the face.
+ static let figureHeadFill = Color(.systemBackground)
+}
diff --git a/Workouts/ExerciseFigure/ExerciseMotion.swift b/Workouts/ExerciseFigure/ExerciseMotion.swift
new file mode 100644
index 0000000..a40cf04
--- /dev/null
+++ b/Workouts/ExerciseFigure/ExerciseMotion.swift
@@ -0,0 +1,101 @@
+//
+// ExerciseMotion.swift
+// Workouts
+//
+// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
+//
+
+import Foundation
+
+/// Codable mirror of the Exercise Library's rig data (see `Exercise Library/SYSTEM.md`).
+///
+/// A **body profile** is a table of bone lengths; a **motion script** is key frames of
+/// absolute joint angles (degrees, y-up: 0 = right, 90 = up, −90 = down), a pelvis
+/// `root` in 320×180 canvas coordinates, optional IK pins for planted hands/feet, and
+/// hold/tween timings. The app bundles verbatim copies exported by
+/// `render.py --export` into `Resources/ExerciseMotions/` — `body.json` plus one
+/// `.motion.json` per library entry.
+
+/// Bone lengths for one figure profile (`neutral` is the default).
+struct ExerciseBodyProfile: Codable {
+ let headR: Double
+ let neck: Double
+ let spine1: Double
+ let spine2: Double
+ let upperArm: Double
+ let foreArm: Double
+ let thigh: Double
+ let shin: Double
+ /// Offset separating left-limb attachments visually: `[dx, dy]` in canvas points.
+ let leftOffset: [Double]
+}
+
+/// One exercise's motion script: key frames plus the parts drawn in the accent color.
+struct ExerciseMotion: Codable {
+ let name: String
+ /// 1-based frame used for the static visual (unused by the animated renderer).
+ let primary: Int?
+ /// Parts (`arm_r`, `leg_l`, `spine`, …) drawn in the working accent color.
+ let working: [String]?
+ /// Limbs fully occluded in this view — never drawn.
+ let hide: [String]?
+ let frames: [MotionKeyFrame]
+}
+
+/// A key frame of absolute joint angles. Limbs listed in the motion's `hide` may be
+/// absent entirely; a two-element array is `[upper, lower]` bone angles.
+struct MotionKeyFrame: Codable {
+ /// Seconds held at this key frame (default 0.5).
+ let hold: Double?
+ /// Seconds animating to the *next* frame (default 0.6); the last frame tweens
+ /// back to the first, looping.
+ let tween: Double?
+ /// Pelvis position, canvas coordinates `[x, y]`.
+ let root: [Double]
+ /// Pelvis→mid and mid→neck angles.
+ let spine: [Double]
+ /// Head direction from the neck joint.
+ let neck: Double
+ /// Nose-tick direction (the belly is on that side).
+ let gaze: Double
+ let armR: [Double]?
+ let armL: [Double]?
+ let legR: [Double]?
+ let legL: [Double]?
+ /// IK targets for planted extremities, keyed `hand_r`/`hand_l`/`foot_r`/`foot_l`.
+ /// A pin present in two consecutive key frames stays planted through the tween.
+ let pins: [String: [Double]]?
+
+ enum CodingKeys: String, CodingKey {
+ case hold, tween, root, spine, neck, gaze, pins
+ case armR = "arm_r"
+ case armL = "arm_l"
+ case legR = "leg_r"
+ case legL = "leg_l"
+ }
+}
+
+/// Finds and decodes the bundled rig resources for an exercise, by exact name match
+/// against the exported `.motion.json` files.
+enum ExerciseMotionLibrary {
+ struct Resources {
+ let motion: ExerciseMotion
+ let body: ExerciseBodyProfile
+ }
+
+ /// The motion script plus the neutral body profile for `exerciseName`, or `nil`
+ /// when no bundled motion matches (most exercises have none — the caller keeps
+ /// its space empty).
+ static func resources(for exerciseName: String) -> Resources? {
+ guard
+ let motionURL = Bundle.main.url(forResource: exerciseName, withExtension: "motion.json"),
+ let bodyURL = Bundle.main.url(forResource: "body", withExtension: "json"),
+ let motionData = try? Data(contentsOf: motionURL),
+ let bodyData = try? Data(contentsOf: bodyURL),
+ let motion = try? JSONDecoder().decode(ExerciseMotion.self, from: motionData),
+ let profiles = try? JSONDecoder().decode([String: ExerciseBodyProfile].self, from: bodyData),
+ let body = profiles["neutral"]
+ else { return nil }
+ return Resources(motion: motion, body: body)
+ }
+}
diff --git a/Workouts/ExerciseFigure/MotionSolver.swift b/Workouts/ExerciseFigure/MotionSolver.swift
new file mode 100644
index 0000000..7cebd1e
--- /dev/null
+++ b/Workouts/ExerciseFigure/MotionSolver.swift
@@ -0,0 +1,288 @@
+//
+// MotionSolver.swift
+// Workouts
+//
+// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
+//
+
+import CoreGraphics
+import Foundation
+
+/// Swift port of the Exercise Library's reference solver (`Exercise Library/render.py`):
+/// forward kinematics, analytic 2-bone IK, and angle-space tweening with shortest-path
+/// lerp and ease-in-out. The math is kept 1:1 with the Python so both renderers produce
+/// the same figure from the same data — change them in lockstep.
+///
+/// Angles are absolute world angles in degrees, y-up (0 = right, 90 = up, −90 = down);
+/// points are y-down 320×180 canvas coordinates, so dir(θ) = (cos θ, −sin θ).
+
+/// The four two-bone limbs, keyed by their motion-script names.
+enum FigureLimb: String, CaseIterable {
+ case armR = "arm_r"
+ case armL = "arm_l"
+ case legR = "leg_r"
+ case legL = "leg_l"
+
+ var isLeft: Bool { self == .armL || self == .legL }
+
+ /// The key a planted extremity uses in a key frame's `pins`.
+ var pinKey: String {
+ switch self {
+ case .armR: "hand_r"
+ case .armL: "hand_l"
+ case .legR: "foot_r"
+ case .legL: "foot_l"
+ }
+ }
+
+ /// `[upper, lower]` bone lengths for this limb.
+ func boneLengths(_ body: ExerciseBodyProfile) -> [Double] {
+ switch self {
+ case .armR, .armL: [body.upperArm, body.foreArm]
+ case .legR, .legL: [body.thigh, body.shin]
+ }
+ }
+
+ /// The authored `[upper, lower]` angles for this limb, if present in the frame.
+ func angles(in frame: MotionKeyFrame) -> [Double]? {
+ switch self {
+ case .armR: frame.armR
+ case .armL: frame.armL
+ case .legR: frame.legR
+ case .legL: frame.legL
+ }
+ }
+}
+
+/// A key frame resolved to pure angles (pinned limbs replaced by their IK solution),
+/// so poses interpolate cleanly in angle space.
+struct FigurePose {
+ var root: CGPoint
+ var spine: [Double]
+ var neck: Double
+ var gaze: Double
+ var limbs: [FigureLimb: [Double]]
+ var pins: [String: CGPoint]
+ var hold: Double
+ var tween: Double
+}
+
+/// Trunk FK: pelvis, spine mid, neck joint, and the offset left-limb attachments.
+struct FigureAttachments {
+ let pelvis: CGPoint
+ let mid: CGPoint
+ let neck: CGPoint
+ let shoulderL: CGPoint
+ let hipL: CGPoint
+
+ func point(for limb: FigureLimb) -> CGPoint {
+ switch limb {
+ case .armR: neck
+ case .armL: shoulderL
+ case .legR: pelvis
+ case .legL: hipL
+ }
+ }
+}
+
+/// A pose resolved to drawable points.
+struct FigureGeometry {
+ var headCenter: CGPoint
+ var headRadius: Double
+ var noseStart: CGPoint
+ var noseEnd: CGPoint
+ /// Quadratic Bézier through pelvis → mid → neck (control = 2·mid − (pelvis+neck)/2).
+ var spineStart: CGPoint
+ var spineControl: CGPoint
+ var spineEnd: CGPoint
+ /// Attachment → elbow/knee → extremity, per drawn limb.
+ var limbs: [FigureLimb: [CGPoint]]
+}
+
+enum MotionSolver {
+ /// Unit vector for a y-up angle, in y-down canvas coordinates.
+ static func direction(_ degrees: Double) -> CGVector {
+ let r = degrees * .pi / 180
+ return CGVector(dx: cos(r), dy: -sin(r))
+ }
+
+ /// Chain FK: `[start, joint1, joint2, …]` for per-bone absolute angles.
+ static func walk(from start: CGPoint, angles: [Double], lengths: [Double]) -> [CGPoint] {
+ var points = [start]
+ var current = start
+ for (angle, length) in zip(angles, lengths) {
+ let d = direction(angle)
+ current = CGPoint(x: current.x + d.dx * length, y: current.y + d.dy * length)
+ points.append(current)
+ }
+ return points
+ }
+
+ /// Y-up world angle (degrees) of the segment a→b.
+ static func angle(from a: CGPoint, to b: CGPoint) -> Double {
+ atan2(-(b.y - a.y), b.x - a.x) * 180 / .pi
+ }
+
+ /// Analytic 2-bone IK: `[upper, lower]` angles reaching from `start` toward
+ /// `target`, choosing the elbow/knee solution nearest the authored guess.
+ static func ik2(start: CGPoint, target: CGPoint, upper a: Double, lower b: Double, guess: [Double]) -> [Double] {
+ let dx = target.x - start.x
+ let dyUp = -(target.y - start.y)
+ // Clamp the reach inside the chain's annulus so acos stays defined.
+ let d = max(abs(a - b) + 0.5, min(a + b - 0.01, hypot(dx, dyUp)))
+ let base = atan2(dyUp, dx) * 180 / .pi
+ let alpha = acos((a * a + d * d - b * b) / (2 * a * d)) * 180 / .pi
+ let guessElbow = walk(from: start, angles: guess, lengths: [a, b])[1]
+ var best: (distance: Double, angles: [Double])?
+ for sign in [1.0, -1.0] {
+ let upperAngle = base + sign * alpha
+ let elbow = walk(from: start, angles: [upperAngle], lengths: [a])[1]
+ let distance = Double(hypot(elbow.x - guessElbow.x, elbow.y - guessElbow.y))
+ if best == nil || distance < best!.distance {
+ best = (distance, [upperAngle, angle(from: elbow, to: target)])
+ }
+ }
+ return best!.angles
+ }
+
+ static func attachments(root: CGPoint, spine: [Double], body: ExerciseBodyProfile) -> FigureAttachments {
+ let mid = walk(from: root, angles: [spine[0]], lengths: [body.spine1])[1]
+ let neck = walk(from: mid, angles: [spine[1]], lengths: [body.spine2])[1]
+ let ox = body.leftOffset.count > 0 ? body.leftOffset[0] : 6
+ let oy = body.leftOffset.count > 1 ? body.leftOffset[1] : 2
+ return FigureAttachments(
+ pelvis: root, mid: mid, neck: neck,
+ shoulderL: CGPoint(x: neck.x + ox, y: neck.y + oy),
+ hipL: CGPoint(x: root.x + ox, y: root.y + oy)
+ )
+ }
+
+ /// Resolve a key frame to pure angles: replace each pinned limb's authored angles
+ /// with its IK solution (the authored angles only pick the bend direction).
+ static func normalize(_ kf: MotionKeyFrame, body: ExerciseBodyProfile) -> FigurePose {
+ let root = CGPoint(x: kf.root[0], y: kf.root[1])
+ var pins: [String: CGPoint] = [:]
+ for (key, xy) in kf.pins ?? [:] where xy.count == 2 {
+ pins[key] = CGPoint(x: xy[0], y: xy[1])
+ }
+ let at = attachments(root: root, spine: kf.spine, body: body)
+ var limbs: [FigureLimb: [Double]] = [:]
+ for limb in FigureLimb.allCases {
+ guard let authored = limb.angles(in: kf) else { continue }
+ if let pin = pins[limb.pinKey] {
+ let lengths = limb.boneLengths(body)
+ limbs[limb] = ik2(start: at.point(for: limb), target: pin,
+ upper: lengths[0], lower: lengths[1], guess: authored)
+ } else {
+ limbs[limb] = authored
+ }
+ }
+ return FigurePose(root: root, spine: kf.spine, neck: kf.neck, gaze: kf.gaze,
+ limbs: limbs, pins: pins,
+ hold: kf.hold ?? 0.5, tween: kf.tween ?? 0.6)
+ }
+
+ /// Ease-in-out: 3t² − 2t³.
+ static func ease(_ t: Double) -> Double {
+ 3 * t * t - 2 * t * t * t
+ }
+
+ /// Shortest-path angular interpolation, so limbs swing in natural arcs.
+ static func lerpAngle(_ a: Double, _ b: Double, _ t: Double) -> Double {
+ var delta = (b - a + 180).truncatingRemainder(dividingBy: 360)
+ if delta < 0 { delta += 360 }
+ return a + (delta - 180) * t
+ }
+
+ /// Interpolate two normalized poses. A pin survives the tween only if planted in
+ /// BOTH neighboring key frames (so planted limbs hold exactly and releasing pins
+ /// release naturally); a limb absent from either side is dropped for the tween.
+ static func lerp(_ a: FigurePose, _ b: FigurePose, _ t: Double) -> FigurePose {
+ var limbs: [FigureLimb: [Double]] = [:]
+ for (limb, va) in a.limbs {
+ guard let vb = b.limbs[limb] else { continue }
+ limbs[limb] = zip(va, vb).map { lerpAngle($0, $1, t) }
+ }
+ var pins: [String: CGPoint] = [:]
+ for (key, pa) in a.pins {
+ guard let pb = b.pins[key] else { continue }
+ pins[key] = CGPoint(x: pa.x + (pb.x - pa.x) * t, y: pa.y + (pb.y - pa.y) * t)
+ }
+ return FigurePose(
+ root: CGPoint(x: a.root.x + (b.root.x - a.root.x) * t,
+ y: a.root.y + (b.root.y - a.root.y) * t),
+ spine: zip(a.spine, b.spine).map { lerpAngle($0, $1, t) },
+ neck: lerpAngle(a.neck, b.neck, t),
+ gaze: lerpAngle(a.gaze, b.gaze, t),
+ limbs: limbs, pins: pins, hold: a.hold, tween: a.tween
+ )
+ }
+
+ /// Normalized pose → drawable points. Limbs with an active pin are re-solved so
+ /// planted hands/feet hold that point exactly through tweens.
+ static func geometry(of pose: FigurePose, body: ExerciseBodyProfile, hide: Set) -> FigureGeometry {
+ let at = attachments(root: pose.root, spine: pose.spine, body: body)
+ let head = walk(from: at.neck, angles: [pose.neck], lengths: [body.neck])[1]
+ let noseTip = walk(from: head, angles: [pose.gaze], lengths: [body.headR + 7])[1]
+ // Nose tick: 7pt outward from the head rim along the gaze direction.
+ let d = max(hypot(noseTip.x - head.x, noseTip.y - head.y), 1)
+ let ux = (noseTip.x - head.x) / d
+ let uy = (noseTip.y - head.y) / d
+ let r = body.headR
+
+ var limbs: [FigureLimb: [CGPoint]] = [:]
+ for (limb, authored) in pose.limbs where !hide.contains(limb.rawValue) {
+ let lengths = limb.boneLengths(body)
+ var angles = authored
+ if let pin = pose.pins[limb.pinKey] {
+ angles = ik2(start: at.point(for: limb), target: pin,
+ upper: lengths[0], lower: lengths[1], guess: angles)
+ }
+ limbs[limb] = walk(from: at.point(for: limb), angles: angles, lengths: lengths)
+ }
+
+ return FigureGeometry(
+ headCenter: head,
+ headRadius: r,
+ noseStart: CGPoint(x: head.x + ux * r, y: head.y + uy * r),
+ noseEnd: CGPoint(x: head.x + ux * (r + 7), y: head.y + uy * (r + 7)),
+ spineStart: at.pelvis,
+ spineControl: CGPoint(x: 2 * at.mid.x - (at.pelvis.x + at.neck.x) / 2,
+ y: 2 * at.mid.y - (at.pelvis.y + at.neck.y) / 2),
+ spineEnd: at.neck,
+ limbs: limbs
+ )
+ }
+}
+
+/// The full looping animation for one motion: normalized key poses plus
+/// continuous-time sampling — hold at each key frame, then an eased angle-space tween
+/// to the next; the last frame tweens back to the first.
+struct MotionTimeline {
+ let poses: [FigurePose]
+ let duration: Double
+
+ init?(motion: ExerciseMotion, body: ExerciseBodyProfile) {
+ let poses = motion.frames.map { MotionSolver.normalize($0, body: body) }
+ let duration = poses.reduce(0) { $0 + $1.hold + $1.tween }
+ guard !poses.isEmpty, duration > 0 else { return nil }
+ self.poses = poses
+ self.duration = duration
+ }
+
+ /// The pose at wall-clock `time`, looping every `duration` seconds.
+ func pose(at time: Double) -> FigurePose {
+ var t = time.truncatingRemainder(dividingBy: duration)
+ if t < 0 { t += duration }
+ for (i, pose) in poses.enumerated() {
+ if t < pose.hold { return pose }
+ t -= pose.hold
+ if t < pose.tween {
+ let next = poses[(i + 1) % poses.count]
+ return MotionSolver.lerp(pose, next, MotionSolver.ease(t / pose.tween))
+ }
+ t -= pose.tween
+ }
+ return poses[0]
+ }
+}
diff --git a/Workouts/Resources/ExerciseMotions/Bird Dog.motion.json b/Workouts/Resources/ExerciseMotions/Bird Dog.motion.json
new file mode 100644
index 0000000..4c04ef0
--- /dev/null
+++ b/Workouts/Resources/ExerciseMotions/Bird Dog.motion.json
@@ -0,0 +1,23 @@
+{
+ "name": "Bird Dog",
+ "primary": 2,
+ "working": ["arm_r", "leg_l"],
+ "frames": [
+ {
+ "hold": 0.5, "tween": 0.8,
+ "root": [190, 106],
+ "spine": [171, 171], "neck": 187, "gaze": 205,
+ "arm_r": [-90, -90], "arm_l": [-90, -90],
+ "leg_r": [-83, 0], "leg_l": [-83, 0],
+ "pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
+ },
+ {
+ "hold": 1.4, "tween": 0.8,
+ "root": [190, 106],
+ "spine": [171, 171], "neck": 187, "gaze": 195,
+ "arm_r": [161, 161], "arm_l": [-90, -90],
+ "leg_r": [-83, 0], "leg_l": [15, 15],
+ "pins": {"hand_l": [111, 154]}
+ }
+ ]
+}
diff --git a/Workouts/Resources/ExerciseMotions/Cat-Cow.motion.json b/Workouts/Resources/ExerciseMotions/Cat-Cow.motion.json
new file mode 100644
index 0000000..699e99d
--- /dev/null
+++ b/Workouts/Resources/ExerciseMotions/Cat-Cow.motion.json
@@ -0,0 +1,23 @@
+{
+ "name": "Cat-Cow",
+ "primary": 1,
+ "working": ["spine"],
+ "frames": [
+ {
+ "hold": 0.8, "tween": 0.9,
+ "root": [190, 106],
+ "spine": [188, 155], "neck": 185, "gaze": 164,
+ "arm_r": [-90, -90], "arm_l": [-90, -90],
+ "leg_r": [-83, 0], "leg_l": [-83, 0],
+ "pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
+ },
+ {
+ "hold": 0.8, "tween": 0.9,
+ "root": [190, 106],
+ "spine": [155, 192], "neck": 215, "gaze": 225,
+ "arm_r": [-90, -90], "arm_l": [-90, -90],
+ "leg_r": [-83, 0], "leg_l": [-83, 0],
+ "pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
+ }
+ ]
+}
diff --git a/Workouts/Resources/ExerciseMotions/Dead Bug.motion.json b/Workouts/Resources/ExerciseMotions/Dead Bug.motion.json
new file mode 100644
index 0000000..974d769
--- /dev/null
+++ b/Workouts/Resources/ExerciseMotions/Dead Bug.motion.json
@@ -0,0 +1,21 @@
+{
+ "name": "Dead Bug",
+ "primary": 2,
+ "working": ["arm_r", "leg_l"],
+ "frames": [
+ {
+ "hold": 0.5, "tween": 0.8,
+ "root": [185, 148],
+ "spine": [180, 180], "neck": 168, "gaze": 90,
+ "arm_r": [90, 90], "arm_l": [90, 90],
+ "leg_r": [64, -10], "leg_l": [64, -10]
+ },
+ {
+ "hold": 0.9, "tween": 0.8,
+ "root": [185, 148],
+ "spine": [180, 180], "neck": 168, "gaze": 90,
+ "arm_r": [135, 135], "arm_l": [90, 90],
+ "leg_r": [64, -10], "leg_l": [16, 16]
+ }
+ ]
+}
diff --git a/Workouts/Resources/ExerciseMotions/Hollow Body Hold.motion.json b/Workouts/Resources/ExerciseMotions/Hollow Body Hold.motion.json
new file mode 100644
index 0000000..8ed3760
--- /dev/null
+++ b/Workouts/Resources/ExerciseMotions/Hollow Body Hold.motion.json
@@ -0,0 +1,22 @@
+{
+ "name": "Hollow Body Hold",
+ "primary": 2,
+ "working": ["spine"],
+ "hide": ["arm_l"],
+ "frames": [
+ {
+ "hold": 0.4, "tween": 0.8,
+ "root": [185, 148],
+ "spine": [180, 180], "neck": 168, "gaze": 90,
+ "arm_r": [180, 180],
+ "leg_r": [0, 0], "leg_l": [0, 0]
+ },
+ {
+ "hold": 1.8, "tween": 0.8,
+ "root": [185, 148],
+ "spine": [174, 160], "neck": 140, "gaze": 50,
+ "arm_r": [155, 155],
+ "leg_r": [12, 12], "leg_l": [12, 12]
+ }
+ ]
+}
diff --git a/Workouts/Resources/ExerciseMotions/Leg Raises.motion.json b/Workouts/Resources/ExerciseMotions/Leg Raises.motion.json
new file mode 100644
index 0000000..3a26817
--- /dev/null
+++ b/Workouts/Resources/ExerciseMotions/Leg Raises.motion.json
@@ -0,0 +1,22 @@
+{
+ "name": "Leg Raises",
+ "primary": 1,
+ "working": ["leg_r", "leg_l"],
+ "hide": ["arm_r"],
+ "frames": [
+ {
+ "hold": 0.5, "tween": 1.4,
+ "root": [185, 148],
+ "spine": [180, 180], "neck": 168, "gaze": 90,
+ "arm_l": [-2, -2],
+ "leg_r": [60, 60], "leg_l": [60, 60]
+ },
+ {
+ "hold": 0.3, "tween": 0.6,
+ "root": [185, 148],
+ "spine": [180, 180], "neck": 168, "gaze": 90,
+ "arm_l": [-2, -2],
+ "leg_r": [8, 8], "leg_l": [8, 8]
+ }
+ ]
+}
diff --git a/Workouts/Resources/ExerciseMotions/Plank.motion.json b/Workouts/Resources/ExerciseMotions/Plank.motion.json
new file mode 100644
index 0000000..8810dec
--- /dev/null
+++ b/Workouts/Resources/ExerciseMotions/Plank.motion.json
@@ -0,0 +1,21 @@
+{
+ "name": "Plank",
+ "primary": 2,
+ "working": ["spine"],
+ "frames": [
+ {
+ "hold": 0.4, "tween": 0.7,
+ "root": [172, 114],
+ "spine": [185, 185], "neck": 185, "gaze": 210,
+ "arm_r": [-90, 180], "arm_l": [-90, 180],
+ "leg_r": [-55, 0], "leg_l": [-55, 0]
+ },
+ {
+ "hold": 1.8, "tween": 0.7,
+ "root": [168, 137],
+ "spine": [170, 170], "neck": 170, "gaze": 210,
+ "arm_r": [-90, 180], "arm_l": [-90, 180],
+ "leg_r": [-10, -10], "leg_l": [-10, -10]
+ }
+ ]
+}
diff --git a/Workouts/Resources/ExerciseMotions/Reverse Crunch.motion.json b/Workouts/Resources/ExerciseMotions/Reverse Crunch.motion.json
new file mode 100644
index 0000000..7be8997
--- /dev/null
+++ b/Workouts/Resources/ExerciseMotions/Reverse Crunch.motion.json
@@ -0,0 +1,22 @@
+{
+ "name": "Reverse Crunch",
+ "primary": 2,
+ "working": ["spine"],
+ "hide": ["arm_r"],
+ "frames": [
+ {
+ "hold": 0.5, "tween": 0.7,
+ "root": [172, 148],
+ "spine": [180, 180], "neck": 168, "gaze": 90,
+ "arm_l": [-2, -2],
+ "leg_r": [100, -20], "leg_l": [100, -20]
+ },
+ {
+ "hold": 0.8, "tween": 0.9,
+ "root": [166, 136],
+ "spine": [195, 175], "neck": 182, "gaze": 90,
+ "arm_l": [-2, -2],
+ "leg_r": [115, -10], "leg_l": [115, -10]
+ }
+ ]
+}
diff --git a/Workouts/Resources/ExerciseMotions/Side Plank.motion.json b/Workouts/Resources/ExerciseMotions/Side Plank.motion.json
new file mode 100644
index 0000000..6a2209a
--- /dev/null
+++ b/Workouts/Resources/ExerciseMotions/Side Plank.motion.json
@@ -0,0 +1,23 @@
+{
+ "name": "Side Plank",
+ "primary": 2,
+ "working": ["spine"],
+ "frames": [
+ {
+ "hold": 0.4, "tween": 0.8,
+ "root": [174, 150],
+ "spine": [162, 162], "neck": 162, "gaze": 175,
+ "arm_r": [-90, 180], "arm_l": [-20, -15],
+ "leg_r": [-1, -1], "leg_l": [-1, -1],
+ "pins": {"hand_r": [60, 152]}
+ },
+ {
+ "hold": 1.8, "tween": 0.8,
+ "root": [174, 137],
+ "spine": [170, 170], "neck": 170, "gaze": 180,
+ "arm_r": [-90, 180], "arm_l": [80, 80],
+ "leg_r": [-10, -10], "leg_l": [-10, -10],
+ "pins": {"hand_r": [60, 152]}
+ }
+ ]
+}
diff --git a/Workouts/Resources/ExerciseMotions/body.json b/Workouts/Resources/ExerciseMotions/body.json
new file mode 100644
index 0000000..e19cb7f
--- /dev/null
+++ b/Workouts/Resources/ExerciseMotions/body.json
@@ -0,0 +1,24 @@
+{
+ "neutral": {
+ "headR": 10,
+ "neck": 20,
+ "spine1": 43,
+ "spine2": 42,
+ "upperArm": 30,
+ "foreArm": 30,
+ "thigh": 46,
+ "shin": 40,
+ "leftOffset": [6, 2]
+ },
+ "female": {
+ "headR": 9,
+ "neck": 19,
+ "spine1": 40,
+ "spine2": 39,
+ "upperArm": 27,
+ "foreArm": 27,
+ "thigh": 47,
+ "shin": 41,
+ "leftOffset": [6, 2]
+ }
+}
diff --git a/Workouts/Screenshots/ScreenshotRootView.swift b/Workouts/Screenshots/ScreenshotRootView.swift
index 91510aa..341ae86 100644
--- a/Workouts/Screenshots/ScreenshotRootView.swift
+++ b/Workouts/Screenshots/ScreenshotRootView.swift
@@ -20,6 +20,7 @@ struct ScreenshotRootView: View {
content
.environment(services)
.environment(services.syncEngine)
+ .environment(services.liveRunState)
.modelContainer(services.container)
}
diff --git a/Workouts/Views/Exercises/ExerciseAddEditView.swift b/Workouts/Views/Exercises/ExerciseAddEditView.swift
index 8e50a39..54c015c 100644
--- a/Workouts/Views/Exercises/ExerciseAddEditView.swift
+++ b/Workouts/Views/Exercises/ExerciseAddEditView.swift
@@ -33,6 +33,7 @@ struct ExerciseAddEditView: View {
@State private var sets: Int
@State private var weightReminderWeeks: Int
@State private var weightLastUpdated: Date?
+ @State private var category: ExerciseCategory
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
init(exercise: Exercise, split: Split) {
@@ -51,6 +52,7 @@ struct ExerciseAddEditView: View {
_sets = State(initialValue: exercise.sets)
_weightReminderWeeks = State(initialValue: exercise.weightReminderTimeIntervalWeeks)
_weightLastUpdated = State(initialValue: exercise.weightLastUpdated)
+ _category = State(initialValue: exercise.categoryEnum)
}
var body: some View {
@@ -98,6 +100,19 @@ struct ExerciseAddEditView: View {
}
}
+ Section(
+ header: Text("Category"),
+ footer: Text("The split screen groups its exercises by category, with the warm-up listed first.")
+ ) {
+ Picker("", selection: $category) {
+ ForEach(ExerciseCategory.displayOrder, id: \.self) { cat in
+ Text(cat.displayName)
+ .tag(cat)
+ }
+ }
+ .pickerStyle(.segmented)
+ }
+
Section(
header: Text("Load Type"),
footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")
@@ -204,6 +219,7 @@ struct ExerciseAddEditView: View {
doc.exercises[idx].durationSeconds = durationSecs
doc.exercises[idx].weightLastUpdated = updatedWeightDate
doc.exercises[idx].weightReminderWeeks = weightReminderWeeks
+ doc.exercises[idx].category = category.rawValue
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift
index 48586f4..9e9736e 100644
--- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift
+++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift
@@ -22,8 +22,9 @@ import UIKit
/// reaches a **Finish** page offering **One More** (append a bonus set) and **Done**
/// (which also auto-fires after a configurable countdown, completing the exercise).
///
-/// The paged flow occupies the **top half** of the screen; the bottom half is reserved
-/// blank for a later iteration. A row of phase dots tracks progress: purple for work,
+/// The paged flow occupies the **top half** of the screen; the bottom half shows the
+/// animated form-guide figure when the exercise has a bundled motion
+/// (`ExerciseFigureSlot`). A row of phase dots tracks progress: purple for work,
/// teal for rest, with the current phase drawn as a wider dash.
struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss
@@ -85,6 +86,11 @@ struct ExerciseProgressView: View {
/// the transient TabView snap-to-0, so it isn't reset on open.
@State private var startsResumed: Bool
+ /// True when the exercise was already completed when this screen opened — it shows a
+ /// static Completed page instead of dropping back into the timer flow. Fixed at init
+ /// so completing the exercise from inside the flow doesn't swap the page mid-dismiss.
+ @State private var startsCompleted: Bool
+
init(doc: Binding, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
self._doc = doc
self.logID = logID
@@ -102,6 +108,7 @@ struct ExerciseProgressView: View {
// the TabView's snap-to-0 on first layout.
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
_startsResumed = State(initialValue: !notStarted)
+ _startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue)
let base = 1
// Resume on the first unfinished set's work page (clamped to the last set).
@@ -181,6 +188,16 @@ struct ExerciseProgressView: View {
}
var body: some View {
+ if startsCompleted {
+ CompletedPhaseView()
+ .navigationTitle(log?.exerciseName ?? "")
+ .navigationBarTitleDisplayMode(.inline)
+ } else {
+ flowBody
+ }
+ }
+
+ private var flowBody: some View {
VStack(spacing: 0) {
// Paged flow — top half.
TabView(selection: $currentPage) {
@@ -198,9 +215,9 @@ struct ExerciseProgressView: View {
}
}
- // Reserved space for a later iteration (set log, history, chart, …).
- Color.clear
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ // Bottom half: the looping form-guide figure when a bundled motion
+ // matches this exercise; empty space otherwise.
+ ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
}
.navigationTitle(log?.exerciseName ?? "")
.navigationBarTitleDisplayMode(.inline)
@@ -677,6 +694,22 @@ private extension View {
}
}
+// MARK: - Completed Phase
+
+/// Shown instead of the run flow when the exercise was already completed on open.
+private struct CompletedPhaseView: View {
+ var body: some View {
+ VStack(spacing: 14) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 96))
+ .foregroundStyle(.green)
+ Text("Completed")
+ .font(.system(.title, design: .rounded, weight: .heavy))
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
// MARK: - Ready Phase
private struct ReadyPhaseView: View {
diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift
index 6d197a8..5f314fe 100644
--- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift
+++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift
@@ -387,7 +387,7 @@ struct SplitExercisePickerSheet: View {
VStack(alignment: .leading) {
Text(exercise.name)
.foregroundColor(.primary)
- Text("\(exercise.sets) × \(exercise.reps) × \(weightUnit.format(exercise.weight))")
+ Text(exercise.planSummary(weightUnit: weightUnit))
.font(.caption)
.foregroundColor(.secondary)
}
diff --git a/WorkoutsTests/ExerciseMotionTests.swift b/WorkoutsTests/ExerciseMotionTests.swift
new file mode 100644
index 0000000..780d5fb
--- /dev/null
+++ b/WorkoutsTests/ExerciseMotionTests.swift
@@ -0,0 +1,86 @@
+import Foundation
+import Testing
+@testable import Workouts
+
+/// Locks the Swift motion solver to the Exercise Library's reference implementation
+/// (`Exercise Library/render.py`): the bundled rig resources must decode, and the
+/// FK / IK / tween math must reproduce values computed by the Python for the same
+/// data — the two renderers are meant to stay in lockstep.
+struct ExerciseMotionTests {
+
+ @Test func bundledBirdDogResourcesDecode() throws {
+ let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog"))
+ #expect(resources.motion.name == "Bird Dog")
+ #expect(resources.motion.frames.count == 2)
+ #expect(resources.motion.working == ["arm_r", "leg_l"])
+ #expect(resources.body.upperArm == 30)
+ #expect(MotionTimeline(motion: resources.motion, body: resources.body) != nil)
+ }
+
+ @Test func exerciseWithoutBundledMotionLoadsNothing() {
+ #expect(ExerciseMotionLibrary.resources(for: "Bench Press") == nil)
+ #expect(FigureAnimation(exerciseName: "Bench Press") == nil)
+ }
+
+ /// Shortest-path angular interpolation (reference: `lerp_angle`).
+ @Test func angleLerpTakesShortestPath() {
+ #expect(MotionSolver.lerpAngle(170, -170, 0.5) == 180)
+ #expect(MotionSolver.lerpAngle(-170, 170, 0.25) == -175)
+ #expect(MotionSolver.lerpAngle(0, 90, 0.5) == 45)
+ }
+
+ /// Normalizing Bird Dog's first key frame must reproduce the reference IK
+ /// solution for the pinned support arm, and the pinned hand must land exactly
+ /// on its target.
+ @Test func normalizeMatchesReferenceIK() throws {
+ let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog"))
+ let pose = MotionSolver.normalize(resources.motion.frames[0], body: resources.body)
+
+ let armR = try #require(pose.limbs[.armR])
+ #expect(abs(armR[0] - (-82.28919)) < 1e-4)
+ #expect(abs(armR[1] - (-99.732948)) < 1e-4)
+
+ let geo = MotionSolver.geometry(of: pose, body: resources.body, hide: [])
+ let hand = try #require(geo.limbs[.armR]?.last)
+ #expect(abs(hand.x - 105) < 1e-6)
+ #expect(abs(hand.y - 152) < 1e-6)
+
+ #expect(abs(geo.headCenter.x - 86.195568) < 1e-4)
+ #expect(abs(geo.headCenter.y - 95.140457) < 1e-4)
+ }
+
+ /// Mid-tween (t = ease(0.5)): the hand pinned in BOTH key frames stays planted
+ /// exactly; the pin present only in frame 1 releases; the lifting arm's angles
+ /// match the reference interpolation.
+ @Test func tweenKeepsSharedPinsAndReleasesOthers() throws {
+ let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog"))
+ let a = MotionSolver.normalize(resources.motion.frames[0], body: resources.body)
+ let b = MotionSolver.normalize(resources.motion.frames[1], body: resources.body)
+ let mid = MotionSolver.lerp(a, b, MotionSolver.ease(0.5))
+
+ #expect(mid.pins["hand_l"] != nil)
+ #expect(mid.pins["hand_r"] == nil)
+
+ let geo = MotionSolver.geometry(of: mid, body: resources.body, hide: [])
+ let plantedHand = try #require(geo.limbs[.armL]?.last)
+ #expect(abs(plantedHand.x - 111) < 1e-6)
+ #expect(abs(plantedHand.y - 154) < 1e-6)
+
+ let liftingArm = try #require(mid.limbs[.armR])
+ #expect(abs(liftingArm[0] - (-140.644595)) < 1e-4)
+ #expect(abs(liftingArm[1] - (-149.366474)) < 1e-4)
+ }
+
+ /// Every exported motion in the bundle decodes and builds a playable timeline.
+ @Test func allBundledMotionsBuildTimelines() throws {
+ let urls = Bundle.main.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? []
+ let motionURLs = urls.filter { $0.lastPathComponent.hasSuffix(".motion.json") }
+ #expect(!motionURLs.isEmpty)
+ for url in motionURLs {
+ let name = url.lastPathComponent.replacingOccurrences(of: ".motion.json", with: "")
+ let resources = try #require(ExerciseMotionLibrary.resources(for: name))
+ let timeline = try #require(MotionTimeline(motion: resources.motion, body: resources.body))
+ #expect(timeline.duration > 0)
+ }
+ }
+}
diff --git a/WorkoutsTests/SplitDocumentCodableTests.swift b/WorkoutsTests/SplitDocumentCodableTests.swift
index 4a5846e..7761934 100644
--- a/WorkoutsTests/SplitDocumentCodableTests.swift
+++ b/WorkoutsTests/SplitDocumentCodableTests.swift
@@ -21,7 +21,8 @@ struct SplitDocumentCodableTests {
loadType: 0,
durationSeconds: 0,
weightLastUpdated: Self.stableTimestamp,
- weightReminderWeeks: 4
+ weightReminderWeeks: 4,
+ category: ExerciseCategory.warmup.rawValue
)
let original = SplitDocument(
schemaVersion: SplitDocument.currentSchemaVersion,
@@ -42,4 +43,34 @@ struct SplitDocumentCodableTests {
#expect(decoded == original)
#expect(decoded.relativePath == "Splits/\(original.id).json")
}
+
+ /// A pre-category exercise (no `category` key) must still decode — the field is
+ /// optional and deliberately not schema-bumped, defaulting to the main circuit.
+ @Test func decodesLegacyExerciseWithoutCategory() throws {
+ let json = """
+ {
+ "schemaVersion": 1,
+ "id": "01HZZZZZZZZZZZZZZZZZZZZZZZ",
+ "name": "Push Day",
+ "color": "blue",
+ "systemImage": "dumbbell.fill",
+ "order": 0,
+ "createdAt": "2023-11-14T22:13:20Z",
+ "updatedAt": "2023-11-14T22:13:20Z",
+ "exercises": [{
+ "id": "01HZZZZZZZZZZZZZZZZZZZZZZY",
+ "name": "Bench Press",
+ "order": 0,
+ "sets": 4,
+ "reps": 10,
+ "weight": 135,
+ "loadType": 1,
+ "durationSeconds": 0,
+ "weightReminderWeeks": 4
+ }]
+ }
+ """
+ let decoded = try DocumentCoder.decode(SplitDocument.self, from: Data(json.utf8))
+ #expect(decoded.exercises.first?.category == nil)
+ }
}