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 @@ + + Bird Dog + + + + + + + + + + + R + + L + + 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 @@ + + Bird Dog + + + + + + + + + + + R + + L + + 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 @@ + + Bird Dog + + + + + + + + + + + R + + L + + 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 @@ + + Cat-Cow + + + + + + + + + + + R + + L + + 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 @@ + + Cat-Cow + + + + + + + + + + + R + + L + + 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 @@ + + Cat-Cow + + + + + + + + + + + R + + L + + 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 @@ + + Dead Bug + + + + + + + + + + + R + + L + + 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 @@ + + Dead Bug + + + + + + + + + + + R + + L + + 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 @@ + + Dead Bug + + + + + + + + + + + R + + L + + 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 @@ + + Hollow Body Hold + + + + + + + + + + R + + L + + 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 @@ + + Hollow Body Hold + + + + + + + + + + R + + L + + 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 @@ + + Hollow Body Hold + + + + + + + + + + R + + L + + 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 @@ + + Leg Raises + + + + + + + + + + R + + L + + 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 @@ + + Leg Raises + + + + + + + + + + R + + L + + 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 @@ + + Leg Raises + + + + + + + + + + R + + L + + 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 @@ + + Plank + + + + + + + + + + + R + + L + + 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 @@ + + Plank + + + + + + + + + + + R + + L + + 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 @@ + + Plank + + + + + + + + + + + R + + L + + 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 @@ + + Reverse Crunch + + + + + + + + + + R + + L + + 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 @@ + + Reverse Crunch + + + + + + + + + + R + + L + + 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 @@ + + Reverse Crunch + + + + + + + + + + R + + L + + 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 @@ + + Side Plank + + + + + + + + + + + R + + L + + 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 @@ + + Side Plank + + + + + + + + + + + R + + L + + 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 @@ + + Side Plank + + + + + + + + + + + R + + L + + 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'', + f' {name}', + f' '] + for part in PART_ORDER: + if part == "head": + hx, hy = geo["head"] + (sx, sy), (ex, ey) = nose_segment(geo) + parts.append(f' ') + parts.append(f' ') + continue + if part not in geo: + continue + color, width = part_style(part, working, colors) + if part == "spine": + (ax, ay), (cx, cy), (bx, by) = geo["spine"] + d = f"M {ax:.1f} {ay:.1f} Q {cx:.1f} {cy:.1f} {bx:.1f} {by:.1f}" + else: + d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in geo[part]) + parts.append(f' ') + lx = w - 78 + parts.append(f' ') + parts.append(f' ') + parts.append(f' R') + parts.append(f' ') + parts.append(f' L') + parts.append(' ') + parts.append('') + 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) + } }