Add the exercise reference library, animated exercise figures, and exercise categories
Exercise Library/ holds per-exercise reference docs (setup, cues, mistakes, progressions) with SVG visuals and a Python-rendered motion pipeline; Workouts/ExerciseFigure renders the bundled *.motion.json rigs as animated stick figures on the exercise screen. Exercises gain a warm-up/main-circuit category, timed exercises display hold time via planSummary, and a completed exercise reopens to a check screen instead of its timers.
@@ -19,3 +19,7 @@ Package.resolved
|
||||
|
||||
## Misc
|
||||
*.log
|
||||
|
||||
## Python (Exercise Library render tooling)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Bird Dog</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 112.0 94.7 L 116.1 124.4 L 111.0 154.0" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 196.0 108.0 L 201.6 153.7 L 241.6 153.7" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 190.0 106.0 Q 147.0 99.2 106.0 92.7" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 106.0 92.7 L 110.1 122.4 L 105.0 152.0" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 190.0 106.0 L 195.6 151.7 L 235.6 151.7" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="86.2" cy="95.1" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="77.1" y1="99.4" x2="70.8" y2="102.3" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Bird Dog</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 112.0 94.7 L 116.1 124.4 L 111.0 154.0" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 196.0 108.0 L 240.4 96.1 L 279.1 85.7" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 190.0 106.0 Q 147.0 99.2 106.0 92.7" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 106.0 92.7 L 77.7 82.9 L 49.3 73.2" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 190.0 106.0 L 195.6 151.7 L 235.6 151.7" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="86.2" cy="95.1" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="76.5" y1="97.7" x2="69.8" y2="99.5" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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
|
||||
@@ -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]}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Bird Dog</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 112.0 94.7 L 116.1 124.4 L 111.0 154.0" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 196.0 108.0 L 240.4 96.1 L 279.1 85.7" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 190.0 106.0 Q 147.0 99.2 106.0 92.7" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 106.0 92.7 L 77.7 82.9 L 49.3 73.2" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 190.0 106.0 L 195.6 151.7 L 235.6 151.7" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="86.2" cy="95.1" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="76.5" y1="97.7" x2="69.8" y2="99.5" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Cat-Cow</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 115.4 96.2 L 121.0 125.7 L 111.0 154.0" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 196.0 108.0 L 201.6 153.7 L 241.6 153.7" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 190.0 106.0 Q 145.2 123.9 109.4 94.2" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 109.4 94.2 L 115.0 123.7 L 105.0 152.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 190.0 106.0 L 195.6 151.7 L 235.6 151.7" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="89.4" cy="96.0" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="79.8" y1="93.2" x2="73.1" y2="91.3" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Cat-Cow</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 115.9 98.6 L 124.6 127.3 L 111.0 154.0" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 196.0 108.0 L 201.6 153.7 L 241.6 153.7" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 190.0 106.0 Q 152.1 74.4 109.9 96.6" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 109.9 96.6 L 118.6 125.3 L 105.0 152.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 190.0 106.0 L 195.6 151.7 L 235.6 151.7" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="93.6" cy="108.0" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="86.5" y1="115.1" x2="81.5" y2="120.1" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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)
|
||||
@@ -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]}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Cat-Cow</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 115.4 96.2 L 121.0 125.7 L 111.0 154.0" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 196.0 108.0 L 201.6 153.7 L 241.6 153.7" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 190.0 106.0 Q 145.2 123.9 109.4 94.2" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 109.4 94.2 L 115.0 123.7 L 105.0 152.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 190.0 106.0 L 195.6 151.7 L 235.6 151.7" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="89.4" cy="96.0" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="79.8" y1="93.2" x2="73.1" y2="91.3" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Dead Bug</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 106.0 150.0 L 106.0 120.0 L 106.0 90.0" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 191.0 150.0 L 211.2 108.7 L 250.6 115.6" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 185.0 148.0 Q 141.5 148.0 100.0 148.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 100.0 148.0 L 100.0 118.0 L 100.0 88.0" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 185.0 148.0 L 205.2 106.7 L 244.6 113.6" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="80.4" cy="143.8" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="80.4" y1="133.8" x2="80.4" y2="126.8" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Dead Bug</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 106.0 150.0 L 106.0 120.0 L 106.0 90.0" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 191.0 150.0 L 235.2 137.3 L 273.7 126.3" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 185.0 148.0 Q 141.5 148.0 100.0 148.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 100.0 148.0 L 78.8 126.8 L 57.6 105.6" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 185.0 148.0 L 205.2 106.7 L 244.6 113.6" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="80.4" cy="143.8" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="80.4" y1="133.8" x2="80.4" y2="126.8" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Dead Bug</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 106.0 150.0 L 106.0 120.0 L 106.0 90.0" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 191.0 150.0 L 235.2 137.3 L 273.7 126.3" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 185.0 148.0 Q 141.5 148.0 100.0 148.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 100.0 148.0 L 78.8 126.8 L 57.6 105.6" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 185.0 148.0 L 205.2 106.7 L 244.6 113.6" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="80.4" cy="143.8" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="80.4" y1="133.8" x2="80.4" y2="126.8" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Hollow Body Hold</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="leg_l" d="M 191.0 150.0 L 237.0 150.0 L 277.0 150.0" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 185.0 148.0 Q 141.5 148.0 100.0 148.0" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 100.0 148.0 L 70.0 148.0 L 40.0 148.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 185.0 148.0 L 231.0 148.0 L 271.0 148.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="80.4" cy="143.8" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="80.4" y1="133.8" x2="80.4" y2="126.8" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Hollow Body Hold</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="leg_l" d="M 191.0 150.0 L 236.0 140.4 L 275.1 132.1" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 185.0 148.0 Q 140.6 148.4 102.8 129.1" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 102.8 129.1 L 75.6 116.5 L 48.4 103.8" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 185.0 148.0 L 230.0 138.4 L 269.1 130.1" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="87.4" cy="116.3" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="93.9" y1="108.6" x2="98.4" y2="103.3" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 108 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Hollow Body Hold</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="leg_l" d="M 191.0 150.0 L 236.0 140.4 L 275.1 132.1" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 185.0 148.0 Q 140.6 148.4 102.8 129.1" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 102.8 129.1 L 75.6 116.5 L 48.4 103.8" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 185.0 148.0 L 230.0 138.4 L 269.1 130.1" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="87.4" cy="116.3" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="93.9" y1="108.6" x2="98.4" y2="103.3" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Leg Raises</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 106.0 150.0 L 136.0 151.0 L 166.0 152.1" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 191.0 150.0 L 214.0 110.2 L 234.0 75.5" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 185.0 148.0 Q 141.5 148.0 100.0 148.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 185.0 148.0 L 208.0 108.2 L 228.0 73.5" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="80.4" cy="143.8" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="80.4" y1="133.8" x2="80.4" y2="126.8" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Leg Raises</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 106.0 150.0 L 136.0 151.0 L 166.0 152.1" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 191.0 150.0 L 236.6 143.6 L 276.2 138.0" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 185.0 148.0 Q 141.5 148.0 100.0 148.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 185.0 148.0 L 230.6 141.6 L 270.2 136.0" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="80.4" cy="143.8" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="80.4" y1="133.8" x2="80.4" y2="126.8" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Leg Raises</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 106.0 150.0 L 136.0 151.0 L 166.0 152.1" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 191.0 150.0 L 214.0 110.2 L 234.0 75.5" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 185.0 148.0 Q 141.5 148.0 100.0 148.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 185.0 148.0 L 208.0 108.2 L 228.0 73.5" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="80.4" cy="143.8" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="80.4" y1="133.8" x2="80.4" y2="126.8" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Plank</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 93.3 123.4 L 93.3 153.4 L 63.3 153.4" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 178.0 116.0 L 204.4 153.7 L 244.4 153.7" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 172.0 114.0 Q 128.7 117.8 87.3 121.4" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 87.3 121.4 L 87.3 151.4 L 57.3 151.4" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 172.0 114.0 L 198.4 151.7 L 238.4 151.7" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="67.4" cy="123.2" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="58.7" y1="128.2" x2="52.7" y2="131.7" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Plank</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 90.3 124.2 L 90.3 154.2 L 60.3 154.2" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 174.0 139.0 L 219.3 147.0 L 258.7 153.9" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 168.0 137.0 Q 125.2 129.4 84.3 122.2" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 84.3 122.2 L 84.3 152.2 L 54.3 152.2" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 168.0 137.0 L 213.3 145.0 L 252.7 151.9" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="64.6" cy="118.8" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="55.9" y1="123.8" x2="49.9" y2="127.3" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Plank</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 90.3 124.2 L 90.3 154.2 L 60.3 154.2" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 174.0 139.0 L 219.3 147.0 L 258.7 153.9" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 168.0 137.0 Q 125.2 129.4 84.3 122.2" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 84.3 122.2 L 84.3 152.2 L 54.3 152.2" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 168.0 137.0 L 213.3 145.0 L 252.7 151.9" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="64.6" cy="118.8" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="55.9" y1="123.8" x2="49.9" y2="127.3" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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.
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Reverse Crunch</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 93.0 150.0 L 123.0 151.0 L 153.0 152.1" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 178.0 150.0 L 170.0 104.7 L 207.6 118.4" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 172.0 148.0 Q 128.5 148.0 87.0 148.0" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 172.0 148.0 L 164.0 102.7 L 201.6 116.4" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="67.4" cy="143.8" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="67.4" y1="133.8" x2="67.4" y2="126.8" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Reverse Crunch</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 88.6 145.5 L 118.6 146.5 L 148.6 147.6" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 172.0 138.0 L 152.6 96.3 L 192.0 103.3" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 166.0 136.0 Q 124.6 154.5 82.6 143.5" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 166.0 136.0 L 146.6 94.3 L 186.0 101.3" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="62.6" cy="144.2" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="62.6" y1="134.2" x2="62.6" y2="127.2" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 108 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Reverse Crunch</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 88.6 145.5 L 118.6 146.5 L 148.6 147.6" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 172.0 138.0 L 152.6 96.3 L 192.0 103.3" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 166.0 136.0 Q 124.6 154.5 82.6 143.5" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 166.0 136.0 L 146.6 94.3 L 186.0 101.3" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="62.6" cy="144.2" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="62.6" y1="134.2" x2="62.6" y2="127.2" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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.
|
||||
- **`<Exercise>/motion.json`** — the exercise script: key frames of
|
||||
**absolute joint angles** (degrees, y-up: `0`=forward/right, `90`=up,
|
||||
`180`=back/left, `-90`=down), a `root` pelvis position, and timing.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Bird Dog",
|
||||
"primary": 2, // 1-based frame used for visual.svg
|
||||
"working": ["arm_r", "leg_l"], // parts drawn in the accent color
|
||||
"hide": [], // limbs fully occluded in this view
|
||||
"frames": [
|
||||
{
|
||||
"hold": 0.5, // seconds held at this key frame
|
||||
"tween": 0.8, // seconds animating to the NEXT frame
|
||||
"root": [190, 106], // pelvis, canvas coords
|
||||
"spine": [171, 171], // pelvis→mid, mid→neck angles
|
||||
"neck": 187, "gaze": 205, // head direction; nose tick direction
|
||||
"arm_r": [-90, -90], // upper-arm, forearm angles
|
||||
"arm_l": [-90, -90],
|
||||
"leg_r": [-83, 0], // thigh, shin angles
|
||||
"leg_l": [-83, 0],
|
||||
"pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **Pins (IK)** — a planted hand/foot names a target point
|
||||
(`hand_r`/`hand_l`/`foot_r`/`foot_l`); the renderer solves the two-bone
|
||||
chain analytically so the extremity holds that point exactly, using the
|
||||
authored angles only to pick the elbow/knee bend direction. A pin active in
|
||||
two consecutive key frames stays planted *throughout the tween* (plank
|
||||
forearms, side-plank support arm); a pin present in only one frame releases
|
||||
naturally (bird-dog arm lifting off).
|
||||
- **Tweening** happens in angle space (shortest path), so limbs swing in
|
||||
natural arcs and bone lengths never distort. The last frame tweens back to
|
||||
the first (looping). Asymmetric timing carries technique: leg raises lower
|
||||
slowly (`tween` 1.4 s down, 0.6 s up).
|
||||
- The same math is the plan for the app: a small SwiftUI renderer consumes
|
||||
`body.json` + `motion.json` and tweens angles on the lower half of the
|
||||
exercise screen (the paged timer flow occupies only the top half).
|
||||
|
||||
## The visual language
|
||||
|
||||
- **Right vs left limb** — the one rule that never bends: the figure's
|
||||
**right**-side limbs are dark (`#3a3f4b`), its **left**-side limbs are light
|
||||
(`#a9afba`) and drawn *behind* the body. Working limbs keep the split:
|
||||
right = teal `#0d9488`, left = light teal `#86cfc5`. Every render embeds a
|
||||
small `R —` / `L —` legend, so opposite-limb moves (bird dog, dead bug) are
|
||||
visibly opposite: one dark-teal limb, one light-teal limb.
|
||||
- **Facing / front-of-torso** — the head carries a **nose tick** (`gaze`
|
||||
angle); the belly is on that side. Prone noses point at the floor, supine
|
||||
at the ceiling. The head is drawn last, filled opaque, so overhead arms
|
||||
pass behind the face.
|
||||
- **Spine** — rendered as a smooth curve through pelvis → mid → neck; teal
|
||||
when the trunk is the working part.
|
||||
- Canvas 320×180, ground line at y = 152. Limbs listed in `hide` are fully
|
||||
occluded in this view and not drawn.
|
||||
|
||||
## Rendering
|
||||
|
||||
```sh
|
||||
cd "Exercise Library"
|
||||
python3 render.py # all exercises: frames/*.svg, preview.gif, visual.svg
|
||||
python3 render.py "Bird Dog" # one exercise
|
||||
python3 render.py --sheet # + contact-sheet.png of every key frame
|
||||
python3 render.py --demo # + demo-sheet.png: profile / flip / theme variants
|
||||
python3 render.py --figure=female # render with another body profile
|
||||
python3 render.py --flip # mirror the figure (faces the other way)
|
||||
python3 render.py --export # copy body.json + <Name>.motion.json app resources
|
||||
```
|
||||
|
||||
`render.py` needs only Pillow (for GIFs/sheets; the SVGs have no dependency).
|
||||
The library lives at the repo root, outside every target's source folders —
|
||||
same-named files per entry (`info.md`, `visual.svg`) would collide in Xcode's
|
||||
flat resource copy, so the library itself never enters the app bundle. Only
|
||||
the `--export` copies ship: `body.json` plus uniquely-named
|
||||
`<Name>.motion.json` files in `Workouts/Resources/ExerciseMotions/`, consumed
|
||||
by the in-app SwiftUI renderer (`Workouts/ExerciseFigure/`). Re-run
|
||||
`python3 render.py --export` after editing any motion; the library stays the
|
||||
source of truth.
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Side Plank</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 99.2 125.7 L 127.4 136.0 L 156.3 143.8" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 180.0 152.0 L 226.0 152.8 L 266.0 153.5" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 174.0 150.0 Q 132.6 136.6 93.2 123.7" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 93.2 123.7 L 90.0 153.6 L 60.0 152.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 174.0 150.0 L 220.0 150.8 L 260.0 151.5" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="74.1" cy="117.6" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="64.2" y1="116.7" x2="57.2" y2="116.1" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Side Plank</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 96.3 124.2 L 101.5 94.7 L 106.7 65.2" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 180.0 139.0 L 225.3 147.0 L 264.7 153.9" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 174.0 137.0 Q 131.2 129.4 90.3 122.2" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 90.3 122.2 L 90.0 152.2 L 60.0 152.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 174.0 137.0 L 219.3 145.0 L 258.7 151.9" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="70.6" cy="118.8" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="60.6" y1="118.8" x2="53.6" y2="118.8" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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)
|
||||
@@ -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]}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 122 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" fill="none">
|
||||
<title>Side Plank</title>
|
||||
<line x1="16" y1="156" x2="304" y2="156" stroke="#b9bec9" stroke-width="3" stroke-linecap="round"/>
|
||||
<path id="arm_l" d="M 96.3 124.2 L 101.5 94.7 L 106.7 65.2" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_l" d="M 180.0 139.0 L 225.3 147.0 L 264.7 153.9" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="spine" d="M 174.0 137.0 Q 131.2 129.4 90.3 122.2" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="arm_r" d="M 90.3 122.2 L 90.0 152.2 L 60.0 152.0" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="leg_r" d="M 174.0 137.0 L 219.3 145.0 L 258.7 151.9" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="head" cx="70.6" cy="118.8" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
|
||||
<line id="nose" x1="60.6" y1="118.8" x2="53.6" y2="118.8" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">
|
||||
<line x1="242" y1="16" x2="258" y2="16" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="264" y="20">R</text>
|
||||
<line x1="282" y1="16" x2="298" y2="16" stroke="#a9afba" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="304" y="20">L</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -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 `<Name>.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'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" fill="none">',
|
||||
f' <title>{name}</title>',
|
||||
f' <line x1="16" y1="{GROUND_Y + 4}" x2="{w - 16}" y2="{GROUND_Y + 4}"'
|
||||
f' stroke="{colors["ground"]}" stroke-width="3" stroke-linecap="round"/>']
|
||||
for part in PART_ORDER:
|
||||
if part == "head":
|
||||
hx, hy = geo["head"]
|
||||
(sx, sy), (ex, ey) = nose_segment(geo)
|
||||
parts.append(f' <circle id="head" cx="{hx:.1f}" cy="{hy:.1f}" r="{geo["headR"]}"'
|
||||
f' fill="{colors["head_fill"]}" stroke="{colors["right"]}" stroke-width="{WIDTHS["head"]}"/>')
|
||||
parts.append(f' <line id="nose" x1="{sx:.1f}" y1="{sy:.1f}" x2="{ex:.1f}" y2="{ey:.1f}"'
|
||||
f' stroke="{colors["right"]}" stroke-width="{WIDTHS["nose"]}" stroke-linecap="round"/>')
|
||||
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' <path id="{part}" d="{d}" stroke="{color}" stroke-width="{width}"'
|
||||
f' stroke-linecap="round" stroke-linejoin="round"/>')
|
||||
lx = w - 78
|
||||
parts.append(f' <g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="{colors["legend_text"]}">')
|
||||
parts.append(f' <line x1="{lx}" y1="16" x2="{lx + 16}" y2="16" stroke="{colors["right"]}" stroke-width="4" stroke-linecap="round"/>')
|
||||
parts.append(f' <text x="{lx + 22}" y="20">R</text>')
|
||||
parts.append(f' <line x1="{lx + 40}" y1="16" x2="{lx + 56}" y2="16" stroke="{colors["left"]}" stroke-width="4" stroke-linecap="round"/>')
|
||||
parts.append(f' <text x="{lx + 62}" y="20">L</text>')
|
||||
parts.append(' </g>')
|
||||
parts.append('</svg>')
|
||||
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 `<Name>.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()
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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..<totalPages, id: \.self) { index in
|
||||
page(for: index)
|
||||
@@ -727,6 +741,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: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.green)
|
||||
Text("Completed")
|
||||
.font(.system(.title3, design: .rounded, weight: .heavy))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ready Phase
|
||||
|
||||
private struct ReadyPhaseView: View {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//
|
||||
// ExerciseFigureView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The looping animated stick-figure for the run screen's bottom half, rendered with
|
||||
/// `Canvas` + `TimelineView(.animation)` from the bundled rig data (see
|
||||
/// `Exercise Library/SYSTEM.md` for the visual language).
|
||||
///
|
||||
/// Draw order matters: left limbs behind, spine, right limbs, then the head filled
|
||||
/// opaque with the background color so overhead arms pass behind the face — plus the
|
||||
/// nose tick (gaze) and the small R/L legend that makes opposite-limb moves readable.
|
||||
|
||||
/// Everything the renderer needs for one exercise, resolved once from the bundle.
|
||||
struct FigureAnimation {
|
||||
let timeline: MotionTimeline
|
||||
/// Parts drawn in the working accent color (`arm_r`, `leg_l`, `spine`, …).
|
||||
let working: Set<String>
|
||||
/// Limbs fully occluded in this view — never drawn.
|
||||
let hide: Set<String>
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
/// `<Exercise Name>.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 `<Exercise Name>.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)
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> 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]
|
||||
}
|
||||
}
|
||||
@@ -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]}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ struct ScreenshotRootView: View {
|
||||
content
|
||||
.environment(services)
|
||||
.environment(services.syncEngine)
|
||||
.environment(services.liveRunState)
|
||||
.modelContainer(services.container)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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<WorkoutDocument>, 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||