diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5bc431..67b7906 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
**July 2026**
+The form-guide figures now have feet — heels rise on calf raises, toes point through leg lifts, and kneeling feet rest naturally.
+
+The Rotary form guide now shows a genuine torso twist, with the arms and bar swinging across the body.
+
Machine exercises can now store your machine setup — seat height, back-rest position, and other comfort settings — editable from the exercise editor or mid-workout.
The Abductor and Adductor form guides now show the real seated machine motion — facing you, knees swinging apart and together.
diff --git a/Exercise Library/Abdominal/frames/frame-1.svg b/Exercise Library/Abdominal/frames/frame-1.svg
index 0fac729..3328638 100644
--- a/Exercise Library/Abdominal/frames/frame-1.svg
+++ b/Exercise Library/Abdominal/frames/frame-1.svg
@@ -7,17 +7,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Abdominal/frames/frame-2.svg b/Exercise Library/Abdominal/frames/frame-2.svg
index e0f75fc..49d9709 100644
--- a/Exercise Library/Abdominal/frames/frame-2.svg
+++ b/Exercise Library/Abdominal/frames/frame-2.svg
@@ -7,17 +7,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Abdominal/motion.json b/Exercise Library/Abdominal/motion.json
index 36f15cb..f41fc49 100644
--- a/Exercise Library/Abdominal/motion.json
+++ b/Exercise Library/Abdominal/motion.json
@@ -2,7 +2,6 @@
"name": "Abdominal",
"primary": 2,
"working": ["spine"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[120, 128], [114, 62]], "w": 8},
@@ -16,18 +15,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [128, 126],
- "spine": [94, 90], "neck": 82, "gaze": -8,
- "arm_r": [-12, -52], "arm_l": [-12, -52],
- "leg_r": [-20, -90], "leg_l": [-20, -90],
+ "root": {"pos": [128, 126], "pitch": -4},
+ "spine": [0, 4],
+ "neck": 8,
+ "shoulder_r": 78, "elbow_r": -40,
+ "shoulder_l": 78, "elbow_l": -40,
+ "hip_r": 66, "knee_r": 70,
+ "hip_l": 66, "knee_l": 70,
"pins": {"hand_r": [155, 62], "hand_l": [161, 64], "foot_r": [168, 150], "foot_l": [172, 150]}
},
{
"hold": 0.4, "tween": 1.0,
- "root": [128, 126],
- "spine": [80, 40], "neck": 22, "gaze": -42,
- "arm_r": [-12, -52], "arm_l": [-12, -52],
- "leg_r": [-20, -90], "leg_l": [-20, -90],
+ "root": {"pos": [128, 126], "pitch": 10},
+ "spine": [0, 40],
+ "neck": 18, "head": -26,
+ "shoulder_r": 128, "elbow_r": -40,
+ "shoulder_l": 128, "elbow_l": -40,
+ "hip_r": 80, "knee_r": 70,
+ "hip_l": 80, "knee_l": 70,
"pins": {"hand_r": [190, 76], "hand_l": [196, 78], "foot_r": [168, 150], "foot_l": [172, 150]}
}
]
diff --git a/Exercise Library/Abdominal/preview.gif b/Exercise Library/Abdominal/preview.gif
index 934972f..30335de 100644
Binary files a/Exercise Library/Abdominal/preview.gif and b/Exercise Library/Abdominal/preview.gif differ
diff --git a/Exercise Library/Abdominal/visual.svg b/Exercise Library/Abdominal/visual.svg
index e0f75fc..49d9709 100644
--- a/Exercise Library/Abdominal/visual.svg
+++ b/Exercise Library/Abdominal/visual.svg
@@ -7,17 +7,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Abductor/frames/frame-1.svg b/Exercise Library/Abductor/frames/frame-1.svg
index 83eac3d..4a1f9ec 100644
--- a/Exercise Library/Abductor/frames/frame-1.svg
+++ b/Exercise Library/Abductor/frames/frame-1.svg
@@ -4,18 +4,18 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Abductor/frames/frame-2.svg b/Exercise Library/Abductor/frames/frame-2.svg
index 43b81b2..3c6dfbf 100644
--- a/Exercise Library/Abductor/frames/frame-2.svg
+++ b/Exercise Library/Abductor/frames/frame-2.svg
@@ -4,18 +4,18 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Abductor/motion.json b/Exercise Library/Abductor/motion.json
index 626a2a5..ee56697 100644
--- a/Exercise Library/Abductor/motion.json
+++ b/Exercise Library/Abductor/motion.json
@@ -1,9 +1,8 @@
{
"name": "Abductor",
"primary": 2,
- "figure": "frontal",
+ "camera": {"yaw": 90},
"working": ["leg_r", "leg_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "rect", "x": 152, "y": 56, "w": 16, "h": 72, "r": 5},
@@ -16,17 +15,23 @@
"frames": [
{
"hold": 0.5, "tween": 1.1,
- "root": [160, 116],
- "spine": [90, 90], "neck": 90,
- "arm_r": [-48, -82], "arm_l": [-132, -98],
- "leg_r": [-75, -90], "leg_l": [-105, -90]
+ "root": {"pos": [160, 116]},
+ "spine": [0, 0],
+ "neck": 0,
+ "shoulder_r": {"flexion": 10, "abduction": 18}, "elbow_r": 25,
+ "shoulder_l": {"flexion": 10, "abduction": 18}, "elbow_l": 25,
+ "hip_r": {"flexion": 78, "abduction": 7}, "knee_r": 62,
+ "hip_l": {"flexion": 78, "abduction": 7}, "knee_l": 62
},
{
"hold": 0.5, "tween": 1.1,
- "root": [160, 116],
- "spine": [90, 90], "neck": 90,
- "arm_r": [-48, -82], "arm_l": [-132, -98],
- "leg_r": [-30, -90], "leg_l": [-150, -90]
+ "root": {"pos": [160, 116]},
+ "spine": [0, 0],
+ "neck": 0,
+ "shoulder_r": {"flexion": 10, "abduction": 18}, "elbow_r": 25,
+ "shoulder_l": {"flexion": 10, "abduction": 18}, "elbow_l": 25,
+ "hip_r": {"flexion": 78, "abduction": 30}, "knee_r": 62,
+ "hip_l": {"flexion": 78, "abduction": 30}, "knee_l": 62
}
]
}
diff --git a/Exercise Library/Abductor/preview.gif b/Exercise Library/Abductor/preview.gif
index e0b7127..8dcce44 100644
Binary files a/Exercise Library/Abductor/preview.gif and b/Exercise Library/Abductor/preview.gif differ
diff --git a/Exercise Library/Abductor/visual.svg b/Exercise Library/Abductor/visual.svg
index 43b81b2..3c6dfbf 100644
--- a/Exercise Library/Abductor/visual.svg
+++ b/Exercise Library/Abductor/visual.svg
@@ -4,18 +4,18 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Adductor/frames/frame-1.svg b/Exercise Library/Adductor/frames/frame-1.svg
index a3f62cf..6ef26d0 100644
--- a/Exercise Library/Adductor/frames/frame-1.svg
+++ b/Exercise Library/Adductor/frames/frame-1.svg
@@ -4,18 +4,18 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Adductor/frames/frame-2.svg b/Exercise Library/Adductor/frames/frame-2.svg
index 2893d9e..faf81a8 100644
--- a/Exercise Library/Adductor/frames/frame-2.svg
+++ b/Exercise Library/Adductor/frames/frame-2.svg
@@ -4,18 +4,18 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Adductor/motion.json b/Exercise Library/Adductor/motion.json
index 348c15e..6ffbf73 100644
--- a/Exercise Library/Adductor/motion.json
+++ b/Exercise Library/Adductor/motion.json
@@ -1,9 +1,8 @@
{
"name": "Adductor",
"primary": 2,
- "figure": "frontal",
+ "camera": {"yaw": 90},
"working": ["leg_r", "leg_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "rect", "x": 152, "y": 56, "w": 16, "h": 72, "r": 5},
@@ -16,17 +15,23 @@
"frames": [
{
"hold": 0.5, "tween": 1.1,
- "root": [160, 116],
- "spine": [90, 90], "neck": 90,
- "arm_r": [-48, -82], "arm_l": [-132, -98],
- "leg_r": [-30, -90], "leg_l": [-150, -90]
+ "root": {"pos": [160, 116]},
+ "spine": [0, 0],
+ "neck": 0,
+ "shoulder_r": {"flexion": 10, "abduction": 18}, "elbow_r": 25,
+ "shoulder_l": {"flexion": 10, "abduction": 18}, "elbow_l": 25,
+ "hip_r": {"flexion": 78, "abduction": 30}, "knee_r": 62,
+ "hip_l": {"flexion": 78, "abduction": 30}, "knee_l": 62
},
{
"hold": 0.5, "tween": 1.1,
- "root": [160, 116],
- "spine": [90, 90], "neck": 90,
- "arm_r": [-48, -82], "arm_l": [-132, -98],
- "leg_r": [-75, -90], "leg_l": [-105, -90]
+ "root": {"pos": [160, 116]},
+ "spine": [0, 0],
+ "neck": 0,
+ "shoulder_r": {"flexion": 10, "abduction": 18}, "elbow_r": 25,
+ "shoulder_l": {"flexion": 10, "abduction": 18}, "elbow_l": 25,
+ "hip_r": {"flexion": 78, "abduction": 7}, "knee_r": 62,
+ "hip_l": {"flexion": 78, "abduction": 7}, "knee_l": 62
}
]
}
diff --git a/Exercise Library/Adductor/preview.gif b/Exercise Library/Adductor/preview.gif
index 404badd..abe9a10 100644
Binary files a/Exercise Library/Adductor/preview.gif and b/Exercise Library/Adductor/preview.gif differ
diff --git a/Exercise Library/Adductor/visual.svg b/Exercise Library/Adductor/visual.svg
index 2893d9e..faf81a8 100644
--- a/Exercise Library/Adductor/visual.svg
+++ b/Exercise Library/Adductor/visual.svg
@@ -4,18 +4,18 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Arm Curl/frames/frame-1.svg b/Exercise Library/Arm Curl/frames/frame-1.svg
index b5d1566..e1bfc2b 100644
--- a/Exercise Library/Arm Curl/frames/frame-1.svg
+++ b/Exercise Library/Arm Curl/frames/frame-1.svg
@@ -5,17 +5,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Arm Curl/frames/frame-2.svg b/Exercise Library/Arm Curl/frames/frame-2.svg
index d98260d..985972f 100644
--- a/Exercise Library/Arm Curl/frames/frame-2.svg
+++ b/Exercise Library/Arm Curl/frames/frame-2.svg
@@ -5,17 +5,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Arm Curl/motion.json b/Exercise Library/Arm Curl/motion.json
index 03f5648..23eb200 100644
--- a/Exercise Library/Arm Curl/motion.json
+++ b/Exercise Library/Arm Curl/motion.json
@@ -2,7 +2,6 @@
"name": "Arm Curl",
"primary": 2,
"working": ["arm_r", "arm_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[116, 134], [150, 134]], "w": 8},
@@ -14,18 +13,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [130, 124],
- "spine": [88, 86], "neck": 82, "gaze": -18,
- "arm_r": [-35, -58], "arm_l": [-35, -58],
- "leg_r": [-8, -72], "leg_l": [-8, -72],
+ "root": {"pos": [130, 124], "pitch": 2},
+ "spine": [0, 2],
+ "neck": 4, "head": 10,
+ "shoulder_r": 59, "elbow_r": -23,
+ "shoulder_l": 59, "elbow_l": -23,
+ "hip_r": 84, "knee_r": 64,
+ "hip_l": 84, "knee_l": 64,
"pins": {"foot_r": [176, 150], "foot_l": [180, 151]}
},
{
"hold": 0.4, "tween": 1.2,
- "root": [130, 124],
- "spine": [88, 86], "neck": 82, "gaze": -18,
- "arm_r": [-35, 50], "arm_l": [-35, 50],
- "leg_r": [-8, -72], "leg_l": [-8, -72],
+ "root": {"pos": [130, 124], "pitch": 2},
+ "spine": [0, 2],
+ "neck": 4, "head": 10,
+ "shoulder_r": 59, "elbow_r": 85,
+ "shoulder_l": 59, "elbow_l": 85,
+ "hip_r": 84, "knee_r": 64,
+ "hip_l": 84, "knee_l": 64,
"pins": {"foot_r": [176, 150], "foot_l": [180, 151]}
}
]
diff --git a/Exercise Library/Arm Curl/preview.gif b/Exercise Library/Arm Curl/preview.gif
index 4072f74..4ac8d39 100644
Binary files a/Exercise Library/Arm Curl/preview.gif and b/Exercise Library/Arm Curl/preview.gif differ
diff --git a/Exercise Library/Arm Curl/visual.svg b/Exercise Library/Arm Curl/visual.svg
index d98260d..985972f 100644
--- a/Exercise Library/Arm Curl/visual.svg
+++ b/Exercise Library/Arm Curl/visual.svg
@@ -5,17 +5,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Bird Dog/frames/frame-1.svg b/Exercise Library/Bird Dog/frames/frame-1.svg
index eeb9333..b6772ea 100644
--- a/Exercise Library/Bird Dog/frames/frame-1.svg
+++ b/Exercise Library/Bird Dog/frames/frame-1.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Bird Dog/frames/frame-2.svg b/Exercise Library/Bird Dog/frames/frame-2.svg
index d842d2e..376ad2c 100644
--- a/Exercise Library/Bird Dog/frames/frame-2.svg
+++ b/Exercise Library/Bird Dog/frames/frame-2.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Bird Dog/frames/frame-3.svg b/Exercise Library/Bird Dog/frames/frame-3.svg
index eeb9333..b6772ea 100644
--- a/Exercise Library/Bird Dog/frames/frame-3.svg
+++ b/Exercise Library/Bird Dog/frames/frame-3.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Bird Dog/frames/frame-4.svg b/Exercise Library/Bird Dog/frames/frame-4.svg
index cc10e51..8a3f9a4 100644
--- a/Exercise Library/Bird Dog/frames/frame-4.svg
+++ b/Exercise Library/Bird Dog/frames/frame-4.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Bird Dog/motion.json b/Exercise Library/Bird Dog/motion.json
index 15336d5..f912e3a 100644
--- a/Exercise Library/Bird Dog/motion.json
+++ b/Exercise Library/Bird Dog/motion.json
@@ -1,39 +1,51 @@
{
"name": "Bird Dog",
"primary": 2,
- "working": ["arm_r", "leg_l", "arm_l", "leg_r"],
+ "working": ["arm_l", "leg_r", "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]}
+ "root": {"pos": [190, 106], "yaw": 180, "pitch": 81},
+ "spine": [0, 0],
+ "neck": 16, "head": -72,
+ "shoulder_r": 81, "elbow_r": 0,
+ "shoulder_l": 81, "elbow_l": 0,
+ "hip_r": 74, "knee_r": 83, "ankle_r": -55,
+ "hip_l": 74, "knee_l": 83, "ankle_l": -55,
+ "pins": {"hand_l": [105, 152], "hand_r": [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]}
+ "root": {"pos": [190, 106], "yaw": 180, "pitch": 81},
+ "spine": [0, 0],
+ "neck": 16, "head": -82,
+ "shoulder_r": 81, "elbow_r": 0,
+ "shoulder_l": 190, "elbow_l": 0,
+ "hip_r": -24, "knee_r": 0, "ankle_r": -25,
+ "hip_l": 74, "knee_l": 83, "ankle_l": -55,
+ "pins": {"hand_r": [111, 154]}
},
{
"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]}
+ "root": {"pos": [190, 106], "yaw": 180, "pitch": 81},
+ "spine": [0, 0],
+ "neck": 16, "head": -72,
+ "shoulder_r": 81, "elbow_r": 0,
+ "shoulder_l": 81, "elbow_l": 0,
+ "hip_r": 74, "knee_r": 83, "ankle_r": -55,
+ "hip_l": 74, "knee_l": 83, "ankle_l": -55,
+ "pins": {"hand_l": [105, 152], "hand_r": [111, 154]}
},
{
"hold": 1.4, "tween": 0.8,
- "root": [190, 106],
- "spine": [171, 171], "neck": 187, "gaze": 195,
- "arm_r": [-90, -90], "arm_l": [161, 161],
- "leg_r": [15, 15], "leg_l": [-83, 0],
- "pins": {"hand_r": [105, 152]}
+ "root": {"pos": [190, 106], "yaw": 180, "pitch": 81},
+ "spine": [0, 0],
+ "neck": 16, "head": -82,
+ "shoulder_r": 190, "elbow_r": 0,
+ "shoulder_l": 81, "elbow_l": 0,
+ "hip_r": 74, "knee_r": 83, "ankle_r": -55,
+ "hip_l": -24, "knee_l": 0, "ankle_l": -25,
+ "pins": {"hand_l": [105, 152]}
}
]
}
diff --git a/Exercise Library/Bird Dog/orbit.gif b/Exercise Library/Bird Dog/orbit.gif
new file mode 100644
index 0000000..a9fb0b7
Binary files /dev/null and b/Exercise Library/Bird Dog/orbit.gif differ
diff --git a/Exercise Library/Bird Dog/preview.gif b/Exercise Library/Bird Dog/preview.gif
index e1f900f..98ffc7d 100644
Binary files a/Exercise Library/Bird Dog/preview.gif and b/Exercise Library/Bird Dog/preview.gif differ
diff --git a/Exercise Library/Bird Dog/visual.svg b/Exercise Library/Bird Dog/visual.svg
index d842d2e..376ad2c 100644
--- a/Exercise Library/Bird Dog/visual.svg
+++ b/Exercise Library/Bird Dog/visual.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Calfs/frames/frame-1.svg b/Exercise Library/Calfs/frames/frame-1.svg
index c5fae77..6e20245 100644
--- a/Exercise Library/Calfs/frames/frame-1.svg
+++ b/Exercise Library/Calfs/frames/frame-1.svg
@@ -8,18 +8,18 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Calfs/frames/frame-2.svg b/Exercise Library/Calfs/frames/frame-2.svg
index e0a3604..e3f2a27 100644
--- a/Exercise Library/Calfs/frames/frame-2.svg
+++ b/Exercise Library/Calfs/frames/frame-2.svg
@@ -8,18 +8,18 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Calfs/motion.json b/Exercise Library/Calfs/motion.json
index ce2a646..0dc1341 100644
--- a/Exercise Library/Calfs/motion.json
+++ b/Exercise Library/Calfs/motion.json
@@ -2,7 +2,6 @@
"name": "Calfs",
"primary": 2,
"working": ["leg_r", "leg_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[124, 126], [158, 124]], "w": 9},
@@ -18,18 +17,24 @@
"frames": [
{
"hold": 0.4, "tween": 0.9,
- "root": [140, 118],
- "spine": [100, 96], "neck": 80, "gaze": -10,
- "arm_r": [-40, -15], "arm_l": [-40, -15],
- "leg_r": [-18, -8], "leg_l": [-18, -8],
+ "root": {"pos": [140, 118], "pitch": -10},
+ "spine": [0, 4],
+ "neck": 16,
+ "shoulder_r": 44, "elbow_r": 25,
+ "shoulder_l": 44, "elbow_l": 25,
+ "hip_r": 62, "knee_r": -10, "ankle_r": 12,
+ "hip_l": 62, "knee_l": -10, "ankle_l": 12,
"pins": {"hand_r": [166, 74], "hand_l": [170, 76], "foot_r": [212, 148], "foot_l": [216, 150]}
},
{
"hold": 0.4, "tween": 1.2,
- "root": [140, 118],
- "spine": [100, 96], "neck": 80, "gaze": -10,
- "arm_r": [-40, -15], "arm_l": [-40, -15],
- "leg_r": [-14, 4], "leg_l": [-14, 4],
+ "root": {"pos": [140, 118], "pitch": -10},
+ "spine": [0, 4],
+ "neck": 16,
+ "shoulder_r": 44, "elbow_r": 25,
+ "shoulder_l": 44, "elbow_l": 25,
+ "hip_r": 66, "knee_r": -18, "ankle_r": -18,
+ "hip_l": 66, "knee_l": -18, "ankle_l": -18,
"pins": {"hand_r": [166, 74], "hand_l": [170, 76], "foot_r": [212, 139], "foot_l": [216, 141]}
}
]
diff --git a/Exercise Library/Calfs/preview.gif b/Exercise Library/Calfs/preview.gif
index 80d183c..d347630 100644
Binary files a/Exercise Library/Calfs/preview.gif and b/Exercise Library/Calfs/preview.gif differ
diff --git a/Exercise Library/Calfs/visual.svg b/Exercise Library/Calfs/visual.svg
index e0a3604..e3f2a27 100644
--- a/Exercise Library/Calfs/visual.svg
+++ b/Exercise Library/Calfs/visual.svg
@@ -8,18 +8,18 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Cat-Cow/frames/frame-1.svg b/Exercise Library/Cat-Cow/frames/frame-1.svg
index ab4d9db..7011f6a 100644
--- a/Exercise Library/Cat-Cow/frames/frame-1.svg
+++ b/Exercise Library/Cat-Cow/frames/frame-1.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Cat-Cow/frames/frame-2.svg b/Exercise Library/Cat-Cow/frames/frame-2.svg
index 33d04de..ec04352 100644
--- a/Exercise Library/Cat-Cow/frames/frame-2.svg
+++ b/Exercise Library/Cat-Cow/frames/frame-2.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Cat-Cow/motion.json b/Exercise Library/Cat-Cow/motion.json
index 699e99d..cce1752 100644
--- a/Exercise Library/Cat-Cow/motion.json
+++ b/Exercise Library/Cat-Cow/motion.json
@@ -5,19 +5,25 @@
"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]}
+ "root": {"pos": [190, 106], "yaw": 180, "pitch": 98},
+ "spine": [0, -33],
+ "neck": 30, "head": -111,
+ "shoulder_r": 65, "elbow_r": 0,
+ "shoulder_l": 65, "elbow_l": 0,
+ "hip_r": 91, "knee_r": 83, "ankle_r": -55,
+ "hip_l": 91, "knee_l": 83, "ankle_l": -55,
+ "pins": {"hand_l": [105, 152], "hand_r": [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]}
+ "root": {"pos": [190, 106], "yaw": 180, "pitch": 65},
+ "spine": [0, 37],
+ "neck": 23, "head": -80,
+ "shoulder_r": 102, "elbow_r": 0,
+ "shoulder_l": 102, "elbow_l": 0,
+ "hip_r": 58, "knee_r": 83, "ankle_r": -55,
+ "hip_l": 58, "knee_l": 83, "ankle_l": -55,
+ "pins": {"hand_l": [105, 152], "hand_r": [111, 154]}
}
]
}
diff --git a/Exercise Library/Cat-Cow/preview.gif b/Exercise Library/Cat-Cow/preview.gif
index 97e7dfe..fdb50c9 100644
Binary files a/Exercise Library/Cat-Cow/preview.gif and b/Exercise Library/Cat-Cow/preview.gif differ
diff --git a/Exercise Library/Cat-Cow/visual.svg b/Exercise Library/Cat-Cow/visual.svg
index ab4d9db..7011f6a 100644
--- a/Exercise Library/Cat-Cow/visual.svg
+++ b/Exercise Library/Cat-Cow/visual.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Chest Press/frames/frame-1.svg b/Exercise Library/Chest Press/frames/frame-1.svg
index 1e6a755..66db829 100644
--- a/Exercise Library/Chest Press/frames/frame-1.svg
+++ b/Exercise Library/Chest Press/frames/frame-1.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Chest Press/frames/frame-2.svg b/Exercise Library/Chest Press/frames/frame-2.svg
index fc88385..f1cce65 100644
--- a/Exercise Library/Chest Press/frames/frame-2.svg
+++ b/Exercise Library/Chest Press/frames/frame-2.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Chest Press/motion.json b/Exercise Library/Chest Press/motion.json
index 8b8cf53..2369b0a 100644
--- a/Exercise Library/Chest Press/motion.json
+++ b/Exercise Library/Chest Press/motion.json
@@ -2,7 +2,6 @@
"name": "Chest Press",
"primary": 2,
"working": ["arm_r", "arm_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[114, 132], [86, 40]], "w": 8},
@@ -15,18 +14,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.1,
- "root": [116, 122],
- "spine": [104, 100], "neck": 84, "gaze": 5,
- "arm_r": [-100, 40], "arm_l": [-100, 40],
- "leg_r": [-22, -72], "leg_l": [-22, -72],
+ "root": {"pos": [116, 122], "pitch": -14},
+ "spine": [0, 4],
+ "neck": 16, "head": -11,
+ "shoulder_r": -20, "elbow_r": 140,
+ "shoulder_l": -20, "elbow_l": 140,
+ "hip_r": 54, "knee_r": 50,
+ "hip_l": 54, "knee_l": 50,
"pins": {"foot_r": [158, 151], "foot_l": [162, 152]}
},
{
"hold": 0.4, "tween": 1.1,
- "root": [116, 122],
- "spine": [104, 100], "neck": 84, "gaze": 5,
- "arm_r": [-6, -14], "arm_l": [-6, -14],
- "leg_r": [-22, -72], "leg_l": [-22, -72],
+ "root": {"pos": [116, 122], "pitch": -14},
+ "spine": [0, 4],
+ "neck": 16, "head": -11,
+ "shoulder_r": 74, "elbow_r": -8,
+ "shoulder_l": 74, "elbow_l": -8,
+ "hip_r": 54, "knee_r": 50,
+ "hip_l": 54, "knee_l": 50,
"pins": {"foot_r": [158, 151], "foot_l": [162, 152]}
}
]
diff --git a/Exercise Library/Chest Press/preview.gif b/Exercise Library/Chest Press/preview.gif
index 7a5fe2e..2fabc73 100644
Binary files a/Exercise Library/Chest Press/preview.gif and b/Exercise Library/Chest Press/preview.gif differ
diff --git a/Exercise Library/Chest Press/visual.svg b/Exercise Library/Chest Press/visual.svg
index fc88385..f1cce65 100644
--- a/Exercise Library/Chest Press/visual.svg
+++ b/Exercise Library/Chest Press/visual.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Dead Bug/frames/frame-1.svg b/Exercise Library/Dead Bug/frames/frame-1.svg
index 5209905..ab520e8 100644
--- a/Exercise Library/Dead Bug/frames/frame-1.svg
+++ b/Exercise Library/Dead Bug/frames/frame-1.svg
@@ -2,16 +2,16 @@
Dead Bug
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Dead Bug/frames/frame-2.svg b/Exercise Library/Dead Bug/frames/frame-2.svg
index 2dd4abf..baf45c4 100644
--- a/Exercise Library/Dead Bug/frames/frame-2.svg
+++ b/Exercise Library/Dead Bug/frames/frame-2.svg
@@ -2,16 +2,16 @@
Dead Bug
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Dead Bug/frames/frame-3.svg b/Exercise Library/Dead Bug/frames/frame-3.svg
index 5209905..ab520e8 100644
--- a/Exercise Library/Dead Bug/frames/frame-3.svg
+++ b/Exercise Library/Dead Bug/frames/frame-3.svg
@@ -2,16 +2,16 @@
Dead Bug
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Dead Bug/frames/frame-4.svg b/Exercise Library/Dead Bug/frames/frame-4.svg
index f3be8a0..b43a0ca 100644
--- a/Exercise Library/Dead Bug/frames/frame-4.svg
+++ b/Exercise Library/Dead Bug/frames/frame-4.svg
@@ -2,16 +2,16 @@
Dead Bug
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Dead Bug/motion.json b/Exercise Library/Dead Bug/motion.json
index 74bc740..c6d7bb4 100644
--- a/Exercise Library/Dead Bug/motion.json
+++ b/Exercise Library/Dead Bug/motion.json
@@ -5,31 +5,43 @@
"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]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_r": 90, "elbow_r": 0,
+ "shoulder_l": 90, "elbow_l": 0,
+ "hip_r": 64, "knee_r": 74,
+ "hip_l": 64, "knee_l": 74
},
{
"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]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_r": 135, "elbow_r": 0,
+ "shoulder_l": 90, "elbow_l": 0,
+ "hip_r": 64, "knee_r": 74,
+ "hip_l": 16, "knee_l": 0
},
{
"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]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_r": 90, "elbow_r": 0,
+ "shoulder_l": 90, "elbow_l": 0,
+ "hip_r": 64, "knee_r": 74,
+ "hip_l": 64, "knee_l": 74
},
{
"hold": 0.9, "tween": 0.8,
- "root": [185, 148],
- "spine": [180, 180], "neck": 168, "gaze": 90,
- "arm_r": [90, 90], "arm_l": [135, 135],
- "leg_r": [16, 16], "leg_l": [64, -10]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_r": 90, "elbow_r": 0,
+ "shoulder_l": 135, "elbow_l": 0,
+ "hip_r": 16, "knee_r": 0,
+ "hip_l": 64, "knee_l": 74
}
]
}
diff --git a/Exercise Library/Dead Bug/preview.gif b/Exercise Library/Dead Bug/preview.gif
index bad6a1f..9bbdc59 100644
Binary files a/Exercise Library/Dead Bug/preview.gif and b/Exercise Library/Dead Bug/preview.gif differ
diff --git a/Exercise Library/Dead Bug/visual.svg b/Exercise Library/Dead Bug/visual.svg
index 2dd4abf..baf45c4 100644
--- a/Exercise Library/Dead Bug/visual.svg
+++ b/Exercise Library/Dead Bug/visual.svg
@@ -2,16 +2,16 @@
Dead Bug
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Hollow Body Hold/frames/frame-1.svg b/Exercise Library/Hollow Body Hold/frames/frame-1.svg
index c5da853..bc2a6ec 100644
--- a/Exercise Library/Hollow Body Hold/frames/frame-1.svg
+++ b/Exercise Library/Hollow Body Hold/frames/frame-1.svg
@@ -1,16 +1,16 @@
diff --git a/Exercise Library/Hollow Body Hold/frames/frame-2.svg b/Exercise Library/Hollow Body Hold/frames/frame-2.svg
index 7440d75..02e6bff 100644
--- a/Exercise Library/Hollow Body Hold/frames/frame-2.svg
+++ b/Exercise Library/Hollow Body Hold/frames/frame-2.svg
@@ -1,16 +1,16 @@
diff --git a/Exercise Library/Hollow Body Hold/motion.json b/Exercise Library/Hollow Body Hold/motion.json
index 8ed3760..d9319c3 100644
--- a/Exercise Library/Hollow Body Hold/motion.json
+++ b/Exercise Library/Hollow Body Hold/motion.json
@@ -6,17 +6,21 @@
"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]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_r": 180, "elbow_r": 0,
+ "hip_r": 0, "knee_r": 0,
+ "hip_l": 0, "knee_l": 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]
+ "root": {"pos": [185, 148], "pitch": -84},
+ "spine": [0, 14],
+ "neck": 20,
+ "shoulder_r": 175, "elbow_r": 0,
+ "hip_r": 18, "knee_r": 0,
+ "hip_l": 18, "knee_l": 0
}
]
}
diff --git a/Exercise Library/Hollow Body Hold/preview.gif b/Exercise Library/Hollow Body Hold/preview.gif
index 2e6b0ee..a0a9417 100644
Binary files a/Exercise Library/Hollow Body Hold/preview.gif and b/Exercise Library/Hollow Body Hold/preview.gif differ
diff --git a/Exercise Library/Hollow Body Hold/visual.svg b/Exercise Library/Hollow Body Hold/visual.svg
index 7440d75..02e6bff 100644
--- a/Exercise Library/Hollow Body Hold/visual.svg
+++ b/Exercise Library/Hollow Body Hold/visual.svg
@@ -1,16 +1,16 @@
diff --git a/Exercise Library/Lat Pull Down/frames/frame-1.svg b/Exercise Library/Lat Pull Down/frames/frame-1.svg
index c9d12ed..907f315 100644
--- a/Exercise Library/Lat Pull Down/frames/frame-1.svg
+++ b/Exercise Library/Lat Pull Down/frames/frame-1.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Lat Pull Down/frames/frame-2.svg b/Exercise Library/Lat Pull Down/frames/frame-2.svg
index fc83429..7061fe1 100644
--- a/Exercise Library/Lat Pull Down/frames/frame-2.svg
+++ b/Exercise Library/Lat Pull Down/frames/frame-2.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Lat Pull Down/motion.json b/Exercise Library/Lat Pull Down/motion.json
index 6d4170a..78202df 100644
--- a/Exercise Library/Lat Pull Down/motion.json
+++ b/Exercise Library/Lat Pull Down/motion.json
@@ -2,7 +2,6 @@
"name": "Lat Pull Down",
"primary": 2,
"working": ["arm_r", "arm_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[126, 130], [162, 130]], "w": 8},
@@ -15,21 +14,25 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [150, 122],
- "spine": [93, 90], "neck": 85, "gaze": 55,
- "arm_r": [35, 35], "arm_l": [35, 35],
- "leg_r": [-12, -80], "leg_l": [-12, -80],
- "pins": {"hand_r": [194, 1], "hand_l": [198, 3],
- "foot_r": [193, 150], "foot_l": [197, 151]}
+ "root": {"pos": [150, 122], "pitch": -3},
+ "spine": [0, 3],
+ "neck": 5, "head": -60,
+ "shoulder_r": 125, "elbow_r": 0,
+ "shoulder_l": 125, "elbow_l": 0,
+ "hip_r": 75, "knee_r": 68,
+ "hip_l": 75, "knee_l": 68,
+ "pins": {"hand_r": [194, 1], "hand_l": [198, 3], "foot_r": [193, 150], "foot_l": [197, 151]}
},
{
"hold": 0.4, "tween": 1.2,
- "root": [150, 122],
- "spine": [93, 90], "neck": 85, "gaze": 30,
- "arm_r": [-38, 38], "arm_l": [-38, 38],
- "leg_r": [-12, -80], "leg_l": [-12, -80],
- "pins": {"hand_r": [194, 35], "hand_l": [198, 37],
- "foot_r": [193, 150], "foot_l": [197, 151]}
+ "root": {"pos": [150, 122], "pitch": -3},
+ "spine": [0, 3],
+ "neck": 5, "head": -35,
+ "shoulder_r": 52, "elbow_r": 76,
+ "shoulder_l": 52, "elbow_l": 76,
+ "hip_r": 75, "knee_r": 68,
+ "hip_l": 75, "knee_l": 68,
+ "pins": {"hand_r": [194, 35], "hand_l": [198, 37], "foot_r": [193, 150], "foot_l": [197, 151]}
}
]
}
diff --git a/Exercise Library/Lat Pull Down/preview.gif b/Exercise Library/Lat Pull Down/preview.gif
index d432080..27635f4 100644
Binary files a/Exercise Library/Lat Pull Down/preview.gif and b/Exercise Library/Lat Pull Down/preview.gif differ
diff --git a/Exercise Library/Lat Pull Down/visual.svg b/Exercise Library/Lat Pull Down/visual.svg
index fc83429..7061fe1 100644
--- a/Exercise Library/Lat Pull Down/visual.svg
+++ b/Exercise Library/Lat Pull Down/visual.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Leg Curl/frames/frame-1.svg b/Exercise Library/Leg Curl/frames/frame-1.svg
index db6e71d..7581263 100644
--- a/Exercise Library/Leg Curl/frames/frame-1.svg
+++ b/Exercise Library/Leg Curl/frames/frame-1.svg
@@ -9,17 +9,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Leg Curl/frames/frame-2.svg b/Exercise Library/Leg Curl/frames/frame-2.svg
index d7317a1..e2ba219 100644
--- a/Exercise Library/Leg Curl/frames/frame-2.svg
+++ b/Exercise Library/Leg Curl/frames/frame-2.svg
@@ -9,17 +9,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Leg Curl/motion.json b/Exercise Library/Leg Curl/motion.json
index 0151be6..5e24c99 100644
--- a/Exercise Library/Leg Curl/motion.json
+++ b/Exercise Library/Leg Curl/motion.json
@@ -2,7 +2,6 @@
"name": "Leg Curl",
"primary": 2,
"working": ["leg_r", "leg_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[110, 122], [86, 48]], "w": 9},
@@ -18,17 +17,23 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [118, 116],
- "spine": [110, 106], "neck": 74, "gaze": -20,
- "arm_r": [-75, -60], "arm_l": [-75, -60],
- "leg_r": [10, 4], "leg_l": [10, 4]
+ "root": {"pos": [118, 116], "pitch": -20},
+ "spine": [0, 4],
+ "neck": 32, "head": 4,
+ "shoulder_r": -1, "elbow_r": 15,
+ "shoulder_l": -1, "elbow_l": 15,
+ "hip_r": 80, "knee_r": 6,
+ "hip_l": 80, "knee_l": 6
},
{
"hold": 0.4, "tween": 1.2,
- "root": [118, 116],
- "spine": [110, 106], "neck": 74, "gaze": -20,
- "arm_r": [-75, -60], "arm_l": [-75, -60],
- "leg_r": [10, -95], "leg_l": [10, -95]
+ "root": {"pos": [118, 116], "pitch": -20},
+ "spine": [0, 4],
+ "neck": 32, "head": 4,
+ "shoulder_r": -1, "elbow_r": 15,
+ "shoulder_l": -1, "elbow_l": 15,
+ "hip_r": 80, "knee_r": 105,
+ "hip_l": 80, "knee_l": 105
}
]
}
diff --git a/Exercise Library/Leg Curl/preview.gif b/Exercise Library/Leg Curl/preview.gif
index ecc0c95..a49d75b 100644
Binary files a/Exercise Library/Leg Curl/preview.gif and b/Exercise Library/Leg Curl/preview.gif differ
diff --git a/Exercise Library/Leg Curl/visual.svg b/Exercise Library/Leg Curl/visual.svg
index d7317a1..e2ba219 100644
--- a/Exercise Library/Leg Curl/visual.svg
+++ b/Exercise Library/Leg Curl/visual.svg
@@ -9,17 +9,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Leg Extension/frames/frame-1.svg b/Exercise Library/Leg Extension/frames/frame-1.svg
index 6482955..7a20cae 100644
--- a/Exercise Library/Leg Extension/frames/frame-1.svg
+++ b/Exercise Library/Leg Extension/frames/frame-1.svg
@@ -8,17 +8,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Leg Extension/frames/frame-2.svg b/Exercise Library/Leg Extension/frames/frame-2.svg
index 65601f1..23d0c0b 100644
--- a/Exercise Library/Leg Extension/frames/frame-2.svg
+++ b/Exercise Library/Leg Extension/frames/frame-2.svg
@@ -8,17 +8,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Leg Extension/motion.json b/Exercise Library/Leg Extension/motion.json
index 367717c..79e628c 100644
--- a/Exercise Library/Leg Extension/motion.json
+++ b/Exercise Library/Leg Extension/motion.json
@@ -2,7 +2,6 @@
"name": "Leg Extension",
"primary": 2,
"working": ["leg_r", "leg_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[110, 122], [86, 48]], "w": 9},
@@ -17,17 +16,23 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [118, 116],
- "spine": [110, 106], "neck": 74, "gaze": -20,
- "arm_r": [-75, -60], "arm_l": [-75, -60],
- "leg_r": [10, -83], "leg_l": [10, -83]
+ "root": {"pos": [118, 116], "pitch": -20},
+ "spine": [0, 4],
+ "neck": 32, "head": 4,
+ "shoulder_r": -1, "elbow_r": 15,
+ "shoulder_l": -1, "elbow_l": 15,
+ "hip_r": 80, "knee_r": 93,
+ "hip_l": 80, "knee_l": 93
},
{
"hold": 0.4, "tween": 1.2,
- "root": [118, 116],
- "spine": [110, 106], "neck": 74, "gaze": -20,
- "arm_r": [-75, -60], "arm_l": [-75, -60],
- "leg_r": [10, 4], "leg_l": [10, 4]
+ "root": {"pos": [118, 116], "pitch": -20},
+ "spine": [0, 4],
+ "neck": 32, "head": 4,
+ "shoulder_r": -1, "elbow_r": 15,
+ "shoulder_l": -1, "elbow_l": 15,
+ "hip_r": 80, "knee_r": 6,
+ "hip_l": 80, "knee_l": 6
}
]
}
diff --git a/Exercise Library/Leg Extension/preview.gif b/Exercise Library/Leg Extension/preview.gif
index e14bb27..d07a7ab 100644
Binary files a/Exercise Library/Leg Extension/preview.gif and b/Exercise Library/Leg Extension/preview.gif differ
diff --git a/Exercise Library/Leg Extension/visual.svg b/Exercise Library/Leg Extension/visual.svg
index 65601f1..23d0c0b 100644
--- a/Exercise Library/Leg Extension/visual.svg
+++ b/Exercise Library/Leg Extension/visual.svg
@@ -8,17 +8,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Leg Press/frames/frame-1.svg b/Exercise Library/Leg Press/frames/frame-1.svg
index 8763d4b..e69ad09 100644
--- a/Exercise Library/Leg Press/frames/frame-1.svg
+++ b/Exercise Library/Leg Press/frames/frame-1.svg
@@ -8,17 +8,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Leg Press/frames/frame-2.svg b/Exercise Library/Leg Press/frames/frame-2.svg
index ec26558..6d041ad 100644
--- a/Exercise Library/Leg Press/frames/frame-2.svg
+++ b/Exercise Library/Leg Press/frames/frame-2.svg
@@ -8,17 +8,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Leg Press/motion.json b/Exercise Library/Leg Press/motion.json
index c28c4eb..78ee2e0 100644
--- a/Exercise Library/Leg Press/motion.json
+++ b/Exercise Library/Leg Press/motion.json
@@ -2,7 +2,6 @@
"name": "Leg Press",
"primary": 2,
"working": ["leg_r", "leg_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[134, 123], [96, 36]], "w": 9},
@@ -17,18 +16,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [140, 116],
- "spine": [116, 112], "neck": 75, "gaze": -15,
- "arm_r": [-75, -20], "arm_l": [-75, -20],
- "leg_r": [40, -58], "leg_l": [40, -58],
+ "root": {"pos": [140, 116], "pitch": -26},
+ "spine": [0, 4],
+ "neck": 37,
+ "shoulder_r": -7, "elbow_r": 55,
+ "shoulder_l": -7, "elbow_l": 55,
+ "hip_r": 104, "knee_r": 98,
+ "hip_l": 104, "knee_l": 98,
"pins": {"foot_r": [185, 102], "foot_l": [189, 104]}
},
{
"hold": 0.4, "tween": 1.2,
- "root": [140, 116],
- "spine": [116, 112], "neck": 75, "gaze": -15,
- "arm_r": [-75, -20], "arm_l": [-75, -20],
- "leg_r": [20, -5], "leg_l": [20, -5],
+ "root": {"pos": [140, 116], "pitch": -26},
+ "spine": [0, 4],
+ "neck": 37,
+ "shoulder_r": -7, "elbow_r": 55,
+ "shoulder_l": -7, "elbow_l": 55,
+ "hip_r": 84, "knee_r": 25,
+ "hip_l": 84, "knee_l": 25,
"pins": {"foot_r": [220, 101], "foot_l": [224, 103]}
}
]
diff --git a/Exercise Library/Leg Press/preview.gif b/Exercise Library/Leg Press/preview.gif
index 819d134..67786a4 100644
Binary files a/Exercise Library/Leg Press/preview.gif and b/Exercise Library/Leg Press/preview.gif differ
diff --git a/Exercise Library/Leg Press/visual.svg b/Exercise Library/Leg Press/visual.svg
index ec26558..6d041ad 100644
--- a/Exercise Library/Leg Press/visual.svg
+++ b/Exercise Library/Leg Press/visual.svg
@@ -8,17 +8,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Leg Raises/frames/frame-1.svg b/Exercise Library/Leg Raises/frames/frame-1.svg
index 5dfd35f..b905672 100644
--- a/Exercise Library/Leg Raises/frames/frame-1.svg
+++ b/Exercise Library/Leg Raises/frames/frame-1.svg
@@ -2,15 +2,15 @@
Leg Raises
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Leg Raises/frames/frame-2.svg b/Exercise Library/Leg Raises/frames/frame-2.svg
index f88ee55..b3878de 100644
--- a/Exercise Library/Leg Raises/frames/frame-2.svg
+++ b/Exercise Library/Leg Raises/frames/frame-2.svg
@@ -2,15 +2,15 @@
Leg Raises
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Leg Raises/motion.json b/Exercise Library/Leg Raises/motion.json
index 3a26817..f1dc7ae 100644
--- a/Exercise Library/Leg Raises/motion.json
+++ b/Exercise Library/Leg Raises/motion.json
@@ -6,17 +6,21 @@
"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]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_l": -2, "elbow_l": 0,
+ "hip_r": 60, "knee_r": 0,
+ "hip_l": 60, "knee_l": 0
},
{
"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]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_l": -2, "elbow_l": 0,
+ "hip_r": 8, "knee_r": 0,
+ "hip_l": 8, "knee_l": 0
}
]
}
diff --git a/Exercise Library/Leg Raises/preview.gif b/Exercise Library/Leg Raises/preview.gif
index 191c447..0e9b7b7 100644
Binary files a/Exercise Library/Leg Raises/preview.gif and b/Exercise Library/Leg Raises/preview.gif differ
diff --git a/Exercise Library/Leg Raises/visual.svg b/Exercise Library/Leg Raises/visual.svg
index 5dfd35f..b905672 100644
--- a/Exercise Library/Leg Raises/visual.svg
+++ b/Exercise Library/Leg Raises/visual.svg
@@ -2,15 +2,15 @@
Leg Raises
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Plank/frames/frame-1.svg b/Exercise Library/Plank/frames/frame-1.svg
index 033b765..915ac30 100644
--- a/Exercise Library/Plank/frames/frame-1.svg
+++ b/Exercise Library/Plank/frames/frame-1.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Plank/frames/frame-2.svg b/Exercise Library/Plank/frames/frame-2.svg
index 566d799..444f09f 100644
--- a/Exercise Library/Plank/frames/frame-2.svg
+++ b/Exercise Library/Plank/frames/frame-2.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Plank/motion.json b/Exercise Library/Plank/motion.json
index 8810dec..abeec08 100644
--- a/Exercise Library/Plank/motion.json
+++ b/Exercise Library/Plank/motion.json
@@ -5,17 +5,23 @@
"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]
+ "root": {"pos": [172, 114], "yaw": 180, "pitch": 95},
+ "spine": [0, 0],
+ "neck": 0, "head": -65,
+ "shoulder_r": 95, "elbow_r": 90,
+ "shoulder_l": 95, "elbow_l": 90,
+ "hip_r": 60, "knee_r": 55,
+ "hip_l": 60, "knee_l": 55
},
{
"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]
+ "root": {"pos": [168, 137], "yaw": 180, "pitch": 80},
+ "spine": [0, 0],
+ "neck": 0, "head": -50,
+ "shoulder_r": 80, "elbow_r": 90,
+ "shoulder_l": 80, "elbow_l": 90,
+ "hip_r": 0, "knee_r": 0,
+ "hip_l": 0, "knee_l": 0
}
]
}
diff --git a/Exercise Library/Plank/preview.gif b/Exercise Library/Plank/preview.gif
index 2081a71..0e0cb24 100644
Binary files a/Exercise Library/Plank/preview.gif and b/Exercise Library/Plank/preview.gif differ
diff --git a/Exercise Library/Plank/visual.svg b/Exercise Library/Plank/visual.svg
index 566d799..444f09f 100644
--- a/Exercise Library/Plank/visual.svg
+++ b/Exercise Library/Plank/visual.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Reverse Crunch/frames/frame-1.svg b/Exercise Library/Reverse Crunch/frames/frame-1.svg
index 7353699..7f24f98 100644
--- a/Exercise Library/Reverse Crunch/frames/frame-1.svg
+++ b/Exercise Library/Reverse Crunch/frames/frame-1.svg
@@ -2,15 +2,15 @@
Reverse Crunch
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Reverse Crunch/frames/frame-2.svg b/Exercise Library/Reverse Crunch/frames/frame-2.svg
index 63351ff..196da24 100644
--- a/Exercise Library/Reverse Crunch/frames/frame-2.svg
+++ b/Exercise Library/Reverse Crunch/frames/frame-2.svg
@@ -2,15 +2,15 @@
Reverse Crunch
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Reverse Crunch/motion.json b/Exercise Library/Reverse Crunch/motion.json
index 7be8997..202c570 100644
--- a/Exercise Library/Reverse Crunch/motion.json
+++ b/Exercise Library/Reverse Crunch/motion.json
@@ -6,17 +6,21 @@
"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]
+ "root": {"pos": [172, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_l": -2, "elbow_l": 0,
+ "hip_r": 100, "knee_r": 120,
+ "hip_l": 100, "knee_l": 120
},
{
"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]
+ "root": {"pos": [166, 136], "pitch": -105},
+ "spine": [0, 20],
+ "neck": -7, "head": 2,
+ "shoulder_l": 3, "elbow_l": 0,
+ "hip_r": 100, "knee_r": 125,
+ "hip_l": 100, "knee_l": 125
}
]
}
diff --git a/Exercise Library/Reverse Crunch/preview.gif b/Exercise Library/Reverse Crunch/preview.gif
index e5dd457..6adb20e 100644
Binary files a/Exercise Library/Reverse Crunch/preview.gif and b/Exercise Library/Reverse Crunch/preview.gif differ
diff --git a/Exercise Library/Reverse Crunch/visual.svg b/Exercise Library/Reverse Crunch/visual.svg
index 63351ff..196da24 100644
--- a/Exercise Library/Reverse Crunch/visual.svg
+++ b/Exercise Library/Reverse Crunch/visual.svg
@@ -2,15 +2,15 @@
Reverse Crunch
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Rotary/frames/frame-1.svg b/Exercise Library/Rotary/frames/frame-1.svg
index 68bcaa4..05aff01 100644
--- a/Exercise Library/Rotary/frames/frame-1.svg
+++ b/Exercise Library/Rotary/frames/frame-1.svg
@@ -4,17 +4,18 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Rotary/frames/frame-2.svg b/Exercise Library/Rotary/frames/frame-2.svg
index 3039099..727bc95 100644
--- a/Exercise Library/Rotary/frames/frame-2.svg
+++ b/Exercise Library/Rotary/frames/frame-2.svg
@@ -4,17 +4,18 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Rotary/motion.json b/Exercise Library/Rotary/motion.json
index 1fbab42..9f2bd89 100644
--- a/Exercise Library/Rotary/motion.json
+++ b/Exercise Library/Rotary/motion.json
@@ -1,8 +1,8 @@
{
"name": "Rotary",
"primary": 1,
+ "camera": {"yaw": 90},
"working": ["spine"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[138, 130], [182, 130]], "w": 8},
@@ -14,19 +14,23 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [160, 122],
- "spine": [104, 109], "neck": 109,
- "arm_r": [-50, -85], "arm_l": [-55, -90],
- "leg_r": [-62, -82], "leg_l": [-118, -98],
- "pins": {"hand_r": [112, 72], "hand_l": [118, 74], "foot_r": [190, 150], "foot_l": [130, 150]}
+ "root": {"pos": [160, 122]},
+ "spine": [{"rotation": -28}, {"rotation": -22}],
+ "neck": {"rotation": 18},
+ "shoulder_r": 70, "elbow_r": 10,
+ "shoulder_l": 70, "elbow_l": 10,
+ "hip_r": {"flexion": 82, "abduction": 16}, "knee_r": 70,
+ "hip_l": {"flexion": 82, "abduction": 16}, "knee_l": 70
},
{
"hold": 0.4, "tween": 1.0,
- "root": [160, 122],
- "spine": [76, 71], "neck": 71,
- "arm_r": [-125, -95], "arm_l": [-130, -90],
- "leg_r": [-62, -82], "leg_l": [-118, -98],
- "pins": {"hand_r": [208, 72], "hand_l": [202, 74], "foot_r": [190, 150], "foot_l": [130, 150]}
+ "root": {"pos": [160, 122]},
+ "spine": [{"rotation": 28}, {"rotation": 22}],
+ "neck": {"rotation": -18},
+ "shoulder_r": 70, "elbow_r": 10,
+ "shoulder_l": 70, "elbow_l": 10,
+ "hip_r": {"flexion": 82, "abduction": 16}, "knee_r": 70,
+ "hip_l": {"flexion": 82, "abduction": 16}, "knee_l": 70
}
]
}
diff --git a/Exercise Library/Rotary/preview.gif b/Exercise Library/Rotary/preview.gif
index 0482f89..2a1337a 100644
Binary files a/Exercise Library/Rotary/preview.gif and b/Exercise Library/Rotary/preview.gif differ
diff --git a/Exercise Library/Rotary/visual.svg b/Exercise Library/Rotary/visual.svg
index 68bcaa4..05aff01 100644
--- a/Exercise Library/Rotary/visual.svg
+++ b/Exercise Library/Rotary/visual.svg
@@ -4,17 +4,18 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/SYSTEM.md b/Exercise Library/SYSTEM.md
index d1899bb..301168f 100644
--- a/Exercise Library/SYSTEM.md
+++ b/Exercise Library/SYSTEM.md
@@ -1,64 +1,112 @@
# 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.
+Exercise visuals are produced by an **anatomical 3D rig**: one shared skeleton
+posed per exercise by real joint angles, projected orthographically onto the
+canvas. Nothing is drawn by hand — a skeleton profile plus a motion script
+resolve through 3D forward kinematics into every frame, so figures are always
+in proportion and anatomically plausible, and the whole library can be
+re-proportioned (male/female), viewed from any side, orbited while animating,
+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. `frontal` shortens
- the legs for face-on seated machines (thighs point mostly at the viewer, so
- they render foreshortened); a motion opts in with a top-level
- `"figure": "frontal"`.
-- **`/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.
+Model space follows the biomechanics (ISB) convention: **X anterior** (the
+figure's facing direction), **Y up**, **Z toward the figure's anatomical
+right**. Every joint angle is measured in degrees from the neutral standing
+pose — upright, arms hanging, legs straight, toes forward. The math lives in
+`kinematics.py`; the conventions are:
+
+- **flexion** — forward positive, everywhere: shoulder flexion raises the arm
+ forward/overhead (0 hanging, 90 horizontal, 180 overhead — normalized
+ flexion-dominant, so an arm past vertical is 190, not extension −170),
+ hip flexion raises the thigh, elbow/knee flexion bends the hinge (knees
+ hinge backward automatically), spine/neck flexion curls forward, negative
+ is extension. Ankle flexion is dorsiflexion (toes up), negative points them.
+- **abduction** — away from the midline positive, for shoulders and hips.
+- **rotation** — external (lateral) positive for shoulders and hips; for the
+ spine and neck, turning to the right positive.
+- A bare number is shorthand for `{"flexion": n}`.
+
+**`skeleton.json`** holds the bone-length **profiles** — `neutral`, `female`,
+`male`: head, neck, two spine segments, arms, legs, plus `foot`,
+`shoulderHalf`/`hipHalf` (real shoulder and pelvis half-widths) and
+`farOffset` (below) — and each joint type's degrees of freedom with its
+physiological **range of motion (ROM)**. Rendering validates every key frame
+against the ROM and prints a warning count (`--strict` lists and fails):
+an impossible pose is a data bug, caught mechanically. Because motions are
+authored in anatomical coordinates against joint names, **swapping profiles
+never touches a motion script** — proportions are the skeleton's problem.
+
+**`/motion.json`** — the exercise script:
```json
{
"name": "Bird Dog",
- "primary": 2, // 1-based frame used for visual.svg
- "figure": "frontal", // optional profile override (default: neutral)
- "working": ["arm_r", "leg_l"], // parts drawn in the accent color
- "hide": [], // limbs fully occluded in this view
+ "primary": 2, // 1-based frame used for visual.svg
+ "camera": {"yaw": 0}, // 0 = side view (default), 90 = face-on
+ "working": ["arm_l", "leg_r"], // parts drawn in the accent color
+ "hide": [], // limbs occluded by convention, not drawn
"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]}
+ "hold": 0.5, "tween": 0.8, // seconds held / animating to the next frame
+ "root": {"pos": [190, 106], // pelvis canvas anchor
+ "yaw": 180, // trunk orientation: facing (0 = canvas-right)
+ "pitch": 81}, // forward bow; "roll": side-lean (both optional)
+ "spine": [0, 0], // two chained segments: flexion, or a dict
+ // {"flexion", "lateral", "rotation"}
+ "neck": 16, "head": -72, // neck flexion (+rotation); head = extra gaze pitch
+ "shoulder_l": 190, "elbow_l": 0, // flexion shorthand, or
+ "hip_r": {"flexion": -24}, // {"flexion", "abduction", "rotation"}
+ "knee_r": 0, "ankle_r": -25, // hinge flexion; ankle = dorsiflexion
+ "pins": {"hand_r": [111, 154]} // IK: this hand holds that canvas point
}
]
}
```
-- **Pins (IK)** — a planted hand/foot names a target point
+- **Pins (IK)** — a planted hand/foot names a canvas target
(`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).
-- **Face-on figures** — `gaze` is optional: a frame without it faces the
- viewer and draws no nose tick. Used by exercises whose motion is lateral
- (abductor/adductor), where a side view would hide the movement.
+ chain analytically in 3D — in the plane picked by the authored elbow/knee —
+ so the extremity holds that point exactly, and writes the solution back as
+ anatomical angles. A pin active in two consecutive key frames stays planted
+ *throughout the tween*; a pin present in only one frame releases naturally.
+- **Tweening** happens in anatomical angle space, so limbs swing in natural
+ arcs, bone lengths never distort, and interpolated poses stay plausible.
+ 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 camera** is orthographic and per-exercise: `yaw` 0 is the classic
+ side view, 90 views the figure face-on — real foreshortening, not faked
+ proportions. Face-on machines (abductor/adductor, rotary torso) author
+ genuine abduction or spine rotation and let projection do the rest. The
+ camera can also orbit while the motion loops (`--orbit`).
+- **Feet** — each leg ends in a foot bone off the ankle. Dorsiflexion 0 keeps
+ the foot perpendicular to the shin (right for standing, seated machines,
+ planks); kneeling poses trail it back (−55), raises point it.
+- **The nose tick** rides the head's anterior axis: it foreshortens with the
+ view and disappears when the face points at (or away from) the camera, so
+ face-on figures need no special casing. `head` adds gaze pitch on top of
+ the neck.
+
+## The visual language
+
+- **Near vs far** — the rule that never bends: within each limb pair the
+ member **nearer the camera** draws dark (`#3a3f4b`) and in front, the far
+ member light (`#a9afba`) and behind. Working limbs keep the split: near =
+ teal `#0d9488`, far = light teal `#86cfc5`. Opposite-limb moves (bird dog,
+ dead bug) read as visibly opposite: one dark-teal limb, one light-teal
+ limb. When a view leaves depths tied (face-on machines), the canvas-right
+ member takes the dark ink. Draw order is by camera depth (far parts first),
+ so a twist genuinely passes the near arm in front of the chest; the head
+ paints last, filled opaque, so overhead arms pass behind the face.
+- **The far offset** — in profile views both members of a pair project onto
+ the same line; the far member is nudged by the profile's `farOffset` so it
+ stays distinguishable. The nudge scales with how side-on the view is and
+ vanishes face-on, where the skeleton's real shoulder/pelvis widths take
+ over — one continuous rule across the whole orbit.
+- **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
+ occluded by convention in this view and not drawn.
## The props layer
@@ -88,7 +136,8 @@ scale drawings of the machine.
- **`scene`** — static shapes in canvas coordinates: `line` (polyline, stroke
width `w`), `circle` (`fill: false` for an outline), `rect` (filled, corner
radius `r`). A shape may set `"color": "prop"` to use the darker
- attached-item gray (e.g. a fixed handle the hands rest on).
+ attached-item gray (e.g. a fixed handle the hands rest on). Scene shapes
+ are view-locked billboards — they don't rotate with `--orbit`.
- **`cable`** — a thin line from a fixed anchor `from` to a moving joint `to`;
the machine's pulley line.
- **`bar` / `dumbbell` / `pad`** — a segment centered on the joint(s) in
@@ -101,29 +150,6 @@ scale drawings of the machine.
default to perpendicular to the lower bone (forearm/shin), or take an
explicit `angle`. `plateR` puts filled discs on both ends (dumbbells default
to 4.5). A prop whose limb is hidden that frame simply isn't drawn.
-- 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`. Opposite-limb moves
- (bird dog, dead bug) read as visibly opposite: one dark-teal limb, one
- light-teal limb. `render.py` reference renders embed a small `R —` / `L —`
- legend for the rig author; the in-app renderer deliberately omits it — the
- labels are only anatomically true for right-facing figures, and the
- contrast alone carries the meaning.
-- **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
@@ -132,20 +158,25 @@ cd "Exercise Library"
python3 render.py # all exercises: frames/*.svg, preview.gif, visual.svg
python3 render.py "Bird Dog" # one exercise
python3 render.py --sheet # + contact-sheet.png of every key frame
-python3 render.py --demo # + demo-sheet.png: profile / flip / theme variants
-python3 render.py --figure=female # render with another body profile
-python3 render.py --flip # mirror the figure (faces the other way)
-python3 render.py --export # copy body.json + .motion.json + .info.md app resources
+python3 render.py --demo # + demo-sheet.png: profiles / flipped camera / theme
+python3 render.py --orbit "Bird Dog" # orbit.gif: camera sweeps 360° while looping
+python3 render.py --figure=female # render with another skeleton profile
+python3 render.py --flip # view from the other side (camera + 180°)
+python3 render.py --strict # fail on any ROM violation, listing each
+python3 render.py --export # bake app resources into Workouts/Resources/ExerciseMotions
```
`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
+the `--export` copies ship: `skeleton.json` plus uniquely-named
`.motion.json` and `.info.md` files in
-`Workouts/Resources/ExerciseMotions/`, consumed by the in-app SwiftUI renderer
-(`Workouts/ExerciseFigure/`) and the exercise-library reference screen
-(`ExerciseInfo.swift` parses the info pages). Re-run
-`python3 render.py --export` after editing any motion or info page; the
-library stays the source of truth.
+`Workouts/Resources/ExerciseMotions/`, consumed by the in-app renderer
+(`Workouts/ExerciseFigure/`, compiled into both the iOS and watch targets)
+and the exercise-library reference screen (`ExerciseInfo.swift` parses the
+info pages). The in-app solver is a line-for-line port of `kinematics.py`,
+held to it by `WorkoutsTests/Fixtures/figure-fixtures.json` — projected
+geometry snapshots the Swift solver must reproduce; regenerate them alongside
+any pipeline change. Re-run `python3 render.py --export` after editing any
+motion or info page; the library stays the source of truth.
diff --git a/Exercise Library/Seated Row/frames/frame-1.svg b/Exercise Library/Seated Row/frames/frame-1.svg
index 6d59643..2d85c7c 100644
--- a/Exercise Library/Seated Row/frames/frame-1.svg
+++ b/Exercise Library/Seated Row/frames/frame-1.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Seated Row/frames/frame-2.svg b/Exercise Library/Seated Row/frames/frame-2.svg
index b017cad..cae5a31 100644
--- a/Exercise Library/Seated Row/frames/frame-2.svg
+++ b/Exercise Library/Seated Row/frames/frame-2.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Seated Row/motion.json b/Exercise Library/Seated Row/motion.json
index 3e53395..dfeae9c 100644
--- a/Exercise Library/Seated Row/motion.json
+++ b/Exercise Library/Seated Row/motion.json
@@ -2,7 +2,6 @@
"name": "Seated Row",
"primary": 2,
"working": ["arm_r", "arm_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[122, 132], [156, 132]], "w": 8},
@@ -15,21 +14,25 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [140, 122],
- "spine": [85, 82], "neck": 80, "gaze": 5,
- "arm_r": [-40, -8], "arm_l": [-40, -8],
- "leg_r": [-8, -75], "leg_l": [-8, -75],
- "pins": {"hand_r": [200, 62], "hand_l": [204, 64],
- "foot_r": [188, 148], "foot_l": [192, 149]}
+ "root": {"pos": [140, 122], "pitch": 5},
+ "spine": [0, 3],
+ "neck": 2, "head": -15,
+ "shoulder_r": 58, "elbow_r": 32,
+ "shoulder_l": 58, "elbow_l": 32,
+ "hip_r": 87, "knee_r": 67,
+ "hip_l": 87, "knee_l": 67,
+ "pins": {"hand_r": [200, 62], "hand_l": [204, 64], "foot_r": [188, 148], "foot_l": [192, 149]}
},
{
"hold": 0.4, "tween": 1.2,
- "root": [140, 122],
- "spine": [85, 82], "neck": 80, "gaze": 5,
- "arm_r": [-120, -35], "arm_l": [-120, -35],
- "leg_r": [-8, -75], "leg_l": [-8, -75],
- "pins": {"hand_r": [158, 72], "hand_l": [162, 74],
- "foot_r": [188, 148], "foot_l": [192, 149]}
+ "root": {"pos": [140, 122], "pitch": 5},
+ "spine": [0, 3],
+ "neck": 2, "head": -15,
+ "shoulder_r": -22, "elbow_r": 85,
+ "shoulder_l": -22, "elbow_l": 85,
+ "hip_r": 87, "knee_r": 67,
+ "hip_l": 87, "knee_l": 67,
+ "pins": {"hand_r": [158, 72], "hand_l": [162, 74], "foot_r": [188, 148], "foot_l": [192, 149]}
}
]
}
diff --git a/Exercise Library/Seated Row/preview.gif b/Exercise Library/Seated Row/preview.gif
index ee9a5ec..f3d0d92 100644
Binary files a/Exercise Library/Seated Row/preview.gif and b/Exercise Library/Seated Row/preview.gif differ
diff --git a/Exercise Library/Seated Row/visual.svg b/Exercise Library/Seated Row/visual.svg
index b017cad..cae5a31 100644
--- a/Exercise Library/Seated Row/visual.svg
+++ b/Exercise Library/Seated Row/visual.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Shoulder Press/frames/frame-1.svg b/Exercise Library/Shoulder Press/frames/frame-1.svg
index 7afd240..1743e62 100644
--- a/Exercise Library/Shoulder Press/frames/frame-1.svg
+++ b/Exercise Library/Shoulder Press/frames/frame-1.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Shoulder Press/frames/frame-2.svg b/Exercise Library/Shoulder Press/frames/frame-2.svg
index 5db76f8..2042d20 100644
--- a/Exercise Library/Shoulder Press/frames/frame-2.svg
+++ b/Exercise Library/Shoulder Press/frames/frame-2.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Shoulder Press/motion.json b/Exercise Library/Shoulder Press/motion.json
index 2502f62..17bf2c6 100644
--- a/Exercise Library/Shoulder Press/motion.json
+++ b/Exercise Library/Shoulder Press/motion.json
@@ -2,7 +2,6 @@
"name": "Shoulder Press",
"primary": 2,
"working": ["arm_r", "arm_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[116, 140], [88, 50]], "w": 8},
@@ -15,18 +14,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.1,
- "root": [120, 130],
- "spine": [110, 104], "neck": 80, "gaze": 8,
- "arm_r": [-60, 90], "arm_l": [-60, 90],
- "leg_r": [-25, -72], "leg_l": [-25, -72],
+ "root": {"pos": [120, 130], "pitch": -20},
+ "spine": [0, 6],
+ "neck": 24, "head": -18,
+ "shoulder_r": 16, "elbow_r": 150,
+ "shoulder_l": 16, "elbow_l": 150,
+ "hip_r": 45, "knee_r": 47,
+ "hip_l": 45, "knee_l": 47,
"pins": {"foot_r": [164, 151], "foot_l": [168, 152]}
},
{
"hold": 0.4, "tween": 1.1,
- "root": [120, 130],
- "spine": [110, 104], "neck": 80, "gaze": 8,
- "arm_r": [54, 36], "arm_l": [54, 36],
- "leg_r": [-25, -72], "leg_l": [-25, -72],
+ "root": {"pos": [120, 130], "pitch": -20},
+ "spine": [0, 6],
+ "neck": 24, "head": -18,
+ "shoulder_r": 130, "elbow_r": -18,
+ "shoulder_l": 130, "elbow_l": -18,
+ "hip_r": 45, "knee_r": 47,
+ "hip_l": 45, "knee_l": 47,
"pins": {"foot_r": [164, 151], "foot_l": [168, 152]}
}
]
diff --git a/Exercise Library/Shoulder Press/preview.gif b/Exercise Library/Shoulder Press/preview.gif
index a4dada7..d237297 100644
Binary files a/Exercise Library/Shoulder Press/preview.gif and b/Exercise Library/Shoulder Press/preview.gif differ
diff --git a/Exercise Library/Shoulder Press/visual.svg b/Exercise Library/Shoulder Press/visual.svg
index 5db76f8..2042d20 100644
--- a/Exercise Library/Shoulder Press/visual.svg
+++ b/Exercise Library/Shoulder Press/visual.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Side Plank/frames/frame-1.svg b/Exercise Library/Side Plank/frames/frame-1.svg
index 37757ef..4ad0cd1 100644
--- a/Exercise Library/Side Plank/frames/frame-1.svg
+++ b/Exercise Library/Side Plank/frames/frame-1.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Side Plank/frames/frame-2.svg b/Exercise Library/Side Plank/frames/frame-2.svg
index f674207..f04b0ac 100644
--- a/Exercise Library/Side Plank/frames/frame-2.svg
+++ b/Exercise Library/Side Plank/frames/frame-2.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Side Plank/motion.json b/Exercise Library/Side Plank/motion.json
index 6a2209a..0fde3ac 100644
--- a/Exercise Library/Side Plank/motion.json
+++ b/Exercise Library/Side Plank/motion.json
@@ -5,19 +5,25 @@
"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]}
+ "root": {"pos": [174, 150], "yaw": 180, "pitch": 72},
+ "spine": [0, 0],
+ "neck": 0, "head": -77,
+ "shoulder_r": 2, "elbow_r": -5,
+ "shoulder_l": 72, "elbow_l": 90,
+ "hip_r": -17, "knee_r": 0,
+ "hip_l": -17, "knee_l": 0,
+ "pins": {"hand_l": [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]}
+ "root": {"pos": [174, 137], "yaw": 180, "pitch": 80},
+ "spine": [0, 0],
+ "neck": 0, "head": -80,
+ "shoulder_r": -90, "elbow_r": 0,
+ "shoulder_l": 80, "elbow_l": 90,
+ "hip_r": 0, "knee_r": 0,
+ "hip_l": 0, "knee_l": 0,
+ "pins": {"hand_l": [60, 152]}
}
]
}
diff --git a/Exercise Library/Side Plank/preview.gif b/Exercise Library/Side Plank/preview.gif
index aeb8a29..48fa18b 100644
Binary files a/Exercise Library/Side Plank/preview.gif and b/Exercise Library/Side Plank/preview.gif differ
diff --git a/Exercise Library/Side Plank/visual.svg b/Exercise Library/Side Plank/visual.svg
index f674207..f04b0ac 100644
--- a/Exercise Library/Side Plank/visual.svg
+++ b/Exercise Library/Side Plank/visual.svg
@@ -1,17 +1,17 @@
diff --git a/Exercise Library/Tricep Press/frames/frame-1.svg b/Exercise Library/Tricep Press/frames/frame-1.svg
index db4abcc..d4f231a 100644
--- a/Exercise Library/Tricep Press/frames/frame-1.svg
+++ b/Exercise Library/Tricep Press/frames/frame-1.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Tricep Press/frames/frame-2.svg b/Exercise Library/Tricep Press/frames/frame-2.svg
index e98eb78..4dd91f6 100644
--- a/Exercise Library/Tricep Press/frames/frame-2.svg
+++ b/Exercise Library/Tricep Press/frames/frame-2.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/Tricep Press/motion.json b/Exercise Library/Tricep Press/motion.json
index 8d34971..bf77781 100644
--- a/Exercise Library/Tricep Press/motion.json
+++ b/Exercise Library/Tricep Press/motion.json
@@ -2,7 +2,6 @@
"name": "Tricep Press",
"primary": 2,
"working": ["arm_r", "arm_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[116, 134], [92, 42]], "w": 8},
@@ -15,21 +14,25 @@
"frames": [
{
"hold": 0.4, "tween": 1.1,
- "root": [118, 124],
- "spine": [100, 98], "neck": 86, "gaze": 6,
- "arm_r": [-108, 12], "arm_l": [-108, 12],
- "leg_r": [-24, -74], "leg_l": [-24, -74],
- "pins": {"hand_r": [124, 62], "hand_l": [128, 64],
- "foot_r": [156, 151], "foot_l": [160, 152]}
+ "root": {"pos": [118, 124], "pitch": -10},
+ "spine": [0, 2],
+ "neck": 12, "head": -10,
+ "shoulder_r": -26, "elbow_r": 120,
+ "shoulder_l": -26, "elbow_l": 120,
+ "hip_r": 56, "knee_r": 50,
+ "hip_l": 56, "knee_l": 50,
+ "pins": {"hand_r": [124, 62], "hand_l": [128, 64], "foot_r": [156, 151], "foot_l": [160, 152]}
},
{
"hold": 0.4, "tween": 1.1,
- "root": [118, 124],
- "spine": [100, 98], "neck": 86, "gaze": 6,
- "arm_r": [-81, -61], "arm_l": [-81, -61],
- "leg_r": [-24, -74], "leg_l": [-24, -74],
- "pins": {"hand_r": [124, 96], "hand_l": [128, 98],
- "foot_r": [156, 151], "foot_l": [160, 152]}
+ "root": {"pos": [118, 124], "pitch": -10},
+ "spine": [0, 2],
+ "neck": 12, "head": -10,
+ "shoulder_r": 1, "elbow_r": 20,
+ "shoulder_l": 1, "elbow_l": 20,
+ "hip_r": 56, "knee_r": 50,
+ "hip_l": 56, "knee_l": 50,
+ "pins": {"hand_r": [124, 96], "hand_l": [128, 98], "foot_r": [156, 151], "foot_l": [160, 152]}
}
]
}
diff --git a/Exercise Library/Tricep Press/preview.gif b/Exercise Library/Tricep Press/preview.gif
index 55d6f0c..65bf588 100644
Binary files a/Exercise Library/Tricep Press/preview.gif and b/Exercise Library/Tricep Press/preview.gif differ
diff --git a/Exercise Library/Tricep Press/visual.svg b/Exercise Library/Tricep Press/visual.svg
index e98eb78..4dd91f6 100644
--- a/Exercise Library/Tricep Press/visual.svg
+++ b/Exercise Library/Tricep Press/visual.svg
@@ -6,17 +6,17 @@
-
+
-
+
-
- R
-
- L
+
+ near
+
+ far
diff --git a/Exercise Library/body.json b/Exercise Library/body.json
deleted file mode 100644
index 87a8fe9..0000000
--- a/Exercise Library/body.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "neutral": {
- "headR": 10,
- "neck": 20,
- "spine1": 43,
- "spine2": 42,
- "upperArm": 30,
- "foreArm": 30,
- "thigh": 46,
- "shin": 40,
- "leftOffset": [6, 2]
- },
- "frontal": {
- "headR": 10,
- "neck": 20,
- "spine1": 43,
- "spine2": 42,
- "upperArm": 30,
- "foreArm": 30,
- "thigh": 22,
- "shin": 26,
- "leftOffset": [6, 2]
- },
- "female": {
- "headR": 9,
- "neck": 19,
- "spine1": 40,
- "spine2": 39,
- "upperArm": 27,
- "foreArm": 27,
- "thigh": 47,
- "shin": 41,
- "leftOffset": [6, 2]
- }
-}
diff --git a/Exercise Library/contact-sheet.png b/Exercise Library/contact-sheet.png
index f2f3838..357487c 100644
Binary files a/Exercise Library/contact-sheet.png and b/Exercise Library/contact-sheet.png differ
diff --git a/Exercise Library/demo-sheet.png b/Exercise Library/demo-sheet.png
index f557c8e..d149962 100644
Binary files a/Exercise Library/demo-sheet.png and b/Exercise Library/demo-sheet.png differ
diff --git a/Exercise Library/kinematics.py b/Exercise Library/kinematics.py
new file mode 100644
index 0000000..d265092
--- /dev/null
+++ b/Exercise Library/kinematics.py
@@ -0,0 +1,381 @@
+"""3D anatomical kinematics for the Exercise Library stick figure.
+
+Model space follows the ISB convention: X anterior (the figure's facing
+direction), Y superior (up), Z toward the figure's anatomical right.
+All angles are degrees, measured from the neutral standing pose (upright,
+facing +X, arms hanging, legs straight, toes forward).
+
+A key frame poses joints with anatomical coordinates:
+ - root: canvas anchor `pos` plus trunk orientation `yaw` (facing: 0 = +X,
+ 180 = -X), `pitch` (forward bow positive), `roll` (toward the right
+ positive) - applied as Ry(yaw) . Rz(-pitch) . Rx(roll).
+ - spine: two chained segments, each {flexion, lateral, rotation}
+ (forward curl / right side-bend / turn right positive).
+ - neck {flexion, rotation}, head {flexion} (extra gaze pitch).
+ - shoulder/hip {flexion, abduction, rotation}: forward, away from the
+ midline, external positive. elbow/knee {flexion}: bend positive.
+ ankle {flexion}: dorsiflexion positive (toes up).
+A bare number is shorthand for {"flexion": n}.
+
+The camera is orthographic and rotates about the vertical axis through the
+root anchor: yaw 0 is the classic side view (from +Z, anatomical right side
+near), 90 views the figure face-on. Poses resolve in *view space* (x right,
+y up, z toward the camera); the renderer maps to y-down canvas points.
+
+Pins are canvas-space IK targets for hands/feet (`hand_r`, `foot_l`, ...):
+the two-bone chain is solved analytically in 3D, in the plane picked by the
+authored (FK) elbow/knee, then converted back to anatomical angles so key
+frames always interpolate in anatomical space.
+
+skeleton.json carries the bone-length profiles (including shoulder/pelvis
+half-widths and feet) and each joint type's degrees of freedom with their
+physiological range of motion (ROM), used to validate authored frames.
+"""
+
+import json
+import math
+from pathlib import Path
+
+LIB = Path(__file__).parent
+
+# ------------------------------------------------------------ vectors / mats
+
+def _cs(deg):
+ r = math.radians(deg)
+ return math.cos(r), math.sin(r)
+
+
+def rot_x(deg):
+ c, s = _cs(deg)
+ return ((1, 0, 0), (0, c, -s), (0, s, c))
+
+
+def rot_y(deg):
+ c, s = _cs(deg)
+ return ((c, 0, s), (0, 1, 0), (-s, 0, c))
+
+
+def rot_z(deg):
+ c, s = _cs(deg)
+ return ((c, -s, 0), (s, c, 0), (0, 0, 1))
+
+
+IDENTITY = ((1, 0, 0), (0, 1, 0), (0, 0, 1))
+
+
+def mmul(a, b):
+ return tuple(tuple(sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3))
+ for i in range(3))
+
+
+def chain(*mats):
+ m = mats[0]
+ for n in mats[1:]:
+ m = mmul(m, n)
+ return m
+
+
+def mvec(m, v):
+ return tuple(m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2] for i in range(3))
+
+
+def mtrans(m):
+ return tuple(tuple(m[j][i] for j in range(3)) for i in range(3))
+
+
+def vadd(a, b):
+ return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
+
+
+def vsub(a, b):
+ return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
+
+
+def vscale(v, s):
+ return (v[0] * s, v[1] * s, v[2] * s)
+
+
+def vdot(a, b):
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+
+
+def vcross(a, b):
+ return (a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0])
+
+
+def vlen(v):
+ return math.sqrt(vdot(v, v))
+
+
+def vnorm(v):
+ d = vlen(v)
+ return vscale(v, 1 / d) if d > 1e-9 else (0.0, 0.0, 0.0)
+
+
+def _clamp(x, lo=-1.0, hi=1.0):
+ return max(lo, min(hi, x))
+
+
+# ---------------------------------------------------------------- the frame
+
+# DoF names per joint type; the first is the shorthand a bare number sets.
+JOINT_DOFS = {
+ "spine": ("flexion", "lateral", "rotation"),
+ "neck": ("flexion", "rotation"),
+ "head": ("flexion",),
+ "shoulder": ("flexion", "abduction", "rotation"),
+ "elbow": ("flexion",),
+ "hip": ("flexion", "abduction", "rotation"),
+ "knee": ("flexion",),
+ "ankle": ("flexion",),
+}
+
+# Frame keys -> joint type (spine is a two-element list handled separately).
+FRAME_JOINTS = {
+ "neck": "neck", "head": "head",
+ "shoulder_r": "shoulder", "shoulder_l": "shoulder",
+ "elbow_r": "elbow", "elbow_l": "elbow",
+ "hip_r": "hip", "hip_l": "hip",
+ "knee_r": "knee", "knee_l": "knee",
+ "ankle_r": "ankle", "ankle_l": "ankle",
+}
+
+LIMBS = { # limb -> (attach point key, side sign, pin key)
+ "arm_r": ("shoulder_r", 1, "hand_r"),
+ "arm_l": ("shoulder_l", -1, "hand_l"),
+ "leg_r": ("hip_r", 1, "foot_r"),
+ "leg_l": ("hip_l", -1, "foot_l"),
+}
+
+
+def load_skeleton():
+ return json.loads((LIB / "skeleton.json").read_text())
+
+
+def _full(value, joint_type):
+ """Expand a joint value (number, partial dict, or None) to a full DoF dict."""
+ dofs = JOINT_DOFS[joint_type]
+ if value is None:
+ return {d: 0.0 for d in dofs}
+ if isinstance(value, (int, float)):
+ out = {d: 0.0 for d in dofs}
+ out[dofs[0]] = float(value)
+ return out
+ return {d: float(value.get(d, 0.0)) for d in dofs}
+
+
+def normalize_frame(kf):
+ """Expand a key frame to full anatomical dicts with defaults filled in."""
+ root = kf.get("root", {})
+ out = {
+ "root": {"pos": [float(root["pos"][0]), float(root["pos"][1])],
+ "yaw": float(root.get("yaw", 0.0)),
+ "pitch": float(root.get("pitch", 0.0)),
+ "roll": float(root.get("roll", 0.0))},
+ "spine": [_full(s, "spine") for s in (kf.get("spine") or [0, 0])],
+ "pins": {k: [float(x), float(y)] for k, (x, y) in kf.get("pins", {}).items()},
+ "hold": float(kf.get("hold", 0.5)),
+ "tween": float(kf.get("tween", 0.6)),
+ }
+ for key, jt in FRAME_JOINTS.items():
+ out[key] = _full(kf.get(key), jt)
+ return out
+
+
+def lerp_frames(a, b, t):
+ """Interpolate two normalized frames in anatomical space. A pin survives
+ the tween only when planted in both neighboring key frames."""
+ def num(x, y):
+ return x + (y - x) * t
+ out = {"root": {"pos": [num(a["root"]["pos"][0], b["root"]["pos"][0]),
+ num(a["root"]["pos"][1], b["root"]["pos"][1])],
+ "yaw": num(a["root"]["yaw"], b["root"]["yaw"]),
+ "pitch": num(a["root"]["pitch"], b["root"]["pitch"]),
+ "roll": num(a["root"]["roll"], b["root"]["roll"])},
+ "spine": [{d: num(sa[d], sb[d]) for d in sa}
+ for sa, sb in zip(a["spine"], b["spine"])],
+ "pins": {k: [num(a["pins"][k][0], b["pins"][k][0]),
+ num(a["pins"][k][1], b["pins"][k][1])]
+ for k in a["pins"] if k in b["pins"]}}
+ for key in FRAME_JOINTS:
+ out[key] = {d: num(a[key][d], b[key][d]) for d in a[key]}
+ return out
+
+
+# ------------------------------------------------------------------ posing
+
+def _ball(joint, sigma):
+ """Local rotation of a ball joint (shoulder/hip) for side sign sigma
+ (+1 right, -1 left): flexion forward, abduction away from the midline,
+ rotation external."""
+ return chain(rot_z(joint["flexion"]),
+ rot_x(-sigma * joint["abduction"]),
+ rot_y(-sigma * joint["rotation"]))
+
+
+def fk_limb(kind, attach, joint, lower, ankle, prof, parent, sigma):
+ """FK one limb from its resolved attach point. Returns the point chain
+ (arm: [shoulder, elbow, hand]; leg: [hip, knee, ankle, toe])."""
+ fu = mmul(parent, _ball(joint, sigma))
+ if kind == "arm":
+ elbow = vadd(attach, mvec(fu, (0, -prof["upperArm"], 0)))
+ fl = mmul(fu, rot_z(lower["flexion"]))
+ hand = vadd(elbow, mvec(fl, (0, -prof["foreArm"], 0)))
+ return [attach, elbow, hand]
+ knee = vadd(attach, mvec(fu, (0, -prof["thigh"], 0)))
+ fl = mmul(fu, rot_z(-lower["flexion"]))
+ ank = vadd(knee, mvec(fl, (0, -prof["shin"], 0)))
+ toe = vadd(ank, mvec(mmul(fl, rot_z(ankle["flexion"])), (prof["foot"], 0, 0)))
+ return [attach, knee, ank, toe]
+
+
+def pose(nf, prof, cam_yaw):
+ """FK a normalized frame into view space (x right, y up, z toward the
+ camera; origin at the root anchor). Returns points, parent frames (for
+ IK inversion), the nose direction, and the lateral depth factor k."""
+ r = nf["root"]
+ f_root = chain(rot_y(-cam_yaw), rot_y(r["yaw"]), rot_z(-r["pitch"]), rot_x(r["roll"]))
+ origin = (0.0, 0.0, 0.0)
+
+ s1, s2 = nf["spine"]
+ f1 = chain(f_root, rot_z(-s1["flexion"]), rot_x(s1["lateral"]), rot_y(-s1["rotation"]))
+ mid = vadd(origin, mvec(f1, (0, prof["spine1"], 0)))
+ f2 = chain(f1, rot_z(-s2["flexion"]), rot_x(s2["lateral"]), rot_y(-s2["rotation"]))
+ neck_base = vadd(mid, mvec(f2, (0, prof["spine2"], 0)))
+
+ fn = chain(f2, rot_z(-nf["neck"]["flexion"]), rot_y(-nf["neck"]["rotation"]))
+ head = vadd(neck_base, mvec(fn, (0, prof["neck"], 0)))
+ nose_dir = mvec(mmul(fn, rot_z(-nf["head"]["flexion"])), (1, 0, 0))
+
+ points = {
+ "pelvis": origin, "mid": mid, "neckB": neck_base, "head": head,
+ "shoulder_r": vadd(neck_base, mvec(f2, (0, 0, prof["shoulderHalf"]))),
+ "shoulder_l": vadd(neck_base, mvec(f2, (0, 0, -prof["shoulderHalf"]))),
+ "hip_r": vadd(origin, mvec(f_root, (0, 0, prof["hipHalf"]))),
+ "hip_l": vadd(origin, mvec(f_root, (0, 0, -prof["hipHalf"]))),
+ }
+ for limb, (attach, sigma, _pin) in LIMBS.items():
+ kind = "arm" if limb.startswith("arm") else "leg"
+ side = limb[-1]
+ parent = f2 if kind == "arm" else f_root
+ upper = nf[("shoulder_" if kind == "arm" else "hip_") + side]
+ lower = nf[("elbow_" if kind == "arm" else "knee_") + side]
+ ankle = nf.get("ankle_" + side)
+ points[limb] = fk_limb(kind, points[attach], upper, lower, ankle, prof, parent, sigma)
+
+ # How side-on the view is: 1 when the body's lateral axis is pure depth
+ # (profile view), 0 when it lies in the screen (face-on).
+ k = abs(mvec(f_root, (0, 0, 1))[2])
+ return {"points": points, "f2": f2, "f_root": f_root,
+ "nose_dir": nose_dir, "k": k}
+
+
+# ---------------------------------------------------------------------- IK
+
+def solve_limb(kind, attach, target, guess_mid, lengths, parent, sigma):
+ """Analytic two-bone IK in 3D: reach from `attach` toward `target` in the
+ plane picked by the authored (FK) mid joint, then convert back to
+ anatomical angles. Returns (upper joint dict, lower joint dict)."""
+ a, b = lengths
+ to_t = vsub(target, attach)
+ d = _clamp(vlen(to_t), abs(a - b) + 0.5, a + b - 0.01)
+ dir_t = vnorm(to_t) if vlen(to_t) > 1e-9 else (0.0, -1.0, 0.0)
+
+ normal = vcross(dir_t, vsub(guess_mid, attach))
+ if vlen(normal) < 1e-6: # chain straight along the target: any plane works
+ normal = vcross(dir_t, (0, 0, 1))
+ if vlen(normal) < 1e-6:
+ normal = vcross(dir_t, (0, 1, 0))
+ normal = vnorm(normal)
+ perp = vcross(normal, dir_t)
+
+ along = (a * a + d * d - b * b) / (2 * d)
+ h = math.sqrt(max(a * a - along * along, 0.0))
+ best = None
+ for sign in (1.0, -1.0):
+ mid = vadd(attach, vadd(vscale(dir_t, along), vscale(perp, sign * h)))
+ dist = vlen(vsub(mid, guess_mid))
+ if best is None or dist < best[0]:
+ best = (dist, mid)
+ mid = best[1]
+ end = vadd(mid, vscale(vnorm(vsub(target, mid)), b))
+ return invert_limb(kind, attach, mid, end, lengths, parent, sigma)
+
+
+def invert_limb(kind, attach, mid, end, lengths, parent, sigma):
+ """Recover anatomical angles from limb joint positions (the inverse of
+ fk_limb, ignoring the foot). Assumes |abduction| < 90."""
+ pt = mtrans(parent)
+ u = vnorm(mvec(pt, vsub(mid, attach)))
+ abd = math.degrees(math.asin(_clamp(sigma * u[2])))
+ flex = math.degrees(math.atan2(u[0], -u[1]))
+ # Peel flexion/abduction off the lower bone to read rotation + hinge bend.
+ peel = mtrans(chain(rot_z(flex), rot_x(-sigma * abd)))
+ w = vnorm(mvec(peel, mvec(pt, vsub(end, mid))))
+ bend = math.degrees(math.acos(_clamp(-w[1])))
+ rot = sigma * math.degrees(math.atan2(w[2], w[0])) if bend > 0.5 else 0.0
+ if kind == "leg": # knees hinge backward; the sign convention flips
+ rot = sigma * math.degrees(math.atan2(-w[2], -w[0])) if bend > 0.5 else 0.0
+ upper = {"flexion": flex, "abduction": abd, "rotation": rot}
+ return upper, {"flexion": bend}
+
+
+# ------------------------------------------------------------------ resolve
+
+def view_from_canvas(pt, anchor, depth):
+ return (pt[0] - anchor[0], anchor[1] - pt[1], depth)
+
+
+def resolve(nf, prof, cam_yaw):
+ """Pose a normalized frame and apply pins: for each pinned limb, solve IK
+ against the canvas target (at the limb's FK depth), write the solved
+ anatomical angles back into the frame, and re-pose. Returns
+ (frame with IK-resolved angles, pose dict)."""
+ p = pose(nf, prof, cam_yaw)
+ anchor = nf["root"]["pos"]
+ solved = False
+ for limb, (attach_key, sigma, pin) in LIMBS.items():
+ if pin not in nf["pins"]:
+ continue
+ kind = "arm" if limb.startswith("arm") else "leg"
+ side = limb[-1]
+ chain_pts = p["points"][limb]
+ attach = chain_pts[0]
+ end_idx = 2
+ target = view_from_canvas(nf["pins"][pin], anchor, chain_pts[end_idx][2])
+ lengths = ((prof["upperArm"], prof["foreArm"]) if kind == "arm"
+ else (prof["thigh"], prof["shin"]))
+ parent = p["f2"] if kind == "arm" else p["f_root"]
+ upper, lower = solve_limb(kind, attach, target, chain_pts[1],
+ lengths, parent, sigma)
+ if kind == "arm":
+ nf["shoulder_" + side], nf["elbow_" + side] = upper, lower
+ else:
+ nf["hip_" + side], nf["knee_" + side] = upper, lower
+ solved = True
+ if solved:
+ p = pose(nf, prof, cam_yaw)
+ return nf, p
+
+
+# -------------------------------------------------------------------- ROM
+
+def validate_rom(nf, joints, label=""):
+ """Check a normalized frame's anatomical angles against each joint's
+ range of motion. Returns a list of human-readable violations."""
+ issues = []
+
+ def check(joint_type, name, value):
+ for dof, angle in value.items():
+ lo_hi = joints.get(joint_type, {}).get(dof)
+ if lo_hi and not (lo_hi[0] - 1e-6 <= angle <= lo_hi[1] + 1e-6):
+ issues.append(f"{label}{name}.{dof} = {angle:.1f} outside "
+ f"[{lo_hi[0]}, {lo_hi[1]}]")
+
+ for i, seg in enumerate(nf["spine"], start=1):
+ check("spine", f"spine{i}", seg)
+ for key, jt in FRAME_JOINTS.items():
+ check(jt, key, nf[key])
+ return issues
diff --git a/Exercise Library/render.py b/Exercise Library/render.py
index 187ecde..2684075 100644
--- a/Exercise Library/render.py
+++ b/Exercise Library/render.py
@@ -1,28 +1,31 @@
#!/usr/bin/env python3
-"""Render Exercise Library visuals from an articulated 2D rig.
+"""Render Exercise Library visuals from an anatomical 3D 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.
+A skeleton profile (skeleton.json: bone lengths incl. shoulder/pelvis widths
+and feet, plus per-joint ROM) and a per-exercise motion script (motion.json:
+key frames of anatomical joint angles - flexion/abduction/rotation measured
+from neutral standing - a root anchor + trunk orientation, optional IK pins,
+timing, and a camera) resolve through 3D forward kinematics and orthographic
+projection into stick-figure frames. See SYSTEM.md for the format and the
+visual language, kinematics.py for the math and conventions.
Outputs per exercise: frames/frame-N.svg, preview.gif (tweened, looping),
visual.svg (the primary frame). `--sheet` writes contact-sheet.png of every
-key frame; `--demo` writes demo-sheet.png showing rig customizations (figure
-profile, flip, theme). `--export` instead copies body.json and each entry's
-motion.json (as `.motion.json`) into Workouts/Resources/ExerciseMotions
-for the in-app SwiftUI renderer. SVGs need no dependencies; GIFs/sheets need
-Pillow.
-
-Angles are absolute world angles in degrees, y-up: 0 = toward +x (right),
-90 = straight up, 180 = toward -x (left), -90 = straight down.
+key frame; `--demo` writes demo-sheet.png showing rig customizations (body
+profiles, flipped camera, theme); `--orbit` writes orbit.gif per named
+exercise (the camera sweeps 360 degrees while the motion loops). `--export`
+instead bakes each motion down to the legacy planar schema consumed by the
+in-app SwiftUI renderer. SVGs need no dependencies; GIFs/sheets need Pillow.
"""
+import copy
import json
import math
import sys
from pathlib import Path
+import kinematics as K
+
LIB = Path(__file__).parent
CANVAS = (320, 180)
GROUND_Y = 152
@@ -41,21 +44,15 @@ PALETTES = {
"equipment": "#c5cad4", "prop": "#6b7180",
},
}
-WIDTHS = {"right": 6, "left": 5, "spine": 6, "head": 6, "nose": 4}
+WIDTHS = {"near": 6, "far": 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"]
+# Draw order is by camera depth (far parts first, head always on top, filled
+# opaque so overhead arms are occluded by the face). Depths are bucketed so
+# side views stay stable; ties fall back to this fixed rank.
+FIXED_RANK = {"arm_l": 0, "leg_l": 1, "spine": 2, "arm_r": 3, "leg_r": 4}
+DEPTH_BUCKET = 3.0
-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"]
+PAIRS = (("arm_r", "arm_l"), ("leg_r", "leg_l"))
# Prop joint refs -> (limb, chain index): extremities are index 2, mid joints
# (elbows/knees) index 1, so equipment can ride either joint.
@@ -67,8 +64,8 @@ JOINT_LIMB = {
}
-def bodies():
- return json.loads((LIB / "body.json").read_text())
+def profiles():
+ return K.load_skeleton()["profiles"]
def dirv(deg):
@@ -77,145 +74,108 @@ def dirv(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."""
+ """Y-up world angle of the canvas 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()}
+def mirror_frame(nf, width):
+ """Mirror a normalized frame's canvas anchors for the flipped camera."""
+ out = copy.deepcopy(nf)
+ out["root"]["pos"][0] = width - out["root"]["pos"][0]
+ out["pins"] = {k: [width - x, y] for k, (x, y) in out["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 _chain_depth(pts):
+ return sum(p[2] for p in pts) / len(pts)
-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 _bucket(depth):
+ return round(depth / DEPTH_BUCKET)
-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]
- geo = {"head": head, "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"]]}
- # `gaze` is optional: a frame without it faces the viewer, so no nose tick.
- if "gaze" in frame:
- geo["nose"] = walk(head, [frame["gaze"]], [body["headR"] + 7])[1]
- 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
+def frame_geometry(nf, prof, cam, flipped=False):
+ """Resolve one normalized frame into drawable 2D geometry.
+ Returns (nf with IK-resolved angles and original pins, geo, order, shade):
+ geo maps parts to canvas points, order is the depth-sorted draw order,
+ shade maps each limb to "near"/"far" (near pair members draw dark and in
+ front - the visual language; canvas-right wins depth ties in face-on
+ views). The far member of each pair also gets the profile's readability
+ offset, scaled by how side-on the view is, so overlapping limbs stay
+ distinguishable in profile views.
+ """
+ p0 = K.pose(nf, prof, cam)
+ shade, order_parts = {}, []
+ for right, left in PAIRS:
+ dr, dl = _chain_depth(p0["points"][right]), _chain_depth(p0["points"][left])
+ if _bucket(dr) == _bucket(dl):
+ ax_r = p0["points"][right][0][0] # view x == canvas offset from anchor
+ near = right if ax_r >= p0["points"][left][0][0] else left
+ else:
+ near = right if dr > dl else left
+ shade[right] = "near" if near == right else "far"
+ shade[left] = "near" if near == left else "far"
+
+ fo = prof.get("farOffset", [6, 2])
+ off = ((-fo[0] if flipped else fo[0]) * p0["k"], fo[1] * p0["k"])
+
+ work = copy.deepcopy(nf)
+ for limb, (_attach, _sigma, pin) in K.LIMBS.items():
+ if shade[limb] == "far" and pin in work["pins"]:
+ work["pins"][pin] = [work["pins"][pin][0] - off[0],
+ work["pins"][pin][1] - off[1]]
+ work, p = K.resolve(work, prof, cam)
+ work["pins"] = dict(nf["pins"]) # keep authored pins; only angles resolved
+
+ anchor = nf["root"]["pos"]
+
+ def scr(v, limb_off=(0, 0)):
+ return (anchor[0] + v[0] + limb_off[0], anchor[1] - v[1] + limb_off[1])
+
+ pts = p["points"]
+ pelvis, mid, neck_b = scr(pts["pelvis"]), scr(pts["mid"]), scr(pts["neckB"])
+ geo = {"headR": prof["headR"], "head": scr(pts["head"]),
+ "spine": [pelvis,
+ (2 * mid[0] - (pelvis[0] + neck_b[0]) / 2,
+ 2 * mid[1] - (pelvis[1] + neck_b[1]) / 2),
+ neck_b]}
+ depths = {"spine": _chain_depth([pts["pelvis"], pts["mid"], pts["neckB"]])}
+ for limb in K.LIMBS:
+ limb_off = off if shade[limb] == "far" else (0, 0)
+ geo[limb] = [scr(v, limb_off) for v in pts[limb]]
+ depths[limb] = _chain_depth(pts[limb])
+
+ # The nose tick rides the head's anterior axis; it foreshortens naturally
+ # and disappears when the face points at (or away from) the camera.
+ nd = p["nose_dir"]
+ mag = math.hypot(nd[0], nd[1])
+ if mag > 0.3:
+ ux, uy = nd[0] / mag, -nd[1] / mag
+ hx, hy = geo["head"]
+ r = prof["headR"]
+ geo["nose"] = ((hx + ux * r, hy + uy * r),
+ (hx + ux * (r + 7 * mag), hy + uy * (r + 7 * mag)))
+
+ order = sorted(depths, key=lambda part: (_bucket(depths[part]),
+ FIXED_RANK[part])) + ["head"]
+ return work, geo, order, shade
-# ------------------------------------------------------------------ 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)]
+ frames += [K.lerp_frames(kf, nxt, ease(s / steps)) for s in range(1, steps)]
return frames
@@ -226,9 +186,9 @@ def timeline(norms, fps=20):
# the resolved hand/foot positions frame by frame.
def flip_props(props, width):
- """Mirror the props horizontally, matching flip_frame. Joint-attached props
- follow the mirrored limbs automatically; only fixed coordinates and world
- angles need mirroring."""
+ """Mirror the props horizontally, matching the flipped camera. Joint-
+ attached props follow the mirrored limbs automatically; only fixed
+ coordinates and world angles need mirroring."""
def fx(p):
return [width - p[0], p[1]]
out = []
@@ -351,10 +311,13 @@ def draw_prims(d, prims, colors, scale):
# ------------------------------------------------------------------- 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]
+def part_style(part, working, colors, shade):
+ """Near pair members draw in the dark ink, far members in the light one;
+ the spine is always dark. Working parts swap ink for the accent teals."""
+ tone = shade.get(part, "near")
+ key = "right" if tone == "near" else "left"
+ color = colors[f"{key}_working"] if part in working else colors[key]
+ width = WIDTHS["spine"] if part == "spine" else WIDTHS[tone]
return color, width
@@ -367,16 +330,7 @@ def quad_points(p0, ctrl, p2, n=24):
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, props=None):
+def svg_for_frame(name, geo, order, shade, working, colors, props=None):
bg, fg = resolve_props(props, geo)
w, h = CANVAS
parts = [f'')
return "\n".join(parts) + "\n"
-def draw_geo(geo, working, colors, scale=2, font=None, props=None):
+def draw_geo(geo, order, shade, working, colors, scale=2, font=None, props=None):
from PIL import Image, ImageDraw
bg, fg = resolve_props(props, geo)
@@ -433,7 +387,7 @@ def draw_geo(geo, working, colors, scale=2, font=None, props=None):
line([(16, GROUND_Y + 4), (CANVAS[0] - 16, GROUND_Y + 4)], colors["ground"], 3)
draw_prims(d, bg, colors, scale)
- for part in PART_ORDER:
+ for part in order:
if part == "head":
draw_prims(d, fg, colors, scale)
hx, hy = geo["head"]
@@ -441,21 +395,21 @@ def draw_geo(geo, working, colors, scale=2, font=None, props=None):
d.ellipse([hx * scale - r, hy * scale - r, hx * scale + r, hy * scale + r],
fill=colors["head_fill"], outline=colors["right"], width=sw)
if "nose" in geo:
- line(list(nose_segment(geo)), colors["right"], WIDTHS["nose"])
+ line(list(geo["nose"]), colors["right"], WIDTHS["nose"])
continue
if part not in geo:
continue
- color, width = part_style(part, working, colors)
+ color, width = part_style(part, working, colors, shade)
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)
+ lx = CANVAS[0] - 96
+ line([(lx, 16), (lx + 14, 16)], colors["right"], 4)
+ line([(lx + 49, 16), (lx + 63, 16)], colors["left"], 4)
if font:
- d.text((lx * scale + 22 * scale, 16 * scale - 11 * scale / 2 - 2), "R",
+ d.text((lx * scale + 19 * scale, 16 * scale - 11 * scale / 2 - 2), "near",
fill=colors["legend_text"], font=font)
- d.text((lx * scale + 62 * scale, 16 * scale - 11 * scale / 2 - 2), "L",
+ d.text((lx * scale + 68 * scale, 16 * scale - 11 * scale / 2 - 2), "far",
fill=colors["legend_text"], font=font)
return img.resize(CANVAS, Image.LANCZOS)
@@ -474,53 +428,68 @@ def load_motion(folder):
return json.loads((folder / "motion.json").read_text())
-def export_app_resources(folders):
- """Write the app's bundled copies: body.json plus one `.motion.json`
- and one `.info.md` per library entry. Unique basenames — Xcode copies
- resources flat, so the per-entry motion.json/info.md 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")
- info = folder / "info.md"
- if info.exists():
- (out / f"{motion['name']}.info.md").write_text(info.read_text())
- print(f" exported {motion['name']}.info.md")
- print(f" exported body.json -> {out}")
-
-
-def render_exercise(folder, figure="neutral", flip=False):
- motion = load_motion(folder)
- # A motion may pin its own profile (e.g. "frontal": foreshortened legs for
- # face-on seated machines); it wins over the CLI default.
- body = bodies()[motion.get("figure", figure)]
- working = set(motion.get("working", []))
- hide = set(motion.get("hide", []))
+def prepare(motion, figure="neutral", flip=False, strict=False):
+ """Load a motion into (normalized frames, profile, camera yaw, props),
+ validating each key frame against the skeleton's ROM."""
+ skel = K.load_skeleton()
+ prof = skel["profiles"][figure]
+ cam = float(motion.get("camera", {}).get("yaw", 0.0)) + (180.0 if flip else 0.0)
+ norms = [K.normalize_frame(kf) for kf in motion["frames"]]
+ issues = []
+ for i, nf in enumerate(norms, start=1):
+ issues += K.validate_rom(nf, skel["joints"], f"frame {i}: ")
+ if issues and strict:
+ print(f" {motion['name']}: ROM violations:")
+ for msg in issues:
+ print(f" {msg}")
+ sys.exit(1)
+ if issues:
+ print(f" {motion['name']}: {len(issues)} ROM warning(s) — run --strict to list")
props = motion.get("props", [])
if flip:
+ norms = [mirror_frame(nf, CANVAS[0]) for nf in norms]
props = flip_props(props, CANVAS[0])
- norms = [normalize(kf, body, flip) for kf in motion["frames"]]
- geos = [geometry(n, body, hide, flip) for n in norms]
+ return norms, prof, cam, props
+
+
+def render_exercise(folder, figure="neutral", flip=False, strict=False):
+ motion = load_motion(folder)
+ working = set(motion.get("working", []))
+ hide = set(motion.get("hide", []))
+ norms, prof, cam, props = prepare(motion, figure, flip, strict)
+
+ def geometry(nf):
+ _, geo, order, shade = frame_geometry(nf, prof, cam, flip)
+ for limb in hide:
+ geo.pop(limb, None)
+ return geo, order, shade
+
+ resolved = []
+ key_geos = []
+ for nf in norms:
+ out, geo, order, shade = frame_geometry(nf, prof, cam, flip)
+ for limb in hide:
+ geo.pop(limb, None)
+ resolved.append(out)
+ key_geos.append((geo, order, shade))
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, props) for g in geos]
+ svgs = [svg_for_frame(motion["name"], geo, order, shade, working, colors, props)
+ for geo, order, shade in key_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, props=props)
- for n in timeline(norms)]
+ imgs = []
+ for nf in timeline(resolved):
+ geo, order, shade = geometry(nf)
+ imgs.append(draw_geo(geo, order, shade, working, colors, font=font, props=props))
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")
@@ -528,43 +497,70 @@ def render_exercise(folder, figure="neutral", flip=False):
print(f" {motion['name']}: Pillow missing — SVGs written, preview.gif skipped")
+def render_orbit(folder, figure="neutral"):
+ """A full-turn demo: the camera sweeps 360 degrees while the motion loops.
+ Scene props are view-locked billboards, so orbit shines on prop-free
+ motions (bodyweight exercises)."""
+ motion = load_motion(folder)
+ working = set(motion.get("working", []))
+ hide = set(motion.get("hide", []))
+ norms, prof, cam, props = prepare(motion, figure)
+ resolved = [frame_geometry(nf, prof, cam)[0] for nf in norms]
+ font = legend_font()
+ colors = PALETTES["default"]
+ ticks = timeline(resolved)
+ imgs = []
+ for i, nf in enumerate(ticks):
+ yaw = cam + 360.0 * i / len(ticks)
+ _, geo, order, shade = frame_geometry(nf, prof, yaw)
+ for limb in hide:
+ geo.pop(limb, None)
+ imgs.append(draw_geo(geo, order, shade, working, colors, font=font, props=props))
+ imgs[0].save(folder / "orbit.gif", save_all=True, append_images=imgs[1:],
+ duration=50, loop=0)
+ print(f" {motion['name']}: orbit.gif ({len(imgs)} frames)")
+
+
def contact_sheet(folders, figure="neutral", out=None):
- from PIL import Image, ImageDraw
-
font = legend_font()
- profiles = bodies()
cells = []
for folder in folders:
motion = load_motion(folder)
- body = profiles[motion.get("figure", figure)]
working, hide = set(motion.get("working", [])), set(motion.get("hide", []))
- props = motion.get("props", [])
- 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, props=props)))
+ norms, prof, cam, props = prepare(motion, figure)
+ for i, nf in enumerate(norms, start=1):
+ _, geo, order, shade = frame_geometry(nf, prof, cam)
+ for limb in hide:
+ geo.pop(limb, None)
+ cells.append((f"{motion['name']} {i}/{len(norms)}",
+ draw_geo(geo, order, shade, working, PALETTES["default"],
+ font=font, props=props)))
save_sheet(cells, Path(out) if out else 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."""
+ """One exercise's primary frame rendered five ways — the doors the rig
+ opens: neutral / female / male profiles (same motion script, different
+ proportions), the flipped camera, an alternate theme."""
motion = load_motion(folder)
working, hide = set(motion.get("working", [])), set(motion.get("hide", []))
- kf = motion["frames"][motion.get("primary", 1) - 1]
+ idx = motion.get("primary", 1) - 1
font = legend_font()
variants = [("neutral", "neutral", False, "default"),
("female profile", "female", False, "default"),
- ("flipped", "neutral", True, "default"),
+ ("male profile", "male", False, "default"),
+ ("flipped camera", "neutral", True, "default"),
("themed (indigo)", "neutral", False, "indigo")]
cells = []
for label, figure, flip, palette in variants:
- body = bodies()[figure]
- props = flip_props(motion.get("props", []), CANVAS[0]) if flip else motion.get("props", [])
- geo = geometry(normalize(kf, body, flip), body, hide, flip)
+ norms, prof, cam, props = prepare(motion, figure, flip)
+ _, geo, order, shade = frame_geometry(norms[idx], prof, cam, flip)
+ for limb in hide:
+ geo.pop(limb, None)
cells.append((f"{motion['name']} — {label}",
- draw_geo(geo, working, PALETTES[palette], font=font, props=props)))
- save_sheet(cells, LIB / "demo-sheet.png", cols=2)
+ draw_geo(geo, order, shade, working, PALETTES[palette],
+ font=font, props=props)))
+ save_sheet(cells, LIB / "demo-sheet.png", cols=3)
def save_sheet(cells, path, cols=4):
@@ -582,6 +578,30 @@ def save_sheet(cells, path, cols=4):
print(f" {path.name} ({len(cells)} cells)")
+# ------------------------------------------------------------------- export
+
+def export_app_resources(folders):
+ """Write the app's bundled copies: skeleton.json plus one
+ `.motion.json` and one `.info.md` per library entry (unique
+ basenames — Xcode copies resources flat). The in-app solver is a port of
+ kinematics.py and consumes the same files; figure-fixtures.json in
+ WorkoutsTests holds it to this pipeline's geometry."""
+ out = LIB.parent / "Workouts" / "Resources" / "ExerciseMotions"
+ out.mkdir(parents=True, exist_ok=True)
+ (out / "skeleton.json").write_text((LIB / "skeleton.json").read_text())
+ (out / "body.json").unlink(missing_ok=True) # the legacy profile file
+ 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")
+ info = folder / "info.md"
+ if info.exists():
+ (out / f"{motion['name']}.info.md").write_text(info.read_text())
+ print(f" exported {motion['name']}.info.md")
+ print(f" exported skeleton.json -> {out}")
+
+
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("--")]
@@ -597,8 +617,13 @@ def main():
if "--export" in flags:
export_app_resources(folders)
return
+ if "--orbit" in flags:
+ for folder in folders:
+ render_orbit(folder, figure=figure)
+ return
for folder in folders:
- render_exercise(folder, figure=figure, flip="--flip" in flags)
+ render_exercise(folder, figure=figure, flip="--flip" in flags,
+ strict="--strict" in flags)
if sheet:
contact_sheet(folders, figure=figure, out=None if sheet is True else sheet)
if "--demo" in flags:
diff --git a/Exercise Library/skeleton.json b/Exercise Library/skeleton.json
new file mode 100644
index 0000000..2f23eab
--- /dev/null
+++ b/Exercise Library/skeleton.json
@@ -0,0 +1,56 @@
+{
+ "profiles": {
+ "neutral": {
+ "headR": 10,
+ "neck": 20,
+ "spine1": 43,
+ "spine2": 42,
+ "upperArm": 30,
+ "foreArm": 30,
+ "thigh": 46,
+ "shin": 40,
+ "foot": 11,
+ "shoulderHalf": 7,
+ "hipHalf": 5,
+ "farOffset": [6, 2]
+ },
+ "female": {
+ "headR": 9,
+ "neck": 19,
+ "spine1": 40,
+ "spine2": 39,
+ "upperArm": 27,
+ "foreArm": 27,
+ "thigh": 47,
+ "shin": 41,
+ "foot": 10,
+ "shoulderHalf": 5.5,
+ "hipHalf": 6.5,
+ "farOffset": [6, 2]
+ },
+ "male": {
+ "headR": 10,
+ "neck": 20,
+ "spine1": 44,
+ "spine2": 43,
+ "upperArm": 31,
+ "foreArm": 31,
+ "thigh": 47,
+ "shin": 41,
+ "foot": 11.5,
+ "shoulderHalf": 9,
+ "hipHalf": 4.5,
+ "farOffset": [6, 2]
+ }
+ },
+ "joints": {
+ "spine": {"flexion": [-40, 55], "lateral": [-35, 35], "rotation": [-55, 55]},
+ "neck": {"flexion": [-55, 55], "rotation": [-80, 80]},
+ "head": {"flexion": [-120, 120]},
+ "shoulder": {"flexion": [-70, 195], "abduction": [-40, 185], "rotation": [-95, 95]},
+ "elbow": {"flexion": [-10, 155]},
+ "hip": {"flexion": [-30, 130], "abduction": [-40, 55], "rotation": [-50, 50]},
+ "knee": {"flexion": [-10, 150]},
+ "ankle": {"flexion": [-60, 30]}
+ }
+}
diff --git a/README.md b/README.md
index 5528c35..b4ebd15 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ your own iCloud Drive.
own copy, and deleted starters can be restored from Settings.
- **Exercise library** — a bundled library of exercises to populate your splits,
authored in `Exercise Library/` with per-exercise setup, cues, mistakes,
- progressions, and a rig-animated stick-figure visual system — covering the
+ progressions, and an anatomically-rigged 3D stick-figure visual system — covering the
bodyweight core moves plus the full Planet Fitness–style machine circuit
(presses, rows, pulldowns, curls, leg and hip machines).
- **Run a workout** — start a session from a split, then tap an exercise to run it
@@ -24,10 +24,11 @@ your own iCloud Drive.
from the **⋯** menu — **Save** keeps your progress (remaining exercises are marked
skipped) or **Discard** removes it.
- **Animated form guide** — every library exercise shows a looping stick-figure
- demonstration on the run screen's bottom half, rendered live from the Exercise
+ demonstration on the run screen's bottom half, posed by real joint angles on an
+ anatomical 3D skeleton (complete with feet) and rendered live from the Exercise
Library's rig data with the working limbs highlighted — machine exercises
include schematic equipment (seats, bars, cables, roller pads) that moves with
- the figure.
+ the figure, and face-on machines show true hip abduction and torso rotation.
- **Machine comfort settings** — machine-based exercises remember your setup
(seat height, back-rest position, pin position, …), shown on the workout
screen and editable mid-workout; changes save back to the split for next time.
diff --git a/Workouts/ExerciseFigure/ExerciseFigureView.swift b/Workouts/ExerciseFigure/ExerciseFigureView.swift
index 24d7e07..4766612 100644
--- a/Workouts/ExerciseFigure/ExerciseFigureView.swift
+++ b/Workouts/ExerciseFigure/ExerciseFigureView.swift
@@ -8,12 +8,14 @@
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
+/// `Canvas` + `TimelineView(.animation)` from the bundled anatomical 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).
+/// The solver hands back a depth-sorted draw order per frame (far parts first, the head
+/// always last so overhead arms pass behind the face) and a near/far tag per limb: the
+/// member nearer the camera draws in the dark ink at the heavier width, the far member
+/// in the light ink one step thinner and nudged by the readability offset. Working parts
+/// swap the same near/far inks for the accent teals; the spine is always dark.
/// Everything the renderer needs for one exercise, resolved once from the bundle.
struct FigureAnimation {
@@ -24,22 +26,26 @@ struct FigureAnimation {
let hide: Set
/// Equipment layer (see SYSTEM.md "The props layer").
let props: [MotionProp]
- let bodyProfile: ExerciseBodyProfile
init?(exerciseName: String) {
guard
let resources = ExerciseMotionLibrary.resources(for: exerciseName),
- let timeline = MotionTimeline(motion: resources.motion, body: resources.body)
+ let timeline = MotionTimeline(motion: resources.motion, profile: resources.profile)
else { return nil }
self.timeline = timeline
self.working = Set(resources.motion.working ?? [])
self.hide = Set(resources.motion.hide ?? [])
self.props = resources.motion.props ?? []
- self.bodyProfile = resources.body
}
+ /// Drawable geometry at `time`, with limbs listed in `hide` dropped so neither they
+ /// nor any prop riding them are drawn.
func geometry(at time: Double) -> FigureGeometry {
- MotionSolver.geometry(of: timeline.pose(at: time), body: bodyProfile, hide: hide)
+ var geo = timeline.geometry(at: time)
+ for name in hide {
+ if let limb = FigureLimb(rawValue: name) { geo.limbs[limb] = nil }
+ }
+ return geo
}
}
@@ -102,39 +108,39 @@ struct ExerciseFigureView: View {
// Equipment behind the figure: scene shapes and cables.
drawBackgroundProps(&ctx, geo)
- // 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)
-
- // Joint-attached equipment over the limbs (bars, dumbbells, pads).
- drawAttachedProps(&ctx, geo)
-
- // 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)
- if let noseStart = geo.noseStart, let noseEnd = geo.noseEnd {
- stroke(&ctx, [noseStart, noseEnd], color: .figureRight, width: 4)
+ // Parts far-to-near. The head paints last (opaque), preceded by the joint-attached
+ // equipment so bars/pads sit over the limbs but behind the face.
+ for part in geo.order {
+ switch part {
+ case "head":
+ drawAttachedProps(&ctx, geo)
+ drawHead(&ctx, geo)
+ case "spine":
+ drawSpine(&ctx, geo)
+ default:
+ guard let limb = FigureLimb(rawValue: part), let points = geo.limbs[limb] else { continue }
+ let shade = geo.shade[limb] ?? .near
+ stroke(&ctx, points, color: ink(part, shade: shade), width: shade == .near ? 6 : 5)
+ }
}
}
- 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 drawHead(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
+ 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(.figureNear), lineWidth: 6)
+ if let noseStart = geo.noseStart, let noseEnd = geo.noseEnd {
+ stroke(&ctx, [noseStart, noseEnd], color: .figureNear, width: 4)
+ }
}
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)),
+ ctx.stroke(path, with: .color(ink("spine", shade: .near)),
style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round))
}
@@ -142,7 +148,8 @@ struct ExerciseFigureView: View {
/// Prop joint refs → (limb, chain index): extremities are index 2, mid joints
/// (elbows/knees) index 1, so equipment can ride either joint. Kept 1:1 with
- /// the reference renderer's `JOINT_LIMB`.
+ /// the reference renderer's `JOINT_LIMB` (legs now end in a foot bone, so a foot
+ /// prop rides the ankle at index 2).
private static let propJoints: [String: (limb: FigureLimb, index: Int)] = [
"hand_r": (.armR, 2), "elbow_r": (.armR, 1),
"hand_l": (.armL, 2), "elbow_l": (.armL, 1),
@@ -248,11 +255,13 @@ struct ExerciseFigureView: View {
}
}
- private func color(for part: String, isLeft: Bool) -> Color {
+ /// Near parts take the dark ink, far parts the light one; working parts swap for the
+ /// accent teals. The spine (passed `.near`) is always dark unless it's the working part.
+ private func ink(_ part: String, shade: Shade) -> Color {
if figure.working.contains(part) {
- return isLeft ? .figureLeftWorking : .figureRightWorking
+ return shade == .near ? .figureNearWorking : .figureFarWorking
}
- return isLeft ? .figureLeft : .figureRight
+ return shade == .near ? .figureNear : .figureFar
}
private func stroke(_ ctx: inout GraphicsContext, _ points: [CGPoint], color: Color, width: CGFloat) {
@@ -265,8 +274,8 @@ struct ExerciseFigureView: View {
// 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,
+/// The reference palette (`render.py`), made dark-mode adaptive: the prominent near
+/// side stays strong (near-black → light gray), the recessive far side stays muted,
/// and the working teals brighten/desaturate so they read on black. watchOS has no
/// dynamic-provider `UIColor` and renders on black, so it takes the dark variant
/// verbatim.
@@ -283,18 +292,18 @@ private extension Color {
#endif
}
- /// Right-side limbs, head, nose — the prominent stroke (`#3a3f4b`).
- static let figureRight = figure(light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83))
+ /// Near-side limbs, spine, head, nose — the prominent stroke (`#3a3f4b`).
+ static let figureNear = figure(light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83))
- /// Left-side limbs, drawn behind — the recessive stroke (`#a9afba`).
- static let figureLeft = figure(light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43))
+ /// Far-side limbs, drawn behind — the recessive stroke (`#a9afba`).
+ static let figureFar = figure(light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43))
- /// Working right-side parts — teal accent (`#0d9488`).
- static let figureRightWorking = figure(light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75))
+ /// Working near-side parts — teal accent (`#0d9488`).
+ static let figureNearWorking = figure(light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75))
- /// 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 = figure(light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52))
+ /// Working far-side parts — light teal (`#86cfc5`), kept more muted than the near
+ /// so the near/far hierarchy holds in both modes.
+ static let figureFarWorking = figure(light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52))
/// Scene equipment — seats, benches, frames, cables (`#c5cad4`); sits behind
/// the figure, one step lighter than the ground line.
diff --git a/Workouts/ExerciseFigure/ExerciseMotion.swift b/Workouts/ExerciseFigure/ExerciseMotion.swift
index f16ba56..e02862f 100644
--- a/Workouts/ExerciseFigure/ExerciseMotion.swift
+++ b/Workouts/ExerciseFigure/ExerciseMotion.swift
@@ -7,17 +7,20 @@
import Foundation
-/// Codable mirror of the Exercise Library's rig data (see `Exercise Library/SYSTEM.md`).
+/// Codable mirror of the Exercise Library's anatomical 3D rig data (see
+/// `Exercise Library/SYSTEM.md`).
///
-/// A **body profile** is a table of bone lengths; a **motion script** is key frames of
-/// absolute joint angles (degrees, y-up: 0 = right, 90 = up, −90 = down), a pelvis
-/// `root` in 320×180 canvas coordinates, optional IK pins for planted hands/feet, and
-/// hold/tween timings. The app bundles verbatim copies exported by
-/// `render.py --export` into `Resources/ExerciseMotions/` — `body.json` plus one
-/// `.motion.json` per library entry.
+/// A **skeleton profile** is a table of bone lengths (plus real shoulder/pelvis widths
+/// and feet); a **motion script** is key frames of anatomical joint angles measured in
+/// degrees from the neutral standing pose (flexion forward, abduction away from the
+/// midline, rotation external — spine/neck rotation is turn-right positive), a pelvis
+/// `root` anchor with trunk orientation, an orthographic camera yaw, optional IK pins
+/// for planted hands/feet, and hold/tween timings. The app bundles verbatim copies
+/// exported by `render.py --export` into `Resources/ExerciseMotions/` — `skeleton.json`
+/// plus one `.motion.json` per library entry.
-/// Bone lengths for one figure profile (`neutral` is the default).
-struct ExerciseBodyProfile: Codable {
+/// Bone lengths for one figure profile (`neutral` is the only one the app renders).
+struct SkeletonProfile: Codable {
let headR: Double
let neck: Double
let spine1: Double
@@ -26,26 +29,81 @@ struct ExerciseBodyProfile: Codable {
let foreArm: Double
let thigh: Double
let shin: Double
- /// Offset separating left-limb attachments visually: `[dx, dy]` in canvas points.
- let leftOffset: [Double]
+ let foot: Double
+ /// Half the shoulder / pelvis width — the lateral offset of each limb's attachment
+ /// from the spine, giving the figure real depth face-on.
+ let shoulderHalf: Double
+ let hipHalf: Double
+ /// Profile-view readability nudge `[dx, dy]` for the far member of a limb pair,
+ /// scaled by how side-on the view is (vanishes face-on).
+ let farOffset: [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?
- /// Body-profile override (e.g. `"frontal"`: foreshortened legs for face-on
- /// seated machines). Nil uses the `neutral` profile.
- let figure: String?
- /// 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]?
- /// Equipment layer: scene shapes and cables behind the figure, joint-attached
- /// items (bar/dumbbell/pad) over the limbs. See SYSTEM.md "The props layer".
- let props: [MotionProp]?
- let frames: [MotionKeyFrame]
+/// The skeleton file: named profiles plus per-joint ROM (unused by the renderer).
+struct Skeleton: Codable {
+ let profiles: [String: SkeletonProfile]
+}
+
+/// A single joint value: a bare number (shorthand for `{"flexion": n}`) or a dict of
+/// named degrees of freedom. Ball joints read `flexion`/`abduction`/`rotation`, spine
+/// segments `flexion`/`lateral`/`rotation`, hinges only `flexion`; each accessor
+/// returns 0 for a DoF the value omits (matching the reference's `_full`).
+enum JointValue: Codable {
+ case scalar(Double)
+ case object(flexion: Double?, abduction: Double?, rotation: Double?, lateral: Double?)
+
+ private enum CodingKeys: String, CodingKey { case flexion, abduction, rotation, lateral }
+
+ init(from decoder: Decoder) throws {
+ if let single = try? decoder.singleValueContainer(), let value = try? single.decode(Double.self) {
+ self = .scalar(value)
+ return
+ }
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self = .object(flexion: try container.decodeIfPresent(Double.self, forKey: .flexion),
+ abduction: try container.decodeIfPresent(Double.self, forKey: .abduction),
+ rotation: try container.decodeIfPresent(Double.self, forKey: .rotation),
+ lateral: try container.decodeIfPresent(Double.self, forKey: .lateral))
+ }
+
+ func encode(to encoder: Encoder) throws {
+ switch self {
+ case .scalar(let value):
+ var container = encoder.singleValueContainer()
+ try container.encode(value)
+ case .object(let flexion, let abduction, let rotation, let lateral):
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encodeIfPresent(flexion, forKey: .flexion)
+ try container.encodeIfPresent(abduction, forKey: .abduction)
+ try container.encodeIfPresent(rotation, forKey: .rotation)
+ try container.encodeIfPresent(lateral, forKey: .lateral)
+ }
+ }
+
+ var flexion: Double {
+ switch self {
+ case .scalar(let value): value
+ case .object(let flexion, _, _, _): flexion ?? 0
+ }
+ }
+ var abduction: Double { if case .object(_, let abduction, _, _) = self { return abduction ?? 0 }; return 0 }
+ var rotation: Double { if case .object(_, _, let rotation, _) = self { return rotation ?? 0 }; return 0 }
+ var lateral: Double { if case .object(_, _, _, let lateral) = self { return lateral ?? 0 }; return 0 }
+}
+
+/// The pelvis anchor plus trunk orientation. `pos` is `[x, y]` in 320×180 canvas
+/// coordinates; `yaw`/`pitch`/`roll` are the trunk's facing, forward bow, and side-lean
+/// (all optional, degrees).
+struct RootValue: Codable {
+ let pos: [Double]
+ let yaw: Double?
+ let pitch: Double?
+ let roll: Double?
+}
+
+/// The orthographic camera: `yaw` 0 is the classic side view, 90 face-on.
+struct MotionCamera: Codable {
+ let yaw: Double?
}
/// A prop's joint reference: one joint (`"hand_r"`, `"knee_l"`, `"elbow_r"`, …)
@@ -118,52 +176,76 @@ struct MotionProp: Codable {
let plateR: Double?
}
-/// 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.
+/// A key frame of anatomical joint angles. A joint absent from the frame poses at
+/// neutral (all zeros); `spine` is `[lower, upper]` segments; a bare number anywhere a
+/// `JointValue` appears is its flexion.
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). Absent when the figure
- /// faces the viewer — no nose is drawn.
- let gaze: Double?
- let armR: [Double]?
- let armL: [Double]?
- let legR: [Double]?
- let legL: [Double]?
+ /// Pelvis anchor plus trunk orientation.
+ let root: RootValue
+ /// The two chained spine segments (pelvis→mid, mid→neck).
+ let spine: [JointValue]?
+ /// Neck: flexion (+ rotation).
+ let neck: JointValue?
+ /// Extra gaze pitch layered on the neck (a bare number = flexion).
+ let head: JointValue?
/// 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]]?
+ let shoulderR: JointValue?
+ let shoulderL: JointValue?
+ let elbowR: JointValue?
+ let elbowL: JointValue?
+ let hipR: JointValue?
+ let hipL: JointValue?
+ let kneeR: JointValue?
+ let kneeL: JointValue?
+ let ankleR: JointValue?
+ let ankleL: JointValue?
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"
+ case hold, tween, root, spine, neck, head, pins
+ case shoulderR = "shoulder_r", shoulderL = "shoulder_l"
+ case elbowR = "elbow_r", elbowL = "elbow_l"
+ case hipR = "hip_r", hipL = "hip_l"
+ case kneeR = "knee_r", kneeL = "knee_l"
+ case ankleR = "ankle_r", ankleL = "ankle_l"
}
}
+/// 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?
+ /// Orthographic camera. Nil is the side view (`yaw` 0).
+ let camera: MotionCamera?
+ /// 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]?
+ /// Equipment layer: scene shapes and cables behind the figure, joint-attached
+ /// items (bar/dumbbell/pad) over the limbs. See SYSTEM.md "The props layer".
+ let props: [MotionProp]?
+ let frames: [MotionKeyFrame]
+}
+
/// Finds and decodes the bundled rig resources for an exercise, by exact name match
/// against the exported `.motion.json` files.
enum ExerciseMotionLibrary {
struct Resources {
let motion: ExerciseMotion
- let body: ExerciseBodyProfile
+ let profile: SkeletonProfile
}
/// Every exercise with a bundled motion script, sorted alphabetically. XcodeGen
/// flattens resource groups, so `.motion.json` files land in the
- /// bundle root alongside `body.json` — enumerate all json and filter on the
- /// compound suffix (which `body.json` doesn't match).
+ /// bundle root alongside `skeleton.json` — enumerate all json and filter on the
+ /// compound suffix (which `skeleton.json` doesn't match).
static let exerciseNames: [String] = {
let urls = Bundle.main.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? []
return urls
@@ -173,19 +255,19 @@ enum ExerciseMotionLibrary {
.sorted()
}()
- /// The motion script plus the neutral body profile for `exerciseName`, or `nil`
+ /// The motion script plus the neutral skeleton 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 skeletonURL = Bundle.main.url(forResource: "skeleton", withExtension: "json"),
let motionData = try? Data(contentsOf: motionURL),
- let bodyData = try? Data(contentsOf: bodyURL),
+ let skeletonData = try? Data(contentsOf: skeletonURL),
let motion = try? JSONDecoder().decode(ExerciseMotion.self, from: motionData),
- let profiles = try? JSONDecoder().decode([String: ExerciseBodyProfile].self, from: bodyData),
- let body = profiles[motion.figure ?? "neutral"]
+ let skeleton = try? JSONDecoder().decode(Skeleton.self, from: skeletonData),
+ let profile = skeleton.profiles["neutral"]
else { return nil }
- return Resources(motion: motion, body: body)
+ return Resources(motion: motion, profile: profile)
}
}
diff --git a/Workouts/ExerciseFigure/MotionSolver.swift b/Workouts/ExerciseFigure/MotionSolver.swift
index 8aac885..bb72360 100644
--- a/Workouts/ExerciseFigure/MotionSolver.swift
+++ b/Workouts/ExerciseFigure/MotionSolver.swift
@@ -8,13 +8,83 @@
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.
+/// Swift port of the Exercise Library's anatomical 3D solver (`Exercise Library/
+/// kinematics.py` + `render.py`'s `frame_geometry`). It poses one shared skeleton by
+/// anatomical joint angles, runs forward kinematics in ISB model space (X anterior,
+/// Y up, Z anatomical right), projects orthographically through a per-exercise camera
+/// yaw, and resolves each frame into drawable canvas geometry — near/far shading, the
+/// readability offset, draw order, spine curve, and the gaze nose tick. The math is
+/// kept 1:1 with the Python so both renderers produce the same figure; it is held to
+/// the reference by `WorkoutsTests/Fixtures/figure-fixtures.json`. 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 θ).
+/// Rotation conventions (all degrees): root = Ry(yaw)·Rz(−pitch)·Rx(roll) after the
+/// camera Ry(−camYaw); ball joints (shoulder/hip) = Rz(flexion)·Rx(−σ·abduction)·
+/// Ry(−σ·rotation) with σ = +1 right / −1 left; the knee hinges backward via Rz(−flexion);
+/// spine segments = Rz(−flexion)·Rx(lateral)·Ry(−rotation); the neck = Rz(−flexion)·
+/// Ry(−rotation). Canvas maps view x → x, view y → −y (origin at the root anchor).
+
+// MARK: - Linear algebra
+
+/// A 3-vector in model / view space.
+struct Vec3 {
+ var x, y, z: Double
+ init(_ x: Double, _ y: Double, _ z: Double) { self.x = x; self.y = y; self.z = z }
+ static func + (a: Vec3, b: Vec3) -> Vec3 { Vec3(a.x + b.x, a.y + b.y, a.z + b.z) }
+ static func - (a: Vec3, b: Vec3) -> Vec3 { Vec3(a.x - b.x, a.y - b.y, a.z - b.z) }
+ func scaled(_ s: Double) -> Vec3 { Vec3(x * s, y * s, z * s) }
+ func dot(_ b: Vec3) -> Double { x * b.x + y * b.y + z * b.z }
+ func cross(_ b: Vec3) -> Vec3 { Vec3(y * b.z - z * b.y, z * b.x - x * b.z, x * b.y - y * b.x) }
+ var length: Double { dot(self).squareRoot() }
+ var normalized: Vec3 { let d = length; return d > 1e-9 ? scaled(1 / d) : Vec3(0, 0, 0) }
+}
+
+/// A row-major 3×3 rotation matrix (rows r0/r1/r2), matching the reference's
+/// tuple-of-rows so multiplication and transpose stay identical.
+struct Mat3 {
+ var r0, r1, r2: Vec3
+
+ static func rotX(_ deg: Double) -> Mat3 {
+ let r = deg * .pi / 180, c = cos(r), s = sin(r)
+ return Mat3(r0: Vec3(1, 0, 0), r1: Vec3(0, c, -s), r2: Vec3(0, s, c))
+ }
+ static func rotY(_ deg: Double) -> Mat3 {
+ let r = deg * .pi / 180, c = cos(r), s = sin(r)
+ return Mat3(r0: Vec3(c, 0, s), r1: Vec3(0, 1, 0), r2: Vec3(-s, 0, c))
+ }
+ static func rotZ(_ deg: Double) -> Mat3 {
+ let r = deg * .pi / 180, c = cos(r), s = sin(r)
+ return Mat3(r0: Vec3(c, -s, 0), r1: Vec3(s, c, 0), r2: Vec3(0, 0, 1))
+ }
+
+ /// `self · v`.
+ func apply(_ v: Vec3) -> Vec3 { Vec3(r0.dot(v), r1.dot(v), r2.dot(v)) }
+
+ /// `self · b`.
+ func times(_ b: Mat3) -> Mat3 {
+ let c0 = Vec3(b.r0.x, b.r1.x, b.r2.x)
+ let c1 = Vec3(b.r0.y, b.r1.y, b.r2.y)
+ let c2 = Vec3(b.r0.z, b.r1.z, b.r2.z)
+ return Mat3(r0: Vec3(r0.dot(c0), r0.dot(c1), r0.dot(c2)),
+ r1: Vec3(r1.dot(c0), r1.dot(c1), r1.dot(c2)),
+ r2: Vec3(r2.dot(c0), r2.dot(c1), r2.dot(c2)))
+ }
+
+ var transposed: Mat3 {
+ Mat3(r0: Vec3(r0.x, r1.x, r2.x), r1: Vec3(r0.y, r1.y, r2.y), r2: Vec3(r0.z, r1.z, r2.z))
+ }
+}
+
+/// Left-to-right matrix product (`chain(a, b, c) == a·b·c`).
+private func chain(_ mats: Mat3...) -> Mat3 {
+ var m = mats[0]
+ for n in mats.dropFirst() { m = m.times(n) }
+ return m
+}
+
+private func clampUnit(_ x: Double, _ lo: Double = -1, _ hi: Double = 1) -> Double { max(lo, min(hi, x)) }
+
+// MARK: - Frame model
/// The four two-bone limbs, keyed by their motion-script names.
enum FigureLimb: String, CaseIterable {
@@ -23,8 +93,9 @@ enum FigureLimb: String, CaseIterable {
case legR = "leg_r"
case legL = "leg_l"
- var isLeft: Bool { self == .armL || self == .legL }
-
+ var isArm: Bool { self == .armR || self == .armL }
+ /// Side sign for a ball joint's abduction/rotation (+1 right, −1 left).
+ var sigma: Double { (self == .armR || self == .legR) ? 1 : -1 }
/// The key a planted extremity uses in a key frame's `pins`.
var pinKey: String {
switch self {
@@ -34,261 +105,402 @@ enum FigureLimb: String, CaseIterable {
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
- /// Absent for figures facing the viewer — no nose tick.
- var gaze: Double?
- var limbs: [FigureLimb: [Double]]
+/// Shoulder / hip: forward flexion, abduction away from the midline, external rotation.
+struct BallJoint { var flexion, abduction, rotation: Double }
+/// One spine segment: forward curl, right side-bend, turn-right rotation.
+struct SpineSeg { var flexion, lateral, rotation: Double }
+/// Neck: forward flexion plus turn-right rotation.
+struct NeckJoint { var flexion, rotation: Double }
+/// Elbow / knee / ankle: a single flexion angle (knees hinge backward automatically).
+struct Hinge { var flexion: Double }
+
+/// A key frame expanded to full anatomical dicts (defaults filled in), the shape FK
+/// and tweening operate on. Pinned limbs' `Ball`/`Hinge` values are overwritten by the
+/// IK solution during `resolve`, so poses always interpolate in anatomical space.
+struct NormalizedFrame {
+ var rootPos: CGPoint
+ var yaw, pitch, roll: Double
+ var spine: [SpineSeg] // two chained segments
+ var neck: NeckJoint
+ var head: Double // extra gaze pitch (flexion)
+ var shoulderR, shoulderL, hipR, hipL: BallJoint
+ var elbowR, elbowL, kneeR, kneeL, ankleR, ankleL: Hinge
var pins: [String: CGPoint]
- var hold: Double
- var tween: Double
-}
+ var hold, 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 ball(for limb: FigureLimb) -> BallJoint {
+ switch limb { case .armR: shoulderR; case .armL: shoulderL; case .legR: hipR; case .legL: hipL }
+ }
+ func lower(for limb: FigureLimb) -> Hinge {
+ switch limb { case .armR: elbowR; case .armL: elbowL; case .legR: kneeR; case .legL: kneeL }
+ }
+ func ankle(for limb: FigureLimb) -> Hinge { limb == .legR ? ankleR : ankleL }
- func point(for limb: FigureLimb) -> CGPoint {
- switch limb {
- case .armR: neck
- case .armL: shoulderL
- case .legR: pelvis
- case .legL: hipL
- }
+ mutating func setUpper(_ b: BallJoint, for limb: FigureLimb) {
+ switch limb { case .armR: shoulderR = b; case .armL: shoulderL = b; case .legR: hipR = b; case .legL: hipL = b }
+ }
+ mutating func setLower(_ h: Hinge, for limb: FigureLimb) {
+ switch limb { case .armR: elbowR = h; case .armL: elbowL = h; case .legR: kneeR = h; case .legL: kneeL = h }
}
}
-/// A pose resolved to drawable points.
+/// Within a limb pair, the member nearer the camera (`near`) draws dark and in front;
+/// the far member draws light, behind, and nudged by the readability offset.
+enum Shade { case near, far }
+
+/// A pose resolved to drawable canvas points (plus the depth-sorted draw order and
+/// per-limb shading the view needs).
struct FigureGeometry {
var headCenter: CGPoint
var headRadius: Double
- /// Nil when the pose has no gaze (face-on view).
- var noseStart: CGPoint?
- var noseEnd: CGPoint?
+ /// Nil when the face points at (or away from) the camera — no nose tick.
+ var noseStart, 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 spineStart, spineControl, spineEnd: CGPoint
+ /// Attach → elbow/knee → hand (arms: 3 points); hip → knee → ankle → toe (legs: 4).
var limbs: [FigureLimb: [CGPoint]]
+ /// Parts far-to-near, `"head"` always last (`"spine"`, `"arm_r"`, … then `"head"`).
+ var order: [String]
+ var shade: [FigureLimb: Shade]
}
+/// FK output in view space (x right, y up, z toward the camera; origin at the root).
+struct FigurePose {
+ var pelvis, mid, neckB, head: Vec3
+ var shoulderR, shoulderL, hipR, hipL: Vec3
+ var limbs: [FigureLimb: [Vec3]]
+ var f2, fRoot: Mat3 // spine-top and root frames, for IK inversion
+ var noseDir: Vec3
+ /// How side-on the view is: 1 in profile (lateral axis is pure depth), 0 face-on.
+ var k: Double
+
+ func attach(for limb: FigureLimb) -> Vec3 {
+ switch limb { case .armR: shoulderR; case .armL: shoulderL; case .legR: hipR; case .legL: hipL }
+ }
+}
+
+// MARK: - Solver
+
enum MotionSolver {
- /// Unit vector for a y-up angle, in y-down canvas coordinates.
+ private static let depthBucket = 3.0
+ private static let pairs: [(FigureLimb, FigureLimb)] = [(.armR, .armL), (.legR, .legL)]
+ /// Fixed draw rank breaking depth-bucket ties (far parts first, `spine` mid-stack).
+ private static let fixedRank: [String: Int] = ["arm_l": 0, "leg_l": 1, "spine": 2, "arm_r": 3, "leg_r": 4]
+
+ /// Unit vector for a y-up angle in y-down canvas coordinates (props' fixed angles).
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
- }
+ // MARK: Normalize / tween
- /// 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])
+ /// Expand a key frame to a `NormalizedFrame` with defaults filled in.
+ static func normalize(_ kf: MotionKeyFrame) -> NormalizedFrame {
+ let spineValues = kf.spine ?? [.scalar(0), .scalar(0)]
+ let spine = spineValues.map { SpineSeg(flexion: $0.flexion, lateral: $0.lateral, rotation: $0.rotation) }
+ func ball(_ v: JointValue?) -> BallJoint { BallJoint(flexion: v?.flexion ?? 0, abduction: v?.abduction ?? 0, rotation: v?.rotation ?? 0) }
+ func hinge(_ v: JointValue?) -> Hinge { Hinge(flexion: v?.flexion ?? 0) }
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)
+ for (key, xy) in kf.pins ?? [:] where xy.count == 2 { pins[key] = CGPoint(x: xy[0], y: xy[1]) }
+ return NormalizedFrame(
+ rootPos: CGPoint(x: kf.root.pos[0], y: kf.root.pos[1]),
+ yaw: kf.root.yaw ?? 0, pitch: kf.root.pitch ?? 0, roll: kf.root.roll ?? 0,
+ spine: spine,
+ neck: NeckJoint(flexion: kf.neck?.flexion ?? 0, rotation: kf.neck?.rotation ?? 0),
+ head: kf.head?.flexion ?? 0,
+ shoulderR: ball(kf.shoulderR), shoulderL: ball(kf.shoulderL),
+ hipR: ball(kf.hipR), hipL: ball(kf.hipL),
+ elbowR: hinge(kf.elbowR), elbowL: hinge(kf.elbowL),
+ kneeR: hinge(kf.kneeR), kneeL: hinge(kf.kneeL),
+ ankleR: hinge(kf.ankleR), ankleL: hinge(kf.ankleL),
+ 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
- }
+ 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) }
+ /// Interpolate two resolved frames — plain linear per degree of freedom, so limbs
+ /// swing in natural anatomical arcs. A pin survives the tween only when planted in
+ /// BOTH neighboring key frames (a one-sided pin releases naturally).
+ static func lerpFrames(_ a: NormalizedFrame, _ b: NormalizedFrame, _ t: Double) -> NormalizedFrame {
+ func n(_ x: Double, _ y: Double) -> Double { x + (y - x) * t }
+ func lerpBall(_ p: BallJoint, _ q: BallJoint) -> BallJoint {
+ BallJoint(flexion: n(p.flexion, q.flexion), abduction: n(p.abduction, q.abduction), rotation: n(p.rotation, q.rotation))
}
+ func lerpHinge(_ p: Hinge, _ q: Hinge) -> Hinge { Hinge(flexion: n(p.flexion, q.flexion)) }
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)
+ pins[key] = CGPoint(x: n(pa.x, pb.x), y: n(pa.y, pb.y))
}
- 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: (a.gaze != nil && b.gaze != nil) ? lerpAngle(a.gaze!, b.gaze!, t) : nil,
- limbs: limbs, pins: pins, hold: a.hold, tween: a.tween
- )
+ return NormalizedFrame(
+ rootPos: CGPoint(x: n(a.rootPos.x, b.rootPos.x), y: n(a.rootPos.y, b.rootPos.y)),
+ yaw: n(a.yaw, b.yaw), pitch: n(a.pitch, b.pitch), roll: n(a.roll, b.roll),
+ spine: zip(a.spine, b.spine).map { SpineSeg(flexion: n($0.flexion, $1.flexion), lateral: n($0.lateral, $1.lateral), rotation: n($0.rotation, $1.rotation)) },
+ neck: NeckJoint(flexion: n(a.neck.flexion, b.neck.flexion), rotation: n(a.neck.rotation, b.neck.rotation)),
+ head: n(a.head, b.head),
+ shoulderR: lerpBall(a.shoulderR, b.shoulderR), shoulderL: lerpBall(a.shoulderL, b.shoulderL),
+ hipR: lerpBall(a.hipR, b.hipR), hipL: lerpBall(a.hipL, b.hipL),
+ elbowR: lerpHinge(a.elbowR, b.elbowR), elbowL: lerpHinge(a.elbowL, b.elbowL),
+ kneeR: lerpHinge(a.kneeR, b.kneeR), kneeL: lerpHinge(a.kneeL, b.kneeL),
+ ankleR: lerpHinge(a.ankleR, b.ankleR), ankleL: lerpHinge(a.ankleL, b.ankleL),
+ pins: pins, hold: a.hold, tween: a.tween)
}
- /// Normalized pose → drawable points. Limbs with an active pin are re-solved so
- /// planted hands/feet hold that point exactly through tweens.
- static func geometry(of pose: FigurePose, body: ExerciseBodyProfile, hide: Set) -> FigureGeometry {
- let at = attachments(root: pose.root, spine: pose.spine, body: body)
- let head = walk(from: at.neck, angles: [pose.neck], lengths: [body.neck])[1]
- let r = body.headR
- // Nose tick: 7pt outward from the head rim along the gaze direction
- // (omitted for face-on poses without a gaze).
- var noseStart: CGPoint?
- var noseEnd: CGPoint?
- if let gaze = pose.gaze {
- let d = direction(gaze)
- noseStart = CGPoint(x: head.x + d.dx * r, y: head.y + d.dy * r)
- noseEnd = CGPoint(x: head.x + d.dx * (r + 7), y: head.y + d.dy * (r + 7))
+ // MARK: Forward kinematics
+
+ /// Local rotation of a ball joint (shoulder/hip) for side sign `sigma`.
+ private static func ballMatrix(_ j: BallJoint, _ sigma: Double) -> Mat3 {
+ chain(Mat3.rotZ(j.flexion), Mat3.rotX(-sigma * j.abduction), Mat3.rotY(-sigma * j.rotation))
+ }
+
+ /// FK one limb from its resolved attach point (arm: [shoulder, elbow, hand];
+ /// leg: [hip, knee, ankle, toe]).
+ private static func fkLimb(_ limb: FigureLimb, attach: Vec3, upper: BallJoint, lower: Hinge, ankle: Hinge, prof: SkeletonProfile, parent: Mat3) -> [Vec3] {
+ let fu = parent.times(ballMatrix(upper, limb.sigma))
+ if limb.isArm {
+ let elbow = attach + fu.apply(Vec3(0, -prof.upperArm, 0))
+ let fl = fu.times(Mat3.rotZ(lower.flexion))
+ let hand = elbow + fl.apply(Vec3(0, -prof.foreArm, 0))
+ return [attach, elbow, hand]
}
+ let knee = attach + fu.apply(Vec3(0, -prof.thigh, 0))
+ let fl = fu.times(Mat3.rotZ(-lower.flexion))
+ let ankleJoint = knee + fl.apply(Vec3(0, -prof.shin, 0))
+ let toe = ankleJoint + fl.times(Mat3.rotZ(ankle.flexion)).apply(Vec3(prof.foot, 0, 0))
+ return [attach, knee, ankleJoint, toe]
+ }
+
+ /// FK a normalized frame into view space through the camera yaw.
+ static func pose(_ nf: NormalizedFrame, prof: SkeletonProfile, cam: Double) -> FigurePose {
+ let fRoot = chain(Mat3.rotY(-cam), Mat3.rotY(nf.yaw), Mat3.rotZ(-nf.pitch), Mat3.rotX(nf.roll))
+ let origin = Vec3(0, 0, 0)
+ let s1 = nf.spine[0], s2 = nf.spine[1]
+ let f1 = chain(fRoot, Mat3.rotZ(-s1.flexion), Mat3.rotX(s1.lateral), Mat3.rotY(-s1.rotation))
+ let mid = origin + f1.apply(Vec3(0, prof.spine1, 0))
+ let f2 = chain(f1, Mat3.rotZ(-s2.flexion), Mat3.rotX(s2.lateral), Mat3.rotY(-s2.rotation))
+ let neckB = mid + f2.apply(Vec3(0, prof.spine2, 0))
+ let fn = chain(f2, Mat3.rotZ(-nf.neck.flexion), Mat3.rotY(-nf.neck.rotation))
+ let head = neckB + fn.apply(Vec3(0, prof.neck, 0))
+ let noseDir = fn.times(Mat3.rotZ(-nf.head)).apply(Vec3(1, 0, 0))
+ var p = FigurePose(
+ pelvis: origin, mid: mid, neckB: neckB, head: head,
+ shoulderR: neckB + f2.apply(Vec3(0, 0, prof.shoulderHalf)),
+ shoulderL: neckB + f2.apply(Vec3(0, 0, -prof.shoulderHalf)),
+ hipR: origin + fRoot.apply(Vec3(0, 0, prof.hipHalf)),
+ hipL: origin + fRoot.apply(Vec3(0, 0, -prof.hipHalf)),
+ limbs: [:], f2: f2, fRoot: fRoot, noseDir: noseDir,
+ k: abs(fRoot.apply(Vec3(0, 0, 1)).z))
+ for limb in FigureLimb.allCases {
+ let parent = limb.isArm ? f2 : fRoot
+ p.limbs[limb] = fkLimb(limb, attach: p.attach(for: limb), upper: nf.ball(for: limb),
+ lower: nf.lower(for: limb), ankle: nf.ankle(for: limb), prof: prof, parent: parent)
+ }
+ return p
+ }
+
+ // MARK: Inverse kinematics
+
+ /// Analytic two-bone IK in 3D: reach from `attach` toward `target` in the plane
+ /// picked by the authored (FK) mid joint, then convert back to anatomical angles.
+ private static func solveLimb(_ limb: FigureLimb, attach: Vec3, target: Vec3, guessMid: Vec3, lengths: (Double, Double), parent: Mat3) -> (BallJoint, Hinge) {
+ let (a, b) = lengths
+ let toTarget = target - attach
+ let d = clampUnit(toTarget.length, abs(a - b) + 0.5, a + b - 0.01)
+ let dirTarget = toTarget.length > 1e-9 ? toTarget.normalized : Vec3(0, -1, 0)
+ var normal = dirTarget.cross(guessMid - attach)
+ if normal.length < 1e-6 {
+ normal = dirTarget.cross(Vec3(0, 0, 1))
+ if normal.length < 1e-6 { normal = dirTarget.cross(Vec3(0, 1, 0)) }
+ }
+ normal = normal.normalized
+ let perp = normal.cross(dirTarget)
+ let along = (a * a + d * d - b * b) / (2 * d)
+ let h = max(a * a - along * along, 0).squareRoot()
+ var best: (distance: Double, mid: Vec3)?
+ for sign in [1.0, -1.0] {
+ let mid = attach + (dirTarget.scaled(along) + perp.scaled(sign * h))
+ let distance = (mid - guessMid).length
+ if best == nil || distance < best!.distance { best = (distance, mid) }
+ }
+ let mid = best!.mid
+ let end = mid + (target - mid).normalized.scaled(b)
+ return invertLimb(limb, attach: attach, mid: mid, end: end, parent: parent)
+ }
+
+ /// Recover anatomical angles from limb joint positions (the inverse of `fkLimb`,
+ /// ignoring the foot). Assumes |abduction| < 90; the leg's rotation sign flips
+ /// because knees hinge backward.
+ private static func invertLimb(_ limb: FigureLimb, attach: Vec3, mid: Vec3, end: Vec3, parent: Mat3) -> (BallJoint, Hinge) {
+ let sigma = limb.sigma
+ let parentT = parent.transposed
+ let u = parentT.apply(mid - attach).normalized
+ let abduction = asin(clampUnit(sigma * u.z)) * 180 / .pi
+ let flexion = atan2(u.x, -u.y) * 180 / .pi
+ let peel = chain(Mat3.rotZ(flexion), Mat3.rotX(-sigma * abduction)).transposed
+ let w = peel.apply(parentT.apply(end - mid)).normalized
+ let bend = acos(clampUnit(-w.y)) * 180 / .pi
+ let rotation: Double
+ if limb.isArm {
+ rotation = bend > 0.5 ? sigma * atan2(w.z, w.x) * 180 / .pi : 0
+ } else {
+ rotation = bend > 0.5 ? sigma * atan2(-w.z, -w.x) * 180 / .pi : 0
+ }
+ return (BallJoint(flexion: flexion, abduction: abduction, rotation: rotation), Hinge(flexion: bend))
+ }
+
+ private static func viewFromCanvas(_ pt: CGPoint, anchor: CGPoint, depth: Double) -> Vec3 {
+ Vec3(pt.x - anchor.x, anchor.y - pt.y, depth)
+ }
+
+ /// Pose a frame and apply pins: for each pinned limb, solve IK against the canvas
+ /// target (at the limb's FK depth), write the solved angles back, and re-pose.
+ private static func resolve(_ nf: NormalizedFrame, prof: SkeletonProfile, cam: Double) -> (NormalizedFrame, FigurePose) {
+ var frame = nf
+ var p = pose(frame, prof: prof, cam: cam)
+ let anchor = frame.rootPos
+ var solved = false
+ for limb in FigureLimb.allCases {
+ guard let pin = frame.pins[limb.pinKey], let chainPts = p.limbs[limb] else { continue }
+ let attach = chainPts[0]
+ let target = viewFromCanvas(pin, anchor: anchor, depth: chainPts[2].z)
+ let lengths: (Double, Double) = limb.isArm ? (prof.upperArm, prof.foreArm) : (prof.thigh, prof.shin)
+ let parent = limb.isArm ? p.f2 : p.fRoot
+ let (upper, lower) = solveLimb(limb, attach: attach, target: target, guessMid: chainPts[1], lengths: lengths, parent: parent)
+ frame.setUpper(upper, for: limb)
+ frame.setLower(lower, for: limb)
+ solved = true
+ }
+ if solved { p = pose(frame, prof: prof, cam: cam) }
+ return (frame, p)
+ }
+
+ // MARK: Frame geometry
+
+ /// Python's `round(x)` uses banker's rounding; match it so depth buckets agree.
+ private static func bucket(_ depth: Double) -> Int { Int((depth / depthBucket).rounded(.toNearestOrEven)) }
+ private static func chainDepth(_ pts: [Vec3]) -> Double { pts.reduce(0) { $0 + $1.z } / Double(pts.count) }
+
+ /// Resolve a normalized frame into drawable geometry. Returns the frame with
+ /// IK-resolved angles (for tweening) plus the canvas geometry: near/far shading
+ /// (the near pair member draws dark and in front; canvas-right wins depth-bucket
+ /// ties in face-on views), the readability offset applied to the far member of
+ /// each pair and subtracted from a far pin's target before IK (authored pins
+ /// restored afterward), the depth-sorted draw order (head last), the spine curve,
+ /// and the foreshortened gaze nose tick (hidden when the face points at/away from
+ /// the camera).
+ static func frameGeometry(_ nf: NormalizedFrame, prof: SkeletonProfile, cam: Double) -> (NormalizedFrame, FigureGeometry) {
+ let p0 = pose(nf, prof: prof, cam: cam)
+ var shade: [FigureLimb: Shade] = [:]
+ for (right, left) in pairs {
+ guard let rp = p0.limbs[right], let lp = p0.limbs[left] else { continue }
+ let dr = chainDepth(rp), dl = chainDepth(lp)
+ let near: FigureLimb
+ if bucket(dr) == bucket(dl) {
+ near = rp[0].x >= lp[0].x ? right : left // view x == canvas offset from anchor
+ } else {
+ near = dr > dl ? right : left
+ }
+ shade[right] = near == right ? .near : .far
+ shade[left] = near == left ? .near : .far
+ }
+
+ let fo = prof.farOffset
+ let off = CGPoint(x: (fo.first ?? 6) * p0.k, y: (fo.count > 1 ? fo[1] : 2) * p0.k)
+ var work = nf
+ for limb in FigureLimb.allCases where shade[limb] == .far {
+ if let pin = work.pins[limb.pinKey] {
+ work.pins[limb.pinKey] = CGPoint(x: pin.x - off.x, y: pin.y - off.y)
+ }
+ }
+ var (resolved, p) = resolve(work, prof: prof, cam: cam)
+ resolved.pins = nf.pins // keep authored pins; only angles resolved
+
+ let anchor = nf.rootPos
+ func scr(_ v: Vec3, _ limbOffset: CGPoint = .zero) -> CGPoint {
+ CGPoint(x: anchor.x + v.x + limbOffset.x, y: anchor.y - v.y + limbOffset.y)
+ }
+ let pelvis = scr(p.pelvis), mid = scr(p.mid), neckB = scr(p.neckB)
+ let control = CGPoint(x: 2 * mid.x - (pelvis.x + neckB.x) / 2, y: 2 * mid.y - (pelvis.y + neckB.y) / 2)
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)
+ var depths: [String: Double] = ["spine": chainDepth([p.pelvis, p.mid, p.neckB])]
+ for limb in FigureLimb.allCases {
+ guard let pts = p.limbs[limb] else { continue }
+ let limbOffset = shade[limb] == .far ? off : .zero
+ limbs[limb] = pts.map { scr($0, limbOffset) }
+ depths[limb.rawValue] = chainDepth(pts)
}
- return FigureGeometry(
- headCenter: head,
- headRadius: r,
- noseStart: noseStart,
- noseEnd: noseEnd,
- 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
- )
+ let head = scr(p.head)
+ var noseStart: CGPoint?, noseEnd: CGPoint?
+ let nose = p.noseDir
+ let mag = (nose.x * nose.x + nose.y * nose.y).squareRoot()
+ if mag > 0.3 {
+ let ux = nose.x / mag, uy = -nose.y / mag, r = prof.headR
+ noseStart = CGPoint(x: head.x + ux * r, y: head.y + uy * r)
+ noseEnd = CGPoint(x: head.x + ux * (r + 7 * mag), y: head.y + uy * (r + 7 * mag))
+ }
+
+ let order = depths.keys.sorted { lhs, rhs in
+ let bl = bucket(depths[lhs]!), br = bucket(depths[rhs]!)
+ if bl != br { return bl < br }
+ return (fixedRank[lhs] ?? 0) < (fixedRank[rhs] ?? 0)
+ } + ["head"]
+
+ let geo = FigureGeometry(
+ headCenter: head, headRadius: prof.headR, noseStart: noseStart, noseEnd: noseEnd,
+ spineStart: pelvis, spineControl: control, spineEnd: neckB,
+ limbs: limbs, order: order, shade: shade)
+ return (resolved, geo)
}
}
-/// 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.
+/// The full looping animation for one motion: key frames resolved to anatomical angles,
+/// then continuous-time sampling — hold at each key frame, then an eased anatomical-space
+/// tween to the next; the last frame tweens back to the first.
struct MotionTimeline {
- let poses: [FigurePose]
+ let resolved: [NormalizedFrame]
+ let profile: SkeletonProfile
+ let cam: Double
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
+ init?(motion: ExerciseMotion, profile: SkeletonProfile) {
+ let cam = motion.camera?.yaw ?? 0
+ let norms = motion.frames.map { MotionSolver.normalize($0) }
+ guard !norms.isEmpty else { return nil }
+ let resolved = norms.map { MotionSolver.frameGeometry($0, prof: profile, cam: cam).0 }
+ let duration = resolved.reduce(0) { $0 + $1.hold + $1.tween }
+ guard duration > 0 else { return nil }
+ self.resolved = resolved
+ self.profile = profile
+ self.cam = cam
self.duration = duration
}
- /// The pose at wall-clock `time`, looping every `duration` seconds.
- func pose(at time: Double) -> FigurePose {
+ /// The resolved frame (or eased tween) at wall-clock `time`, looping every
+ /// `duration` seconds.
+ func frame(at time: Double) -> NormalizedFrame {
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))
+ for (i, f) in resolved.enumerated() {
+ if t < f.hold { return f }
+ t -= f.hold
+ if t < f.tween {
+ let next = resolved[(i + 1) % resolved.count]
+ return MotionSolver.lerpFrames(f, next, MotionSolver.ease(t / f.tween))
}
- t -= pose.tween
+ t -= f.tween
}
- return poses[0]
+ return resolved[0]
+ }
+
+ /// The drawable geometry at wall-clock `time`.
+ func geometry(at time: Double) -> FigureGeometry {
+ MotionSolver.frameGeometry(frame(at: time), prof: profile, cam: cam).1
}
}
diff --git a/Workouts/Resources/ExerciseMotions/Abdominal.motion.json b/Workouts/Resources/ExerciseMotions/Abdominal.motion.json
index 36f15cb..f41fc49 100644
--- a/Workouts/Resources/ExerciseMotions/Abdominal.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Abdominal.motion.json
@@ -2,7 +2,6 @@
"name": "Abdominal",
"primary": 2,
"working": ["spine"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[120, 128], [114, 62]], "w": 8},
@@ -16,18 +15,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [128, 126],
- "spine": [94, 90], "neck": 82, "gaze": -8,
- "arm_r": [-12, -52], "arm_l": [-12, -52],
- "leg_r": [-20, -90], "leg_l": [-20, -90],
+ "root": {"pos": [128, 126], "pitch": -4},
+ "spine": [0, 4],
+ "neck": 8,
+ "shoulder_r": 78, "elbow_r": -40,
+ "shoulder_l": 78, "elbow_l": -40,
+ "hip_r": 66, "knee_r": 70,
+ "hip_l": 66, "knee_l": 70,
"pins": {"hand_r": [155, 62], "hand_l": [161, 64], "foot_r": [168, 150], "foot_l": [172, 150]}
},
{
"hold": 0.4, "tween": 1.0,
- "root": [128, 126],
- "spine": [80, 40], "neck": 22, "gaze": -42,
- "arm_r": [-12, -52], "arm_l": [-12, -52],
- "leg_r": [-20, -90], "leg_l": [-20, -90],
+ "root": {"pos": [128, 126], "pitch": 10},
+ "spine": [0, 40],
+ "neck": 18, "head": -26,
+ "shoulder_r": 128, "elbow_r": -40,
+ "shoulder_l": 128, "elbow_l": -40,
+ "hip_r": 80, "knee_r": 70,
+ "hip_l": 80, "knee_l": 70,
"pins": {"hand_r": [190, 76], "hand_l": [196, 78], "foot_r": [168, 150], "foot_l": [172, 150]}
}
]
diff --git a/Workouts/Resources/ExerciseMotions/Abductor.motion.json b/Workouts/Resources/ExerciseMotions/Abductor.motion.json
index 626a2a5..ee56697 100644
--- a/Workouts/Resources/ExerciseMotions/Abductor.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Abductor.motion.json
@@ -1,9 +1,8 @@
{
"name": "Abductor",
"primary": 2,
- "figure": "frontal",
+ "camera": {"yaw": 90},
"working": ["leg_r", "leg_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "rect", "x": 152, "y": 56, "w": 16, "h": 72, "r": 5},
@@ -16,17 +15,23 @@
"frames": [
{
"hold": 0.5, "tween": 1.1,
- "root": [160, 116],
- "spine": [90, 90], "neck": 90,
- "arm_r": [-48, -82], "arm_l": [-132, -98],
- "leg_r": [-75, -90], "leg_l": [-105, -90]
+ "root": {"pos": [160, 116]},
+ "spine": [0, 0],
+ "neck": 0,
+ "shoulder_r": {"flexion": 10, "abduction": 18}, "elbow_r": 25,
+ "shoulder_l": {"flexion": 10, "abduction": 18}, "elbow_l": 25,
+ "hip_r": {"flexion": 78, "abduction": 7}, "knee_r": 62,
+ "hip_l": {"flexion": 78, "abduction": 7}, "knee_l": 62
},
{
"hold": 0.5, "tween": 1.1,
- "root": [160, 116],
- "spine": [90, 90], "neck": 90,
- "arm_r": [-48, -82], "arm_l": [-132, -98],
- "leg_r": [-30, -90], "leg_l": [-150, -90]
+ "root": {"pos": [160, 116]},
+ "spine": [0, 0],
+ "neck": 0,
+ "shoulder_r": {"flexion": 10, "abduction": 18}, "elbow_r": 25,
+ "shoulder_l": {"flexion": 10, "abduction": 18}, "elbow_l": 25,
+ "hip_r": {"flexion": 78, "abduction": 30}, "knee_r": 62,
+ "hip_l": {"flexion": 78, "abduction": 30}, "knee_l": 62
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Adductor.motion.json b/Workouts/Resources/ExerciseMotions/Adductor.motion.json
index 348c15e..6ffbf73 100644
--- a/Workouts/Resources/ExerciseMotions/Adductor.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Adductor.motion.json
@@ -1,9 +1,8 @@
{
"name": "Adductor",
"primary": 2,
- "figure": "frontal",
+ "camera": {"yaw": 90},
"working": ["leg_r", "leg_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "rect", "x": 152, "y": 56, "w": 16, "h": 72, "r": 5},
@@ -16,17 +15,23 @@
"frames": [
{
"hold": 0.5, "tween": 1.1,
- "root": [160, 116],
- "spine": [90, 90], "neck": 90,
- "arm_r": [-48, -82], "arm_l": [-132, -98],
- "leg_r": [-30, -90], "leg_l": [-150, -90]
+ "root": {"pos": [160, 116]},
+ "spine": [0, 0],
+ "neck": 0,
+ "shoulder_r": {"flexion": 10, "abduction": 18}, "elbow_r": 25,
+ "shoulder_l": {"flexion": 10, "abduction": 18}, "elbow_l": 25,
+ "hip_r": {"flexion": 78, "abduction": 30}, "knee_r": 62,
+ "hip_l": {"flexion": 78, "abduction": 30}, "knee_l": 62
},
{
"hold": 0.5, "tween": 1.1,
- "root": [160, 116],
- "spine": [90, 90], "neck": 90,
- "arm_r": [-48, -82], "arm_l": [-132, -98],
- "leg_r": [-75, -90], "leg_l": [-105, -90]
+ "root": {"pos": [160, 116]},
+ "spine": [0, 0],
+ "neck": 0,
+ "shoulder_r": {"flexion": 10, "abduction": 18}, "elbow_r": 25,
+ "shoulder_l": {"flexion": 10, "abduction": 18}, "elbow_l": 25,
+ "hip_r": {"flexion": 78, "abduction": 7}, "knee_r": 62,
+ "hip_l": {"flexion": 78, "abduction": 7}, "knee_l": 62
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Arm Curl.motion.json b/Workouts/Resources/ExerciseMotions/Arm Curl.motion.json
index 03f5648..23eb200 100644
--- a/Workouts/Resources/ExerciseMotions/Arm Curl.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Arm Curl.motion.json
@@ -2,7 +2,6 @@
"name": "Arm Curl",
"primary": 2,
"working": ["arm_r", "arm_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[116, 134], [150, 134]], "w": 8},
@@ -14,18 +13,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [130, 124],
- "spine": [88, 86], "neck": 82, "gaze": -18,
- "arm_r": [-35, -58], "arm_l": [-35, -58],
- "leg_r": [-8, -72], "leg_l": [-8, -72],
+ "root": {"pos": [130, 124], "pitch": 2},
+ "spine": [0, 2],
+ "neck": 4, "head": 10,
+ "shoulder_r": 59, "elbow_r": -23,
+ "shoulder_l": 59, "elbow_l": -23,
+ "hip_r": 84, "knee_r": 64,
+ "hip_l": 84, "knee_l": 64,
"pins": {"foot_r": [176, 150], "foot_l": [180, 151]}
},
{
"hold": 0.4, "tween": 1.2,
- "root": [130, 124],
- "spine": [88, 86], "neck": 82, "gaze": -18,
- "arm_r": [-35, 50], "arm_l": [-35, 50],
- "leg_r": [-8, -72], "leg_l": [-8, -72],
+ "root": {"pos": [130, 124], "pitch": 2},
+ "spine": [0, 2],
+ "neck": 4, "head": 10,
+ "shoulder_r": 59, "elbow_r": 85,
+ "shoulder_l": 59, "elbow_l": 85,
+ "hip_r": 84, "knee_r": 64,
+ "hip_l": 84, "knee_l": 64,
"pins": {"foot_r": [176, 150], "foot_l": [180, 151]}
}
]
diff --git a/Workouts/Resources/ExerciseMotions/Bird Dog.motion.json b/Workouts/Resources/ExerciseMotions/Bird Dog.motion.json
index 15336d5..f912e3a 100644
--- a/Workouts/Resources/ExerciseMotions/Bird Dog.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Bird Dog.motion.json
@@ -1,39 +1,51 @@
{
"name": "Bird Dog",
"primary": 2,
- "working": ["arm_r", "leg_l", "arm_l", "leg_r"],
+ "working": ["arm_l", "leg_r", "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]}
+ "root": {"pos": [190, 106], "yaw": 180, "pitch": 81},
+ "spine": [0, 0],
+ "neck": 16, "head": -72,
+ "shoulder_r": 81, "elbow_r": 0,
+ "shoulder_l": 81, "elbow_l": 0,
+ "hip_r": 74, "knee_r": 83, "ankle_r": -55,
+ "hip_l": 74, "knee_l": 83, "ankle_l": -55,
+ "pins": {"hand_l": [105, 152], "hand_r": [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]}
+ "root": {"pos": [190, 106], "yaw": 180, "pitch": 81},
+ "spine": [0, 0],
+ "neck": 16, "head": -82,
+ "shoulder_r": 81, "elbow_r": 0,
+ "shoulder_l": 190, "elbow_l": 0,
+ "hip_r": -24, "knee_r": 0, "ankle_r": -25,
+ "hip_l": 74, "knee_l": 83, "ankle_l": -55,
+ "pins": {"hand_r": [111, 154]}
},
{
"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]}
+ "root": {"pos": [190, 106], "yaw": 180, "pitch": 81},
+ "spine": [0, 0],
+ "neck": 16, "head": -72,
+ "shoulder_r": 81, "elbow_r": 0,
+ "shoulder_l": 81, "elbow_l": 0,
+ "hip_r": 74, "knee_r": 83, "ankle_r": -55,
+ "hip_l": 74, "knee_l": 83, "ankle_l": -55,
+ "pins": {"hand_l": [105, 152], "hand_r": [111, 154]}
},
{
"hold": 1.4, "tween": 0.8,
- "root": [190, 106],
- "spine": [171, 171], "neck": 187, "gaze": 195,
- "arm_r": [-90, -90], "arm_l": [161, 161],
- "leg_r": [15, 15], "leg_l": [-83, 0],
- "pins": {"hand_r": [105, 152]}
+ "root": {"pos": [190, 106], "yaw": 180, "pitch": 81},
+ "spine": [0, 0],
+ "neck": 16, "head": -82,
+ "shoulder_r": 190, "elbow_r": 0,
+ "shoulder_l": 81, "elbow_l": 0,
+ "hip_r": 74, "knee_r": 83, "ankle_r": -55,
+ "hip_l": -24, "knee_l": 0, "ankle_l": -25,
+ "pins": {"hand_l": [105, 152]}
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Calfs.motion.json b/Workouts/Resources/ExerciseMotions/Calfs.motion.json
index ce2a646..0dc1341 100644
--- a/Workouts/Resources/ExerciseMotions/Calfs.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Calfs.motion.json
@@ -2,7 +2,6 @@
"name": "Calfs",
"primary": 2,
"working": ["leg_r", "leg_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[124, 126], [158, 124]], "w": 9},
@@ -18,18 +17,24 @@
"frames": [
{
"hold": 0.4, "tween": 0.9,
- "root": [140, 118],
- "spine": [100, 96], "neck": 80, "gaze": -10,
- "arm_r": [-40, -15], "arm_l": [-40, -15],
- "leg_r": [-18, -8], "leg_l": [-18, -8],
+ "root": {"pos": [140, 118], "pitch": -10},
+ "spine": [0, 4],
+ "neck": 16,
+ "shoulder_r": 44, "elbow_r": 25,
+ "shoulder_l": 44, "elbow_l": 25,
+ "hip_r": 62, "knee_r": -10, "ankle_r": 12,
+ "hip_l": 62, "knee_l": -10, "ankle_l": 12,
"pins": {"hand_r": [166, 74], "hand_l": [170, 76], "foot_r": [212, 148], "foot_l": [216, 150]}
},
{
"hold": 0.4, "tween": 1.2,
- "root": [140, 118],
- "spine": [100, 96], "neck": 80, "gaze": -10,
- "arm_r": [-40, -15], "arm_l": [-40, -15],
- "leg_r": [-14, 4], "leg_l": [-14, 4],
+ "root": {"pos": [140, 118], "pitch": -10},
+ "spine": [0, 4],
+ "neck": 16,
+ "shoulder_r": 44, "elbow_r": 25,
+ "shoulder_l": 44, "elbow_l": 25,
+ "hip_r": 66, "knee_r": -18, "ankle_r": -18,
+ "hip_l": 66, "knee_l": -18, "ankle_l": -18,
"pins": {"hand_r": [166, 74], "hand_l": [170, 76], "foot_r": [212, 139], "foot_l": [216, 141]}
}
]
diff --git a/Workouts/Resources/ExerciseMotions/Cat-Cow.motion.json b/Workouts/Resources/ExerciseMotions/Cat-Cow.motion.json
index 699e99d..cce1752 100644
--- a/Workouts/Resources/ExerciseMotions/Cat-Cow.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Cat-Cow.motion.json
@@ -5,19 +5,25 @@
"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]}
+ "root": {"pos": [190, 106], "yaw": 180, "pitch": 98},
+ "spine": [0, -33],
+ "neck": 30, "head": -111,
+ "shoulder_r": 65, "elbow_r": 0,
+ "shoulder_l": 65, "elbow_l": 0,
+ "hip_r": 91, "knee_r": 83, "ankle_r": -55,
+ "hip_l": 91, "knee_l": 83, "ankle_l": -55,
+ "pins": {"hand_l": [105, 152], "hand_r": [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]}
+ "root": {"pos": [190, 106], "yaw": 180, "pitch": 65},
+ "spine": [0, 37],
+ "neck": 23, "head": -80,
+ "shoulder_r": 102, "elbow_r": 0,
+ "shoulder_l": 102, "elbow_l": 0,
+ "hip_r": 58, "knee_r": 83, "ankle_r": -55,
+ "hip_l": 58, "knee_l": 83, "ankle_l": -55,
+ "pins": {"hand_l": [105, 152], "hand_r": [111, 154]}
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Chest Press.motion.json b/Workouts/Resources/ExerciseMotions/Chest Press.motion.json
index 8b8cf53..2369b0a 100644
--- a/Workouts/Resources/ExerciseMotions/Chest Press.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Chest Press.motion.json
@@ -2,7 +2,6 @@
"name": "Chest Press",
"primary": 2,
"working": ["arm_r", "arm_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[114, 132], [86, 40]], "w": 8},
@@ -15,18 +14,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.1,
- "root": [116, 122],
- "spine": [104, 100], "neck": 84, "gaze": 5,
- "arm_r": [-100, 40], "arm_l": [-100, 40],
- "leg_r": [-22, -72], "leg_l": [-22, -72],
+ "root": {"pos": [116, 122], "pitch": -14},
+ "spine": [0, 4],
+ "neck": 16, "head": -11,
+ "shoulder_r": -20, "elbow_r": 140,
+ "shoulder_l": -20, "elbow_l": 140,
+ "hip_r": 54, "knee_r": 50,
+ "hip_l": 54, "knee_l": 50,
"pins": {"foot_r": [158, 151], "foot_l": [162, 152]}
},
{
"hold": 0.4, "tween": 1.1,
- "root": [116, 122],
- "spine": [104, 100], "neck": 84, "gaze": 5,
- "arm_r": [-6, -14], "arm_l": [-6, -14],
- "leg_r": [-22, -72], "leg_l": [-22, -72],
+ "root": {"pos": [116, 122], "pitch": -14},
+ "spine": [0, 4],
+ "neck": 16, "head": -11,
+ "shoulder_r": 74, "elbow_r": -8,
+ "shoulder_l": 74, "elbow_l": -8,
+ "hip_r": 54, "knee_r": 50,
+ "hip_l": 54, "knee_l": 50,
"pins": {"foot_r": [158, 151], "foot_l": [162, 152]}
}
]
diff --git a/Workouts/Resources/ExerciseMotions/Dead Bug.motion.json b/Workouts/Resources/ExerciseMotions/Dead Bug.motion.json
index 74bc740..c6d7bb4 100644
--- a/Workouts/Resources/ExerciseMotions/Dead Bug.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Dead Bug.motion.json
@@ -5,31 +5,43 @@
"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]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_r": 90, "elbow_r": 0,
+ "shoulder_l": 90, "elbow_l": 0,
+ "hip_r": 64, "knee_r": 74,
+ "hip_l": 64, "knee_l": 74
},
{
"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]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_r": 135, "elbow_r": 0,
+ "shoulder_l": 90, "elbow_l": 0,
+ "hip_r": 64, "knee_r": 74,
+ "hip_l": 16, "knee_l": 0
},
{
"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]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_r": 90, "elbow_r": 0,
+ "shoulder_l": 90, "elbow_l": 0,
+ "hip_r": 64, "knee_r": 74,
+ "hip_l": 64, "knee_l": 74
},
{
"hold": 0.9, "tween": 0.8,
- "root": [185, 148],
- "spine": [180, 180], "neck": 168, "gaze": 90,
- "arm_r": [90, 90], "arm_l": [135, 135],
- "leg_r": [16, 16], "leg_l": [64, -10]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_r": 90, "elbow_r": 0,
+ "shoulder_l": 135, "elbow_l": 0,
+ "hip_r": 16, "knee_r": 0,
+ "hip_l": 64, "knee_l": 74
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Hollow Body Hold.motion.json b/Workouts/Resources/ExerciseMotions/Hollow Body Hold.motion.json
index 8ed3760..d9319c3 100644
--- a/Workouts/Resources/ExerciseMotions/Hollow Body Hold.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Hollow Body Hold.motion.json
@@ -6,17 +6,21 @@
"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]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_r": 180, "elbow_r": 0,
+ "hip_r": 0, "knee_r": 0,
+ "hip_l": 0, "knee_l": 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]
+ "root": {"pos": [185, 148], "pitch": -84},
+ "spine": [0, 14],
+ "neck": 20,
+ "shoulder_r": 175, "elbow_r": 0,
+ "hip_r": 18, "knee_r": 0,
+ "hip_l": 18, "knee_l": 0
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Lat Pull Down.motion.json b/Workouts/Resources/ExerciseMotions/Lat Pull Down.motion.json
index 6d4170a..78202df 100644
--- a/Workouts/Resources/ExerciseMotions/Lat Pull Down.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Lat Pull Down.motion.json
@@ -2,7 +2,6 @@
"name": "Lat Pull Down",
"primary": 2,
"working": ["arm_r", "arm_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[126, 130], [162, 130]], "w": 8},
@@ -15,21 +14,25 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [150, 122],
- "spine": [93, 90], "neck": 85, "gaze": 55,
- "arm_r": [35, 35], "arm_l": [35, 35],
- "leg_r": [-12, -80], "leg_l": [-12, -80],
- "pins": {"hand_r": [194, 1], "hand_l": [198, 3],
- "foot_r": [193, 150], "foot_l": [197, 151]}
+ "root": {"pos": [150, 122], "pitch": -3},
+ "spine": [0, 3],
+ "neck": 5, "head": -60,
+ "shoulder_r": 125, "elbow_r": 0,
+ "shoulder_l": 125, "elbow_l": 0,
+ "hip_r": 75, "knee_r": 68,
+ "hip_l": 75, "knee_l": 68,
+ "pins": {"hand_r": [194, 1], "hand_l": [198, 3], "foot_r": [193, 150], "foot_l": [197, 151]}
},
{
"hold": 0.4, "tween": 1.2,
- "root": [150, 122],
- "spine": [93, 90], "neck": 85, "gaze": 30,
- "arm_r": [-38, 38], "arm_l": [-38, 38],
- "leg_r": [-12, -80], "leg_l": [-12, -80],
- "pins": {"hand_r": [194, 35], "hand_l": [198, 37],
- "foot_r": [193, 150], "foot_l": [197, 151]}
+ "root": {"pos": [150, 122], "pitch": -3},
+ "spine": [0, 3],
+ "neck": 5, "head": -35,
+ "shoulder_r": 52, "elbow_r": 76,
+ "shoulder_l": 52, "elbow_l": 76,
+ "hip_r": 75, "knee_r": 68,
+ "hip_l": 75, "knee_l": 68,
+ "pins": {"hand_r": [194, 35], "hand_l": [198, 37], "foot_r": [193, 150], "foot_l": [197, 151]}
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Leg Curl.motion.json b/Workouts/Resources/ExerciseMotions/Leg Curl.motion.json
index 0151be6..5e24c99 100644
--- a/Workouts/Resources/ExerciseMotions/Leg Curl.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Leg Curl.motion.json
@@ -2,7 +2,6 @@
"name": "Leg Curl",
"primary": 2,
"working": ["leg_r", "leg_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[110, 122], [86, 48]], "w": 9},
@@ -18,17 +17,23 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [118, 116],
- "spine": [110, 106], "neck": 74, "gaze": -20,
- "arm_r": [-75, -60], "arm_l": [-75, -60],
- "leg_r": [10, 4], "leg_l": [10, 4]
+ "root": {"pos": [118, 116], "pitch": -20},
+ "spine": [0, 4],
+ "neck": 32, "head": 4,
+ "shoulder_r": -1, "elbow_r": 15,
+ "shoulder_l": -1, "elbow_l": 15,
+ "hip_r": 80, "knee_r": 6,
+ "hip_l": 80, "knee_l": 6
},
{
"hold": 0.4, "tween": 1.2,
- "root": [118, 116],
- "spine": [110, 106], "neck": 74, "gaze": -20,
- "arm_r": [-75, -60], "arm_l": [-75, -60],
- "leg_r": [10, -95], "leg_l": [10, -95]
+ "root": {"pos": [118, 116], "pitch": -20},
+ "spine": [0, 4],
+ "neck": 32, "head": 4,
+ "shoulder_r": -1, "elbow_r": 15,
+ "shoulder_l": -1, "elbow_l": 15,
+ "hip_r": 80, "knee_r": 105,
+ "hip_l": 80, "knee_l": 105
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Leg Extension.motion.json b/Workouts/Resources/ExerciseMotions/Leg Extension.motion.json
index 367717c..79e628c 100644
--- a/Workouts/Resources/ExerciseMotions/Leg Extension.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Leg Extension.motion.json
@@ -2,7 +2,6 @@
"name": "Leg Extension",
"primary": 2,
"working": ["leg_r", "leg_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[110, 122], [86, 48]], "w": 9},
@@ -17,17 +16,23 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [118, 116],
- "spine": [110, 106], "neck": 74, "gaze": -20,
- "arm_r": [-75, -60], "arm_l": [-75, -60],
- "leg_r": [10, -83], "leg_l": [10, -83]
+ "root": {"pos": [118, 116], "pitch": -20},
+ "spine": [0, 4],
+ "neck": 32, "head": 4,
+ "shoulder_r": -1, "elbow_r": 15,
+ "shoulder_l": -1, "elbow_l": 15,
+ "hip_r": 80, "knee_r": 93,
+ "hip_l": 80, "knee_l": 93
},
{
"hold": 0.4, "tween": 1.2,
- "root": [118, 116],
- "spine": [110, 106], "neck": 74, "gaze": -20,
- "arm_r": [-75, -60], "arm_l": [-75, -60],
- "leg_r": [10, 4], "leg_l": [10, 4]
+ "root": {"pos": [118, 116], "pitch": -20},
+ "spine": [0, 4],
+ "neck": 32, "head": 4,
+ "shoulder_r": -1, "elbow_r": 15,
+ "shoulder_l": -1, "elbow_l": 15,
+ "hip_r": 80, "knee_r": 6,
+ "hip_l": 80, "knee_l": 6
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Leg Press.motion.json b/Workouts/Resources/ExerciseMotions/Leg Press.motion.json
index c28c4eb..78ee2e0 100644
--- a/Workouts/Resources/ExerciseMotions/Leg Press.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Leg Press.motion.json
@@ -2,7 +2,6 @@
"name": "Leg Press",
"primary": 2,
"working": ["leg_r", "leg_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[134, 123], [96, 36]], "w": 9},
@@ -17,18 +16,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [140, 116],
- "spine": [116, 112], "neck": 75, "gaze": -15,
- "arm_r": [-75, -20], "arm_l": [-75, -20],
- "leg_r": [40, -58], "leg_l": [40, -58],
+ "root": {"pos": [140, 116], "pitch": -26},
+ "spine": [0, 4],
+ "neck": 37,
+ "shoulder_r": -7, "elbow_r": 55,
+ "shoulder_l": -7, "elbow_l": 55,
+ "hip_r": 104, "knee_r": 98,
+ "hip_l": 104, "knee_l": 98,
"pins": {"foot_r": [185, 102], "foot_l": [189, 104]}
},
{
"hold": 0.4, "tween": 1.2,
- "root": [140, 116],
- "spine": [116, 112], "neck": 75, "gaze": -15,
- "arm_r": [-75, -20], "arm_l": [-75, -20],
- "leg_r": [20, -5], "leg_l": [20, -5],
+ "root": {"pos": [140, 116], "pitch": -26},
+ "spine": [0, 4],
+ "neck": 37,
+ "shoulder_r": -7, "elbow_r": 55,
+ "shoulder_l": -7, "elbow_l": 55,
+ "hip_r": 84, "knee_r": 25,
+ "hip_l": 84, "knee_l": 25,
"pins": {"foot_r": [220, 101], "foot_l": [224, 103]}
}
]
diff --git a/Workouts/Resources/ExerciseMotions/Leg Raises.motion.json b/Workouts/Resources/ExerciseMotions/Leg Raises.motion.json
index 3a26817..f1dc7ae 100644
--- a/Workouts/Resources/ExerciseMotions/Leg Raises.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Leg Raises.motion.json
@@ -6,17 +6,21 @@
"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]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_l": -2, "elbow_l": 0,
+ "hip_r": 60, "knee_r": 0,
+ "hip_l": 60, "knee_l": 0
},
{
"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]
+ "root": {"pos": [185, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_l": -2, "elbow_l": 0,
+ "hip_r": 8, "knee_r": 0,
+ "hip_l": 8, "knee_l": 0
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Plank.motion.json b/Workouts/Resources/ExerciseMotions/Plank.motion.json
index 8810dec..abeec08 100644
--- a/Workouts/Resources/ExerciseMotions/Plank.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Plank.motion.json
@@ -5,17 +5,23 @@
"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]
+ "root": {"pos": [172, 114], "yaw": 180, "pitch": 95},
+ "spine": [0, 0],
+ "neck": 0, "head": -65,
+ "shoulder_r": 95, "elbow_r": 90,
+ "shoulder_l": 95, "elbow_l": 90,
+ "hip_r": 60, "knee_r": 55,
+ "hip_l": 60, "knee_l": 55
},
{
"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]
+ "root": {"pos": [168, 137], "yaw": 180, "pitch": 80},
+ "spine": [0, 0],
+ "neck": 0, "head": -50,
+ "shoulder_r": 80, "elbow_r": 90,
+ "shoulder_l": 80, "elbow_l": 90,
+ "hip_r": 0, "knee_r": 0,
+ "hip_l": 0, "knee_l": 0
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Reverse Crunch.motion.json b/Workouts/Resources/ExerciseMotions/Reverse Crunch.motion.json
index 7be8997..202c570 100644
--- a/Workouts/Resources/ExerciseMotions/Reverse Crunch.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Reverse Crunch.motion.json
@@ -6,17 +6,21 @@
"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]
+ "root": {"pos": [172, 148], "pitch": -90},
+ "spine": [0, 0],
+ "neck": 12, "head": -12,
+ "shoulder_l": -2, "elbow_l": 0,
+ "hip_r": 100, "knee_r": 120,
+ "hip_l": 100, "knee_l": 120
},
{
"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]
+ "root": {"pos": [166, 136], "pitch": -105},
+ "spine": [0, 20],
+ "neck": -7, "head": 2,
+ "shoulder_l": 3, "elbow_l": 0,
+ "hip_r": 100, "knee_r": 125,
+ "hip_l": 100, "knee_l": 125
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Rotary.motion.json b/Workouts/Resources/ExerciseMotions/Rotary.motion.json
index 1fbab42..9f2bd89 100644
--- a/Workouts/Resources/ExerciseMotions/Rotary.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Rotary.motion.json
@@ -1,8 +1,8 @@
{
"name": "Rotary",
"primary": 1,
+ "camera": {"yaw": 90},
"working": ["spine"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[138, 130], [182, 130]], "w": 8},
@@ -14,19 +14,23 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [160, 122],
- "spine": [104, 109], "neck": 109,
- "arm_r": [-50, -85], "arm_l": [-55, -90],
- "leg_r": [-62, -82], "leg_l": [-118, -98],
- "pins": {"hand_r": [112, 72], "hand_l": [118, 74], "foot_r": [190, 150], "foot_l": [130, 150]}
+ "root": {"pos": [160, 122]},
+ "spine": [{"rotation": -28}, {"rotation": -22}],
+ "neck": {"rotation": 18},
+ "shoulder_r": 70, "elbow_r": 10,
+ "shoulder_l": 70, "elbow_l": 10,
+ "hip_r": {"flexion": 82, "abduction": 16}, "knee_r": 70,
+ "hip_l": {"flexion": 82, "abduction": 16}, "knee_l": 70
},
{
"hold": 0.4, "tween": 1.0,
- "root": [160, 122],
- "spine": [76, 71], "neck": 71,
- "arm_r": [-125, -95], "arm_l": [-130, -90],
- "leg_r": [-62, -82], "leg_l": [-118, -98],
- "pins": {"hand_r": [208, 72], "hand_l": [202, 74], "foot_r": [190, 150], "foot_l": [130, 150]}
+ "root": {"pos": [160, 122]},
+ "spine": [{"rotation": 28}, {"rotation": 22}],
+ "neck": {"rotation": -18},
+ "shoulder_r": 70, "elbow_r": 10,
+ "shoulder_l": 70, "elbow_l": 10,
+ "hip_r": {"flexion": 82, "abduction": 16}, "knee_r": 70,
+ "hip_l": {"flexion": 82, "abduction": 16}, "knee_l": 70
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Seated Row.motion.json b/Workouts/Resources/ExerciseMotions/Seated Row.motion.json
index 3e53395..dfeae9c 100644
--- a/Workouts/Resources/ExerciseMotions/Seated Row.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Seated Row.motion.json
@@ -2,7 +2,6 @@
"name": "Seated Row",
"primary": 2,
"working": ["arm_r", "arm_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[122, 132], [156, 132]], "w": 8},
@@ -15,21 +14,25 @@
"frames": [
{
"hold": 0.4, "tween": 1.0,
- "root": [140, 122],
- "spine": [85, 82], "neck": 80, "gaze": 5,
- "arm_r": [-40, -8], "arm_l": [-40, -8],
- "leg_r": [-8, -75], "leg_l": [-8, -75],
- "pins": {"hand_r": [200, 62], "hand_l": [204, 64],
- "foot_r": [188, 148], "foot_l": [192, 149]}
+ "root": {"pos": [140, 122], "pitch": 5},
+ "spine": [0, 3],
+ "neck": 2, "head": -15,
+ "shoulder_r": 58, "elbow_r": 32,
+ "shoulder_l": 58, "elbow_l": 32,
+ "hip_r": 87, "knee_r": 67,
+ "hip_l": 87, "knee_l": 67,
+ "pins": {"hand_r": [200, 62], "hand_l": [204, 64], "foot_r": [188, 148], "foot_l": [192, 149]}
},
{
"hold": 0.4, "tween": 1.2,
- "root": [140, 122],
- "spine": [85, 82], "neck": 80, "gaze": 5,
- "arm_r": [-120, -35], "arm_l": [-120, -35],
- "leg_r": [-8, -75], "leg_l": [-8, -75],
- "pins": {"hand_r": [158, 72], "hand_l": [162, 74],
- "foot_r": [188, 148], "foot_l": [192, 149]}
+ "root": {"pos": [140, 122], "pitch": 5},
+ "spine": [0, 3],
+ "neck": 2, "head": -15,
+ "shoulder_r": -22, "elbow_r": 85,
+ "shoulder_l": -22, "elbow_l": 85,
+ "hip_r": 87, "knee_r": 67,
+ "hip_l": 87, "knee_l": 67,
+ "pins": {"hand_r": [158, 72], "hand_l": [162, 74], "foot_r": [188, 148], "foot_l": [192, 149]}
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Shoulder Press.motion.json b/Workouts/Resources/ExerciseMotions/Shoulder Press.motion.json
index 2502f62..17bf2c6 100644
--- a/Workouts/Resources/ExerciseMotions/Shoulder Press.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Shoulder Press.motion.json
@@ -2,7 +2,6 @@
"name": "Shoulder Press",
"primary": 2,
"working": ["arm_r", "arm_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[116, 140], [88, 50]], "w": 8},
@@ -15,18 +14,24 @@
"frames": [
{
"hold": 0.4, "tween": 1.1,
- "root": [120, 130],
- "spine": [110, 104], "neck": 80, "gaze": 8,
- "arm_r": [-60, 90], "arm_l": [-60, 90],
- "leg_r": [-25, -72], "leg_l": [-25, -72],
+ "root": {"pos": [120, 130], "pitch": -20},
+ "spine": [0, 6],
+ "neck": 24, "head": -18,
+ "shoulder_r": 16, "elbow_r": 150,
+ "shoulder_l": 16, "elbow_l": 150,
+ "hip_r": 45, "knee_r": 47,
+ "hip_l": 45, "knee_l": 47,
"pins": {"foot_r": [164, 151], "foot_l": [168, 152]}
},
{
"hold": 0.4, "tween": 1.1,
- "root": [120, 130],
- "spine": [110, 104], "neck": 80, "gaze": 8,
- "arm_r": [54, 36], "arm_l": [54, 36],
- "leg_r": [-25, -72], "leg_l": [-25, -72],
+ "root": {"pos": [120, 130], "pitch": -20},
+ "spine": [0, 6],
+ "neck": 24, "head": -18,
+ "shoulder_r": 130, "elbow_r": -18,
+ "shoulder_l": 130, "elbow_l": -18,
+ "hip_r": 45, "knee_r": 47,
+ "hip_l": 45, "knee_l": 47,
"pins": {"foot_r": [164, 151], "foot_l": [168, 152]}
}
]
diff --git a/Workouts/Resources/ExerciseMotions/Side Plank.motion.json b/Workouts/Resources/ExerciseMotions/Side Plank.motion.json
index 6a2209a..0fde3ac 100644
--- a/Workouts/Resources/ExerciseMotions/Side Plank.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Side Plank.motion.json
@@ -5,19 +5,25 @@
"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]}
+ "root": {"pos": [174, 150], "yaw": 180, "pitch": 72},
+ "spine": [0, 0],
+ "neck": 0, "head": -77,
+ "shoulder_r": 2, "elbow_r": -5,
+ "shoulder_l": 72, "elbow_l": 90,
+ "hip_r": -17, "knee_r": 0,
+ "hip_l": -17, "knee_l": 0,
+ "pins": {"hand_l": [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]}
+ "root": {"pos": [174, 137], "yaw": 180, "pitch": 80},
+ "spine": [0, 0],
+ "neck": 0, "head": -80,
+ "shoulder_r": -90, "elbow_r": 0,
+ "shoulder_l": 80, "elbow_l": 90,
+ "hip_r": 0, "knee_r": 0,
+ "hip_l": 0, "knee_l": 0,
+ "pins": {"hand_l": [60, 152]}
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/Tricep Press.motion.json b/Workouts/Resources/ExerciseMotions/Tricep Press.motion.json
index 8d34971..bf77781 100644
--- a/Workouts/Resources/ExerciseMotions/Tricep Press.motion.json
+++ b/Workouts/Resources/ExerciseMotions/Tricep Press.motion.json
@@ -2,7 +2,6 @@
"name": "Tricep Press",
"primary": 2,
"working": ["arm_r", "arm_l"],
- "hide": [],
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[116, 134], [92, 42]], "w": 8},
@@ -15,21 +14,25 @@
"frames": [
{
"hold": 0.4, "tween": 1.1,
- "root": [118, 124],
- "spine": [100, 98], "neck": 86, "gaze": 6,
- "arm_r": [-108, 12], "arm_l": [-108, 12],
- "leg_r": [-24, -74], "leg_l": [-24, -74],
- "pins": {"hand_r": [124, 62], "hand_l": [128, 64],
- "foot_r": [156, 151], "foot_l": [160, 152]}
+ "root": {"pos": [118, 124], "pitch": -10},
+ "spine": [0, 2],
+ "neck": 12, "head": -10,
+ "shoulder_r": -26, "elbow_r": 120,
+ "shoulder_l": -26, "elbow_l": 120,
+ "hip_r": 56, "knee_r": 50,
+ "hip_l": 56, "knee_l": 50,
+ "pins": {"hand_r": [124, 62], "hand_l": [128, 64], "foot_r": [156, 151], "foot_l": [160, 152]}
},
{
"hold": 0.4, "tween": 1.1,
- "root": [118, 124],
- "spine": [100, 98], "neck": 86, "gaze": 6,
- "arm_r": [-81, -61], "arm_l": [-81, -61],
- "leg_r": [-24, -74], "leg_l": [-24, -74],
- "pins": {"hand_r": [124, 96], "hand_l": [128, 98],
- "foot_r": [156, 151], "foot_l": [160, 152]}
+ "root": {"pos": [118, 124], "pitch": -10},
+ "spine": [0, 2],
+ "neck": 12, "head": -10,
+ "shoulder_r": 1, "elbow_r": 20,
+ "shoulder_l": 1, "elbow_l": 20,
+ "hip_r": 56, "knee_r": 50,
+ "hip_l": 56, "knee_l": 50,
+ "pins": {"hand_r": [124, 96], "hand_l": [128, 98], "foot_r": [156, 151], "foot_l": [160, 152]}
}
]
}
diff --git a/Workouts/Resources/ExerciseMotions/body.json b/Workouts/Resources/ExerciseMotions/body.json
deleted file mode 100644
index 87a8fe9..0000000
--- a/Workouts/Resources/ExerciseMotions/body.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "neutral": {
- "headR": 10,
- "neck": 20,
- "spine1": 43,
- "spine2": 42,
- "upperArm": 30,
- "foreArm": 30,
- "thigh": 46,
- "shin": 40,
- "leftOffset": [6, 2]
- },
- "frontal": {
- "headR": 10,
- "neck": 20,
- "spine1": 43,
- "spine2": 42,
- "upperArm": 30,
- "foreArm": 30,
- "thigh": 22,
- "shin": 26,
- "leftOffset": [6, 2]
- },
- "female": {
- "headR": 9,
- "neck": 19,
- "spine1": 40,
- "spine2": 39,
- "upperArm": 27,
- "foreArm": 27,
- "thigh": 47,
- "shin": 41,
- "leftOffset": [6, 2]
- }
-}
diff --git a/Workouts/Resources/ExerciseMotions/skeleton.json b/Workouts/Resources/ExerciseMotions/skeleton.json
new file mode 100644
index 0000000..2f23eab
--- /dev/null
+++ b/Workouts/Resources/ExerciseMotions/skeleton.json
@@ -0,0 +1,56 @@
+{
+ "profiles": {
+ "neutral": {
+ "headR": 10,
+ "neck": 20,
+ "spine1": 43,
+ "spine2": 42,
+ "upperArm": 30,
+ "foreArm": 30,
+ "thigh": 46,
+ "shin": 40,
+ "foot": 11,
+ "shoulderHalf": 7,
+ "hipHalf": 5,
+ "farOffset": [6, 2]
+ },
+ "female": {
+ "headR": 9,
+ "neck": 19,
+ "spine1": 40,
+ "spine2": 39,
+ "upperArm": 27,
+ "foreArm": 27,
+ "thigh": 47,
+ "shin": 41,
+ "foot": 10,
+ "shoulderHalf": 5.5,
+ "hipHalf": 6.5,
+ "farOffset": [6, 2]
+ },
+ "male": {
+ "headR": 10,
+ "neck": 20,
+ "spine1": 44,
+ "spine2": 43,
+ "upperArm": 31,
+ "foreArm": 31,
+ "thigh": 47,
+ "shin": 41,
+ "foot": 11.5,
+ "shoulderHalf": 9,
+ "hipHalf": 4.5,
+ "farOffset": [6, 2]
+ }
+ },
+ "joints": {
+ "spine": {"flexion": [-40, 55], "lateral": [-35, 35], "rotation": [-55, 55]},
+ "neck": {"flexion": [-55, 55], "rotation": [-80, 80]},
+ "head": {"flexion": [-120, 120]},
+ "shoulder": {"flexion": [-70, 195], "abduction": [-40, 185], "rotation": [-95, 95]},
+ "elbow": {"flexion": [-10, 155]},
+ "hip": {"flexion": [-30, 130], "abduction": [-40, 55], "rotation": [-50, 50]},
+ "knee": {"flexion": [-10, 150]},
+ "ankle": {"flexion": [-60, 30]}
+ }
+}
diff --git a/WorkoutsTests/ExerciseMotionTests.swift b/WorkoutsTests/ExerciseMotionTests.swift
index 93063b1..b8aedd6 100644
--- a/WorkoutsTests/ExerciseMotionTests.swift
+++ b/WorkoutsTests/ExerciseMotionTests.swift
@@ -2,21 +2,22 @@ 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.
+/// Locks the Swift motion solver to the Exercise Library's anatomical 3D reference
+/// (`Exercise Library/kinematics.py` + `render.py`): the bundled rig resources must
+/// decode, and the FK / IK / frame-geometry / tween math must reproduce the projected
+/// geometry the Python computes — captured in `Fixtures/figure-fixtures.json`. 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")
- // Bird Dog alternates sides, so all four limbs are in the working set and
- // the loop is four key frames (lift right/left pair, then the opposite pair).
+ // Bird Dog alternates sides, so all four limbs are in the working set and the
+ // loop is four key frames (support both hands, lift one arm/leg pair, and back).
#expect(resources.motion.frames.count == 4)
- #expect(resources.motion.working == ["arm_r", "leg_l", "arm_l", "leg_r"])
- #expect(resources.body.upperArm == 30)
- #expect(MotionTimeline(motion: resources.motion, body: resources.body) != nil)
+ #expect(resources.motion.working == ["arm_l", "leg_r", "arm_r", "leg_l"])
+ #expect(resources.profile.upperArm == 30)
+ #expect(MotionTimeline(motion: resources.motion, profile: resources.profile) != nil)
}
@Test func exerciseWithoutBundledMotionLoadsNothing() {
@@ -24,53 +25,53 @@ struct ExerciseMotionTests {
#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 {
+ /// Resolving Bird Dog's first key frame must reproduce the reference IK: both
+ /// pinned hands land exactly on their canvas targets, the head projects to the
+ /// reference point, and the far right arm's solved anatomical angles match.
+ @Test func birdDogFrameZeroMatchesReference() throws {
let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog"))
- let pose = MotionSolver.normalize(resources.motion.frames[0], body: resources.body)
+ let cam = resources.motion.camera?.yaw ?? 0
+ let frame = MotionSolver.normalize(resources.motion.frames[0])
+ let (resolved, geo) = MotionSolver.frameGeometry(frame, prof: resources.profile, cam: cam)
- 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)
+ let handR = try #require(geo.limbs[.armR]?.last)
+ #expect(abs(handR.x - 111) < 1e-3)
+ #expect(abs(handR.y - 154) < 1e-3)
+ let handL = try #require(geo.limbs[.armL]?.last)
+ #expect(abs(handL.x - 105) < 1e-3)
+ #expect(abs(handL.y - 152) < 1e-3)
#expect(abs(geo.headCenter.x - 86.195568) < 1e-4)
#expect(abs(geo.headCenter.y - 95.140457) < 1e-4)
+
+ // The right arm is the far member here, so its angles solve against the
+ // offset target; the drawn hand still lands on the authored pin above.
+ #expect(geo.shade[.armR] == .far)
+ #expect(geo.shade[.armL] == .near)
+ #expect(abs(resolved.shoulderR.flexion - 73.289190) < 1e-4)
+ #expect(abs(resolved.elbowR.flexion - 17.443758) < 1e-4)
+ #expect(geo.order == ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"])
}
- /// 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.
+ /// Mid-tween of resolved frames 1→2: a hand pinned in BOTH frames stays planted
+ /// exactly; a pin present in only one frame releases and swings off its target.
@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))
+ let cam = resources.motion.camera?.yaw ?? 0
+ let r0 = MotionSolver.frameGeometry(MotionSolver.normalize(resources.motion.frames[0]), prof: resources.profile, cam: cam).0
+ let r1 = MotionSolver.frameGeometry(MotionSolver.normalize(resources.motion.frames[1]), prof: resources.profile, cam: cam).0
+ let mid = MotionSolver.lerpFrames(r0, r1, MotionSolver.ease(0.5))
- #expect(mid.pins["hand_l"] != nil)
- #expect(mid.pins["hand_r"] == nil)
+ #expect(mid.pins["hand_r"] != nil)
+ #expect(mid.pins["hand_l"] == 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 (_, geo) = MotionSolver.frameGeometry(mid, prof: resources.profile, cam: cam)
+ let plantedHand = try #require(geo.limbs[.armR]?.last)
+ #expect(abs(plantedHand.x - 111) < 1e-3)
+ #expect(abs(plantedHand.y - 154) < 1e-3)
- let liftingArm = try #require(mid.limbs[.armR])
- #expect(abs(liftingArm[0] - (-140.644595)) < 1e-4)
- #expect(abs(liftingArm[1] - (-149.366474)) < 1e-4)
+ let releasedHand = try #require(geo.limbs[.armL]?.last)
+ #expect(hypot(releasedHand.x - 105, releasedHand.y - 152) > 1)
}
/// Every exported motion in the bundle decodes and builds a playable timeline.
@@ -81,8 +82,123 @@ struct ExerciseMotionTests {
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))
+ let timeline = try #require(MotionTimeline(motion: resources.motion, profile: resources.profile))
#expect(timeline.duration > 0)
}
}
+
+ /// The projected-geometry ground truth: for every exercise and key frame, the draw
+ /// order and near/far shading must match exactly and every point land within 0.5 px
+ /// of the reference, plus the mid-tween sample and (Bird Dog) two orbit-camera views.
+ @Test func figureFixturesMatchReference() throws {
+ let bundle = Bundle(for: FigureFixtureMarker.self)
+ let url = try #require(
+ bundle.url(forResource: "figure-fixtures", withExtension: "json"),
+ "figure-fixtures.json must be bundled as a WorkoutsTests resource (see project.yml)")
+ let fixtures = try JSONDecoder().decode(FigureFixtures.self, from: Data(contentsOf: url))
+ #expect(fixtures.exercises.count == 22)
+
+ for exercise in fixtures.exercises {
+ let resources = try #require(ExerciseMotionLibrary.resources(for: exercise.name),
+ "no bundled motion for \(exercise.name)")
+ let cam = resources.motion.camera?.yaw ?? 0
+ let profile = resources.profile
+ let norms = resources.motion.frames.map { MotionSolver.normalize($0) }
+
+ var resolved: [NormalizedFrame] = []
+ for (index, frame) in norms.enumerated() {
+ let (resolvedFrame, geo) = MotionSolver.frameGeometry(frame, prof: profile, cam: cam)
+ resolved.append(resolvedFrame)
+ if index < exercise.frames.count {
+ expectMatch(geo, exercise.frames[index], "\(exercise.name) frame \(index)")
+ }
+ }
+
+ let mid = MotionSolver.lerpFrames(resolved[0], resolved[1], MotionSolver.ease(exercise.tween.t))
+ expectMatch(MotionSolver.frameGeometry(mid, prof: profile, cam: cam).1,
+ exercise.tween.sample, "\(exercise.name) tween")
+
+ for orbit in exercise.orbit ?? [] {
+ expectMatch(MotionSolver.frameGeometry(norms[0], prof: profile, cam: orbit.yaw).1,
+ orbit.sample, "\(exercise.name) orbit \(orbit.yaw)")
+ }
+ }
+ }
+}
+
+/// Marker for locating the test bundle that carries `figure-fixtures.json`.
+private final class FigureFixtureMarker {}
+
+// MARK: - Fixture decoding + comparison
+
+private struct FigureFixtures: Decodable {
+ let profile: String
+ let exercises: [FixtureExercise]
+}
+
+private struct FixtureExercise: Decodable {
+ let name: String
+ let camera: Double
+ let frames: [FixtureSample]
+ let tween: FixtureTween
+ let orbit: [FixtureOrbit]?
+}
+
+private struct FixtureTween: Decodable { let t: Double; let sample: FixtureSample }
+private struct FixtureOrbit: Decodable { let yaw: Double; let sample: FixtureSample }
+
+private struct FixtureSample: Decodable {
+ let order: [String]
+ let shade: [String: String]
+ let spine: [[Double]]
+ let head: [Double]
+ let nose: [[Double]]?
+ let armR: [[Double]]
+ let armL: [[Double]]
+ let legR: [[Double]]
+ let legL: [[Double]]
+
+ enum CodingKeys: String, CodingKey {
+ case order, shade, spine, head, nose
+ case armR = "arm_r", armL = "arm_l", legR = "leg_r", legL = "leg_l"
+ }
+}
+
+private func expectClose(_ point: CGPoint, _ expected: [Double], _ label: String) {
+ #expect(abs(Double(point.x) - expected[0]) < 0.5 && abs(Double(point.y) - expected[1]) < 0.5,
+ "\(label): got (\(point.x), \(point.y)) expected \(expected)")
+}
+
+private func expectChain(_ points: [CGPoint]?, _ expected: [[Double]], _ label: String) {
+ guard let points, points.count == expected.count else {
+ Issue.record("\(label): chain length mismatch")
+ return
+ }
+ for (index, (point, target)) in zip(points, expected).enumerated() {
+ expectClose(point, target, "\(label)[\(index)]")
+ }
+}
+
+private func expectMatch(_ geo: FigureGeometry, _ fixture: FixtureSample, _ label: String) {
+ #expect(geo.order == fixture.order, "\(label) order")
+ let shade = Dictionary(uniqueKeysWithValues: geo.shade.map { ($0.key.rawValue, $0.value == .near ? "near" : "far") })
+ #expect(shade == fixture.shade, "\(label) shade")
+
+ expectChain([geo.spineStart, geo.spineControl, geo.spineEnd], fixture.spine, "\(label).spine")
+ expectClose(geo.headCenter, fixture.head, "\(label).head")
+
+ if let nose = fixture.nose {
+ #expect(geo.noseStart != nil && geo.noseEnd != nil, "\(label): expected a nose tick")
+ if let start = geo.noseStart, let end = geo.noseEnd {
+ expectClose(start, nose[0], "\(label).nose.start")
+ expectClose(end, nose[1], "\(label).nose.end")
+ }
+ } else {
+ #expect(geo.noseStart == nil, "\(label): expected no nose tick")
+ }
+
+ expectChain(geo.limbs[.armR], fixture.armR, "\(label).arm_r")
+ expectChain(geo.limbs[.armL], fixture.armL, "\(label).arm_l")
+ expectChain(geo.limbs[.legR], fixture.legR, "\(label).leg_r")
+ expectChain(geo.limbs[.legL], fixture.legL, "\(label).leg_l")
}
diff --git a/WorkoutsTests/Fixtures/figure-fixtures.json b/WorkoutsTests/Fixtures/figure-fixtures.json
new file mode 100644
index 0000000..e4b4660
--- /dev/null
+++ b/WorkoutsTests/Fixtures/figure-fixtures.json
@@ -0,0 +1 @@
+{"profile": "neutral", "exercises": [{"name": "Abdominal", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[128.0, 126.0], [123.501, 82.657], [125.0, 41.105]], "head": [127.784, 21.299], "nose": [[137.687, 22.691], [144.618, 23.665]], "arm_r": [[125.0, 41.105], [153.596, 32.033], [155.0, 62.0]], "arm_l": [[131.0, 43.105], [159.596, 34.033], [161.0, 64.0]], "leg_r": [[128.0, 126.0], [171.174, 110.126], [168.0, 150.0], [178.965, 150.873]], "leg_l": [[134.0, 128.0], [176.436, 110.247], [172.0, 150.0], [182.932, 151.22]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[128.0, 126.0], [123.113, 75.978], [167.641, 56.656]], "head": [186.184, 49.164], "nose": [[193.616, 55.855], [198.818, 60.539]], "arm_r": [[167.641, 56.656], [195.9, 46.586], [190.0, 76.0]], "arm_l": [[173.641, 58.656], [201.9, 48.586], [196.0, 78.0]], "leg_r": [[128.0, 126.0], [171.174, 110.126], [168.0, 150.0], [178.965, 150.873]], "leg_l": [[134.0, 128.0], [176.436, 110.247], [172.0, 150.0], [182.932, 151.22]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[128.0, 126.0], [122.501, 80.621], [148.0, 44.994]], "head": [160.314, 29.234], "nose": [[169.377, 33.46], [175.721, 36.418]], "arm_r": [[148.0, 44.994], [177.477, 39.416], [172.5, 69.0]], "arm_l": [[154.0, 46.994], [183.477, 41.416], [178.5, 71.0]], "leg_r": [[128.0, 126.0], [171.174, 110.126], [168.0, 150.0], [178.965, 150.873]], "leg_l": [[134.0, 128.0], [176.436, 110.247], [172.0, 150.0], [182.932, 151.22]]}}}, {"name": "Abductor", "camera": 90.0, "frames": [{"order": ["spine", "arm_l", "arm_r", "leg_l", "leg_r", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[160.0, 116.0], [160.0, 72.5], [160.0, 31.0]], "head": [160.0, 11.0], "nose": null, "arm_r": [[153.0, 31.0], [143.729, 59.098], [135.328, 82.362]], "arm_l": [[167.0, 31.0], [176.271, 59.098], [184.672, 82.362]], "leg_r": [[155.0, 116.0], [149.394, 125.493], [147.105, 163.914], [145.922, 160.867]], "leg_l": [[165.0, 116.0], [170.606, 125.493], [172.895, 163.914], [174.078, 160.867]]}, {"order": ["spine", "arm_l", "arm_r", "leg_l", "leg_r", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[160.0, 116.0], [160.0, 72.5], [160.0, 31.0]], "head": [160.0, 11.0], "nose": null, "arm_r": [[153.0, 31.0], [143.729, 59.098], [135.328, 82.362]], "arm_l": [[167.0, 31.0], [176.271, 59.098], [184.672, 82.362]], "leg_r": [[155.0, 116.0], [132.0, 124.283], [122.611, 162.21], [117.754, 158.907]], "leg_l": [[165.0, 116.0], [188.0, 124.283], [197.389, 162.21], [202.246, 158.907]]}], "tween": {"t": 0.5, "sample": {"order": ["spine", "arm_l", "arm_r", "leg_l", "leg_r", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[160.0, 116.0], [160.0, 72.5], [160.0, 31.0]], "head": [160.0, 11.0], "nose": null, "arm_r": [[153.0, 31.0], [143.729, 59.098], [135.328, 82.362]], "arm_l": [[167.0, 31.0], [176.271, 59.098], [184.672, 82.362]], "leg_r": [[155.0, 116.0], [140.404, 125.07], [134.445, 163.318], [131.364, 160.182]], "leg_l": [[165.0, 116.0], [179.596, 125.07], [185.555, 163.318], [188.636, 160.182]]}}}, {"name": "Adductor", "camera": 90.0, "frames": [{"order": ["spine", "arm_l", "arm_r", "leg_l", "leg_r", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[160.0, 116.0], [160.0, 72.5], [160.0, 31.0]], "head": [160.0, 11.0], "nose": null, "arm_r": [[153.0, 31.0], [143.729, 59.098], [135.328, 82.362]], "arm_l": [[167.0, 31.0], [176.271, 59.098], [184.672, 82.362]], "leg_r": [[155.0, 116.0], [132.0, 124.283], [122.611, 162.21], [117.754, 158.907]], "leg_l": [[165.0, 116.0], [188.0, 124.283], [197.389, 162.21], [202.246, 158.907]]}, {"order": ["spine", "arm_l", "arm_r", "leg_l", "leg_r", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[160.0, 116.0], [160.0, 72.5], [160.0, 31.0]], "head": [160.0, 11.0], "nose": null, "arm_r": [[153.0, 31.0], [143.729, 59.098], [135.328, 82.362]], "arm_l": [[167.0, 31.0], [176.271, 59.098], [184.672, 82.362]], "leg_r": [[155.0, 116.0], [149.394, 125.493], [147.105, 163.914], [145.922, 160.867]], "leg_l": [[165.0, 116.0], [170.606, 125.493], [172.895, 163.914], [174.078, 160.867]]}], "tween": {"t": 0.5, "sample": {"order": ["spine", "arm_l", "arm_r", "leg_l", "leg_r", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[160.0, 116.0], [160.0, 72.5], [160.0, 31.0]], "head": [160.0, 11.0], "nose": null, "arm_r": [[153.0, 31.0], [143.729, 59.098], [135.328, 82.362]], "arm_l": [[167.0, 31.0], [176.271, 59.098], [184.672, 82.362]], "leg_r": [[155.0, 116.0], [140.404, 125.07], [134.445, 163.318], [131.364, 160.182]], "leg_l": [[165.0, 116.0], [179.596, 125.07], [185.555, 163.318], [188.636, 160.182]]}}}, {"name": "Arm Curl", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[130.0, 124.0], [130.786, 80.488], [134.43, 39.129]], "head": [137.214, 19.323], "nose": [[146.724, 22.413], [153.382, 24.576]], "arm_r": [[134.43, 39.129], [159.005, 56.336], [174.903, 81.777]], "arm_l": [[140.43, 41.129], [165.005, 58.336], [180.903, 83.777]], "leg_r": [[130.0, 124.0], [173.836, 110.059], [176.0, 150.0], [186.984, 149.405]], "leg_l": [[136.0, 126.0], [179.487, 111.003], [180.0, 151.0], [190.999, 150.859]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[130.0, 124.0], [130.786, 80.488], [134.43, 39.129]], "head": [137.214, 19.323], "nose": [[146.724, 22.413], [153.382, 24.576]], "arm_r": [[134.43, 39.129], [159.005, 56.336], [178.289, 33.354]], "arm_l": [[140.43, 41.129], [165.005, 58.336], [184.289, 35.354]], "leg_r": [[130.0, 124.0], [173.836, 110.059], [176.0, 150.0], [186.984, 149.405]], "leg_l": [[136.0, 126.0], [179.487, 111.003], [180.0, 151.0], [190.999, 150.859]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[130.0, 124.0], [130.786, 80.488], [134.43, 39.129]], "head": [137.214, 19.323], "nose": [[146.724, 22.413], [153.382, 24.576]], "arm_r": [[134.43, 39.129], [159.005, 56.336], [188.932, 58.428]], "arm_l": [[140.43, 41.129], [165.005, 58.336], [194.932, 60.428]], "leg_r": [[130.0, 124.0], [173.836, 110.059], [176.0, 150.0], [186.984, 149.405]], "leg_l": [[136.0, 126.0], [179.487, 111.003], [180.0, 151.0], [190.999, 150.859]]}}}, {"name": "Bird Dog", "camera": 0.0, "frames": [{"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[190.0, 106.0], [147.036, 99.195], [106.046, 92.703]], "head": [86.196, 95.14], "nose": [[77.132, 99.367], [70.788, 102.325]], "arm_r": [[112.046, 94.703], [116.072, 124.432], [111.0, 154.0]], "arm_l": [[106.046, 92.703], [110.072, 122.432], [105.0, 152.0]], "leg_r": [[196.0, 108.0], [201.606, 153.657], [241.606, 153.657], [250.617, 159.966]], "leg_l": [[190.0, 106.0], [195.606, 151.657], [235.606, 151.657], [244.617, 157.966]]}, {"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[190.0, 106.0], [147.036, 99.195], [106.046, 92.703]], "head": [86.196, 95.14], "nose": [[76.536, 97.729], [69.775, 99.54]], "arm_r": [[112.046, 94.703], [116.072, 124.432], [111.0, 154.0]], "arm_l": [[106.046, 92.703], [77.681, 82.936], [49.315, 73.169]], "leg_r": [[196.0, 108.0], [240.433, 96.094], [279.07, 85.742], [286.14, 94.168]], "leg_l": [[190.0, 106.0], [195.606, 151.657], [235.606, 151.657], [244.617, 157.966]]}, {"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[190.0, 106.0], [147.036, 99.195], [106.046, 92.703]], "head": [86.196, 95.14], "nose": [[77.132, 99.367], [70.788, 102.325]], "arm_r": [[112.046, 94.703], [116.072, 124.432], [111.0, 154.0]], "arm_l": [[106.046, 92.703], [110.072, 122.432], [105.0, 152.0]], "leg_r": [[196.0, 108.0], [201.606, 153.657], [241.606, 153.657], [250.617, 159.966]], "leg_l": [[190.0, 106.0], [195.606, 151.657], [235.606, 151.657], [244.617, 157.966]]}, {"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[190.0, 106.0], [147.036, 99.195], [106.046, 92.703]], "head": [86.196, 95.14], "nose": [[76.536, 97.729], [69.775, 99.54]], "arm_r": [[112.046, 94.703], [83.681, 84.936], [55.315, 75.169]], "arm_l": [[106.046, 92.703], [110.072, 122.432], [105.0, 152.0]], "leg_r": [[196.0, 108.0], [201.606, 153.657], [241.606, 153.657], [250.617, 159.966]], "leg_l": [[190.0, 106.0], [234.433, 94.094], [273.07, 83.742], [280.14, 92.168]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[190.0, 106.0], [147.036, 99.195], [106.046, 92.703]], "head": [86.196, 95.14], "nose": [[76.799, 98.561], [70.221, 100.955]], "arm_r": [[112.046, 94.703], [116.072, 124.432], [111.0, 154.0]], "arm_l": [[106.046, 92.703], [82.85, 111.727], [57.036, 127.013]], "leg_r": [[196.0, 108.0], [234.136, 133.723], [273.794, 128.502], [281.904, 135.933]], "leg_l": [[190.0, 106.0], [195.606, 151.657], [235.606, 151.657], [244.617, 157.966]]}}, "orbit": [{"yaw": 37.0, "sample": {"order": ["arm_r", "arm_l", "spine", "leg_r", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[190.0, 106.0], [155.687, 99.195], [122.952, 92.703]], "head": [107.098, 95.14], "nose": [[98.462, 100.183], [93.396, 103.141]], "arm_r": [[131.956, 94.3], [122.538, 122.784], [112.137, 150.923]], "arm_l": [[118.739, 92.703], [112.502, 122.048], [105.213, 151.149]], "leg_r": [[197.801, 107.597], [202.278, 153.254], [234.223, 153.254], [241.42, 159.564]], "leg_l": [[186.991, 106.0], [191.468, 151.657], [223.413, 151.657], [230.61, 157.966]]}}, {"yaw": 180.0, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[190.0, 106.0], [232.964, 99.195], [273.954, 92.703]], "head": [293.804, 95.14], "nose": [[302.868, 99.367], [309.212, 102.325]], "arm_r": [[273.954, 92.703], [246.072, 103.776], [217.953, 114.232]], "arm_l": [[279.954, 94.703], [251.619, 104.559], [223.076, 113.795]], "leg_r": [[190.0, 106.0], [184.394, 151.657], [144.394, 151.657], [135.383, 157.966]], "leg_l": [[196.0, 108.0], [190.394, 153.657], [150.394, 153.657], [141.383, 159.966]]}}]}, {"name": "Calfs", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[140.0, 118.0], [130.995, 75.365], [128.143, 33.883]], "head": [131.616, 14.187], "nose": [[141.464, 15.924], [148.358, 17.139]], "arm_r": [[128.143, 33.883], [155.658, 45.839], [166.0, 74.0]], "arm_l": [[134.143, 35.883], [161.969, 47.095], [170.0, 76.0]], "leg_r": [[140.0, 118.0], [185.998, 117.604], [212.0, 148.0], [218.69, 139.268]], "leg_l": [[146.0, 120.0], [191.958, 118.032], [216.0, 150.0], [223.225, 141.705]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[140.0, 118.0], [130.995, 75.365], [128.143, 33.883]], "head": [131.616, 14.187], "nose": [[141.464, 15.924], [148.358, 17.139]], "arm_r": [[128.143, 33.883], [155.658, 45.839], [166.0, 74.0]], "arm_l": [[134.143, 35.883], [161.969, 47.095], [170.0, 76.0]], "leg_r": [[140.0, 118.0], [185.175, 109.328], [212.0, 139.0], [222.04, 134.506]], "leg_l": [[146.0, 120.0], [190.872, 109.878], [216.0, 141.0], [226.275, 137.073]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[140.0, 118.0], [130.995, 75.365], [128.143, 33.883]], "head": [131.616, 14.187], "nose": [[141.464, 15.924], [148.358, 17.139]], "arm_r": [[128.143, 33.883], [155.658, 45.839], [166.0, 74.0]], "arm_l": [[134.143, 35.883], [161.969, 47.095], [170.0, 76.0]], "leg_r": [[140.0, 118.0], [185.76, 113.309], [212.0, 143.5], [220.669, 136.728]], "leg_l": [[146.0, 120.0], [191.583, 113.817], [216.0, 145.5], [225.052, 139.25]]}}}, {"name": "Cat-Cow", "camera": 0.0, "frames": [{"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[190.0, 106.0], [145.16, 123.852], [109.354, 94.234]], "head": [89.43, 95.978], "nose": [[79.817, 93.221], [73.088, 91.292]], "arm_r": [[115.354, 96.234], [120.968, 125.704], [111.0, 154.0]], "arm_l": [[109.354, 94.234], [114.968, 123.704], [105.0, 152.0]], "leg_r": [[196.0, 108.0], [201.606, 153.657], [241.606, 153.657], [250.617, 159.966]], "leg_l": [[190.0, 106.0], [195.606, 151.657], [235.606, 151.657], [244.617, 157.966]]}, {"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[190.0, 106.0], [152.084, 74.375], [109.947, 96.56]], "head": [93.564, 108.031], "nose": [[86.492, 115.102], [81.543, 120.052]], "arm_r": [[115.947, 98.56], [124.631, 127.275], [111.0, 154.0]], "arm_l": [[109.947, 96.56], [118.631, 125.275], [105.0, 152.0]], "leg_r": [[196.0, 108.0], [201.606, 153.657], [241.606, 153.657], [250.617, 159.966]], "leg_l": [[190.0, 106.0], [195.606, 151.657], [235.606, 151.657], [244.617, 157.966]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[190.0, 106.0], [147.073, 98.844], [105.742, 94.89]], "head": [86.948, 101.73], "nose": [[77.267, 104.234], [70.49, 105.987]], "arm_r": [[111.742, 96.89], [120.561, 125.564], [111.0, 154.0]], "arm_l": [[105.742, 94.89], [114.561, 123.564], [105.0, 152.0]], "leg_r": [[196.0, 108.0], [201.606, 153.657], [241.606, 153.657], [250.617, 159.966]], "leg_l": [[190.0, 106.0], [195.606, 151.657], [235.606, 151.657], [244.617, 157.966]]}}}, {"name": "Chest Press", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[116.0, 122.0], [104.043, 80.097], [98.304, 38.915]], "head": [100.395, 19.025], "nose": [[110.357, 18.153], [117.33, 17.543]], "arm_r": [[98.304, 38.915], [93.095, 68.46], [116.076, 49.176]], "arm_l": [[104.304, 40.915], [99.095, 70.46], [122.076, 51.176]], "leg_r": [[116.0, 122.0], [160.688, 111.09], [158.0, 151.0], [168.975, 151.739]], "leg_l": [[122.0, 124.0], [166.474, 112.251], [162.0, 152.0], [172.931, 153.23]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[116.0, 122.0], [104.043, 80.097], [98.304, 38.915]], "head": [100.395, 19.025], "nose": [[110.357, 18.153], [117.33, 17.543]], "arm_r": [[98.304, 38.915], [128.14, 42.051], [157.249, 49.309]], "arm_l": [[104.304, 40.915], [134.14, 44.051], [163.249, 51.309]], "leg_r": [[116.0, 122.0], [160.688, 111.09], [158.0, 151.0], [168.975, 151.739]], "leg_l": [[122.0, 124.0], [166.474, 112.251], [162.0, 152.0], [172.931, 153.23]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[116.0, 122.0], [104.043, 80.097], [98.304, 38.915]], "head": [100.395, 19.025], "nose": [[110.357, 18.153], [117.33, 17.543]], "arm_r": [[98.304, 38.915], [116.359, 62.874], [145.59, 56.126]], "arm_l": [[104.304, 40.915], [122.359, 64.874], [151.59, 58.126]], "leg_r": [[116.0, 122.0], [160.688, 111.09], [158.0, 151.0], [168.975, 151.739]], "leg_l": [[122.0, 124.0], [166.474, 112.251], [162.0, 152.0], [172.931, 153.23]]}}}, {"name": "Dead Bug", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[185.0, 148.0], [141.5, 148.0], [100.0, 148.0]], "head": [80.437, 143.842], "nose": [[80.437, 133.842], [80.437, 126.842]], "arm_r": [[100.0, 148.0], [100.0, 118.0], [100.0, 88.0]], "arm_l": [[106.0, 150.0], [106.0, 120.0], [106.0, 90.0]], "leg_r": [[185.0, 148.0], [205.165, 106.655], [244.557, 113.601], [246.468, 102.769]], "leg_l": [[191.0, 150.0], [211.165, 108.655], [250.557, 115.601], [252.468, 104.769]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[185.0, 148.0], [141.5, 148.0], [100.0, 148.0]], "head": [80.437, 143.842], "nose": [[80.437, 133.842], [80.437, 126.842]], "arm_r": [[100.0, 148.0], [78.787, 126.787], [57.574, 105.574]], "arm_l": [[106.0, 150.0], [106.0, 120.0], [106.0, 90.0]], "leg_r": [[185.0, 148.0], [205.165, 106.655], [244.557, 113.601], [246.468, 102.769]], "leg_l": [[191.0, 150.0], [235.218, 137.321], [273.669, 126.295], [270.636, 115.721]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[185.0, 148.0], [141.5, 148.0], [100.0, 148.0]], "head": [80.437, 143.842], "nose": [[80.437, 133.842], [80.437, 126.842]], "arm_r": [[100.0, 148.0], [100.0, 118.0], [100.0, 88.0]], "arm_l": [[106.0, 150.0], [106.0, 120.0], [106.0, 90.0]], "leg_r": [[185.0, 148.0], [205.165, 106.655], [244.557, 113.601], [246.468, 102.769]], "leg_l": [[191.0, 150.0], [211.165, 108.655], [250.557, 115.601], [252.468, 104.769]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[185.0, 148.0], [141.5, 148.0], [100.0, 148.0]], "head": [80.437, 143.842], "nose": [[80.437, 133.842], [80.437, 126.842]], "arm_r": [[100.0, 148.0], [100.0, 118.0], [100.0, 88.0]], "arm_l": [[106.0, 150.0], [84.787, 128.787], [63.574, 107.574]], "leg_r": [[185.0, 148.0], [229.218, 135.321], [267.669, 124.295], [264.636, 113.721]], "leg_l": [[191.0, 150.0], [211.165, 108.655], [250.557, 115.601], [252.468, 104.769]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[185.0, 148.0], [141.5, 148.0], [100.0, 148.0]], "head": [80.437, 143.842], "nose": [[80.437, 133.842], [80.437, 126.842]], "arm_r": [[100.0, 148.0], [88.519, 120.284], [77.039, 92.567]], "arm_l": [[106.0, 150.0], [106.0, 120.0], [106.0, 90.0]], "leg_r": [[185.0, 148.0], [205.165, 106.655], [244.557, 113.601], [246.468, 102.769]], "leg_l": [[191.0, 150.0], [226.238, 120.432], [266.183, 118.338], [265.608, 107.353]]}}}, {"name": "Hollow Body Hold", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[185.0, 148.0], [141.5, 148.0], [100.0, 148.0]], "head": [80.437, 143.842], "nose": [[80.437, 133.842], [80.437, 126.842]], "arm_r": [[100.0, 148.0], [70.0, 148.0], [40.0, 148.0]], "arm_l": [[106.0, 150.0], [136.0, 150.0], [166.0, 150.0]], "leg_r": [[185.0, 148.0], [231.0, 148.0], [271.0, 148.0], [271.0, 137.0]], "leg_l": [[191.0, 150.0], [237.0, 150.0], [277.0, 150.0], [277.0, 139.0]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[185.0, 148.0], [140.587, 148.44], [102.768, 129.14]], "head": [87.448, 116.285], "nose": [[93.875, 108.624], [98.375, 103.262]], "arm_r": [[102.768, 129.14], [75.579, 116.462], [48.39, 103.783]], "arm_l": [[108.768, 131.14], [136.959, 141.401], [165.15, 151.662]], "leg_r": [[185.0, 148.0], [229.995, 138.436], [269.121, 130.12], [266.834, 119.36]], "leg_l": [[191.0, 150.0], [235.995, 140.436], [275.121, 132.12], [272.834, 121.36]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[185.0, 148.0], [141.269, 148.271], [100.697, 138.456]], "head": [82.721, 129.689], "nose": [[86.141, 120.292], [88.535, 113.714]], "arm_r": [[100.697, 138.456], [71.408, 131.963], [42.119, 125.47]], "arm_l": [[106.697, 140.456], [136.241, 145.666], [165.785, 150.875]], "leg_r": [[185.0, 148.0], [230.748, 143.192], [270.529, 139.011], [269.379, 128.071]], "leg_l": [[191.0, 150.0], [236.748, 145.192], [276.529, 141.011], [275.379, 130.071]]}}}, {"name": "Lat Pull Down", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[150.0, 122.0], [146.624, 78.588], [147.75, 37.059]], "head": [149.493, 17.135], "nose": [[155.228, 8.944], [159.243, 3.209]], "arm_r": [[147.75, 37.059], [174.771, 24.027], [194.0, 1.0]], "arm_l": [[153.75, 39.059], [181.713, 28.194], [198.0, 3.0]], "leg_r": [[150.0, 122.0], [194.414, 110.025], [193.0, 150.0], [203.993, 150.389]], "leg_l": [[156.0, 124.0], [200.161, 111.125], [197.0, 151.0], [207.966, 151.869]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[150.0, 122.0], [146.624, 78.588], [147.75, 37.059]], "head": [149.493, 17.135], "nose": [[158.153, 12.135], [164.215, 8.635]], "arm_r": [[147.75, 37.059], [171.723, 55.094], [194.0, 35.0]], "arm_l": [[153.75, 39.059], [176.815, 58.242], [198.0, 37.0]], "leg_r": [[150.0, 122.0], [194.414, 110.025], [193.0, 150.0], [203.993, 150.389]], "leg_l": [[156.0, 124.0], [200.161, 111.125], [197.0, 151.0], [207.966, 151.869]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[150.0, 122.0], [146.624, 78.588], [147.75, 37.059]], "head": [149.493, 17.135], "nose": [[156.865, 10.379], [162.026, 5.65]], "arm_r": [[147.75, 37.059], [177.186, 42.845], [194.0, 18.0]], "arm_l": [[153.75, 39.059], [182.947, 45.95], [198.0, 20.0]], "leg_r": [[150.0, 122.0], [194.414, 110.025], [193.0, 150.0], [203.993, 150.389]], "leg_l": [[156.0, 124.0], [200.161, 111.125], [197.0, 151.0], [207.966, 151.869]]}}}, {"name": "Leg Curl", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[118.0, 116.0], [101.728, 75.576], [91.716, 35.22]], "head": [97.229, 15.995], "nose": [[106.626, 19.415], [113.204, 21.809]], "arm_r": [[91.716, 35.22], [99.481, 64.198], [114.481, 90.179]], "arm_l": [[97.716, 37.22], [105.481, 66.198], [120.481, 92.179]], "leg_r": [[118.0, 116.0], [163.301, 108.012], [203.204, 105.222], [202.436, 94.249]], "leg_l": [[124.0, 118.0], [169.301, 110.012], [209.204, 107.222], [208.436, 96.249]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[118.0, 116.0], [101.728, 75.576], [91.716, 35.22]], "head": [97.229, 15.995], "nose": [[106.626, 19.415], [113.204, 21.809]], "arm_r": [[91.716, 35.22], [99.481, 64.198], [114.481, 90.179]], "arm_l": [[97.716, 37.22], [105.481, 66.198], [120.481, 92.179]], "leg_r": [[118.0, 116.0], [163.301, 108.012], [159.815, 147.86], [170.773, 148.819]], "leg_l": [[124.0, 118.0], [169.301, 110.012], [165.815, 149.86], [176.773, 150.819]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[118.0, 116.0], [101.728, 75.576], [91.716, 35.22]], "head": [97.229, 15.995], "nose": [[106.626, 19.415], [113.204, 21.809]], "arm_r": [[91.716, 35.22], [99.481, 64.198], [114.481, 90.179]], "arm_l": [[97.716, 37.22], [105.481, 66.198], [120.481, 92.179]], "leg_r": [[118.0, 116.0], [163.301, 108.012], [191.338, 136.542], [199.183, 128.832]], "leg_l": [[124.0, 118.0], [169.301, 110.012], [197.338, 138.542], [205.183, 130.832]]}}}, {"name": "Leg Extension", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[118.0, 116.0], [101.728, 75.576], [91.716, 35.22]], "head": [97.229, 15.995], "nose": [[106.626, 19.415], [113.204, 21.809]], "arm_r": [[91.716, 35.22], [99.481, 64.198], [114.481, 90.179]], "arm_l": [[97.716, 37.22], [105.481, 66.198], [120.481, 92.179]], "leg_r": [[118.0, 116.0], [163.301, 108.012], [168.176, 147.714], [179.094, 146.373]], "leg_l": [[124.0, 118.0], [169.301, 110.012], [174.176, 149.714], [185.094, 148.373]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[118.0, 116.0], [101.728, 75.576], [91.716, 35.22]], "head": [97.229, 15.995], "nose": [[106.626, 19.415], [113.204, 21.809]], "arm_r": [[91.716, 35.22], [99.481, 64.198], [114.481, 90.179]], "arm_l": [[97.716, 37.22], [105.481, 66.198], [120.481, 92.179]], "leg_r": [[118.0, 116.0], [163.301, 108.012], [203.204, 105.222], [202.436, 94.249]], "leg_l": [[124.0, 118.0], [169.301, 110.012], [209.204, 107.222], [208.436, 96.249]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[118.0, 116.0], [101.728, 75.576], [91.716, 35.22]], "head": [97.229, 15.995], "nose": [[106.626, 19.415], [113.204, 21.809]], "arm_r": [[91.716, 35.22], [99.481, 64.198], [114.481, 90.179]], "arm_l": [[97.716, 37.22], [105.481, 66.198], [120.481, 92.179]], "leg_r": [[118.0, 116.0], [163.301, 108.012], [194.166, 133.455], [201.163, 124.967]], "leg_l": [[124.0, 118.0], [169.301, 110.012], [200.166, 135.455], [207.163, 126.967]]}}}, {"name": "Leg Press", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[140.0, 116.0], [119.592, 77.499], [105.417, 38.41]], "head": [110.593, 19.092], "nose": [[120.252, 21.68], [127.014, 23.492]], "arm_r": [[105.417, 38.41], [113.181, 67.388], [141.372, 77.649]], "arm_l": [[111.417, 40.41], [119.181, 69.388], [147.372, 79.649]], "leg_r": [[140.0, 116.0], [157.129, 73.308], [185.0, 102.0], [192.89, 94.336]], "leg_l": [[146.0, 118.0], [161.702, 74.763], [189.0, 104.0], [197.04, 96.493]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[140.0, 116.0], [119.592, 77.499], [105.417, 38.41]], "head": [110.593, 19.092], "nose": [[120.252, 21.68], [127.014, 23.492]], "arm_r": [[105.417, 38.41], [113.181, 67.388], [141.372, 77.649]], "arm_l": [[111.417, 40.41], [119.181, 69.388], [147.372, 79.649]], "leg_r": [[140.0, 116.0], [180.564, 94.307], [220.0, 101.0], [221.84, 90.155]], "leg_l": [[146.0, 118.0], [185.086, 93.745], [224.0, 103.0], [226.545, 92.299]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[140.0, 116.0], [119.592, 77.499], [105.417, 38.41]], "head": [110.593, 19.092], "nose": [[120.252, 21.68], [127.014, 23.492]], "arm_r": [[105.417, 38.41], [113.181, 67.388], [141.372, 77.649]], "arm_l": [[111.417, 40.41], [119.181, 69.388], [147.372, 79.649]], "leg_r": [[140.0, 116.0], [168.724, 80.071], [202.5, 101.5], [208.393, 92.212]], "leg_l": [[146.0, 118.0], [173.396, 81.048], [206.5, 103.5], [212.674, 94.396]]}}}, {"name": "Leg Raises", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[185.0, 148.0], [141.5, 148.0], [100.0, 148.0]], "head": [80.437, 143.842], "nose": [[80.437, 133.842], [80.437, 126.842]], "arm_r": [[100.0, 148.0], [130.0, 148.0], [160.0, 148.0]], "arm_l": [[106.0, 150.0], [135.982, 151.047], [165.963, 152.094]], "leg_r": [[185.0, 148.0], [208.0, 108.163], [228.0, 73.522], [218.474, 68.022]], "leg_l": [[191.0, 150.0], [214.0, 110.163], [234.0, 75.522], [224.474, 70.022]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[185.0, 148.0], [141.5, 148.0], [100.0, 148.0]], "head": [80.437, 143.842], "nose": [[80.437, 133.842], [80.437, 126.842]], "arm_r": [[100.0, 148.0], [130.0, 148.0], [160.0, 148.0]], "arm_l": [[106.0, 150.0], [135.982, 151.047], [165.963, 152.094]], "leg_r": [[185.0, 148.0], [230.552, 141.598], [270.163, 136.031], [268.632, 125.138]], "leg_l": [[191.0, 150.0], [236.552, 143.598], [276.163, 138.031], [274.632, 127.138]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[185.0, 148.0], [141.5, 148.0], [100.0, 148.0]], "head": [80.437, 143.842], "nose": [[80.437, 133.842], [80.437, 126.842]], "arm_r": [[100.0, 148.0], [130.0, 148.0], [160.0, 148.0]], "arm_l": [[106.0, 150.0], [135.982, 151.047], [165.963, 152.094]], "leg_r": [[185.0, 148.0], [223.136, 122.277], [256.297, 99.909], [250.146, 90.79]], "leg_l": [[191.0, 150.0], [229.136, 124.277], [262.297, 101.909], [256.146, 92.79]]}}}, {"name": "Plank", "camera": 0.0, "frames": [{"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[172.0, 114.0], [128.666, 117.791], [87.323, 121.408]], "head": [67.4, 123.151], "nose": [[58.739, 128.151], [52.677, 131.651]], "arm_r": [[93.323, 123.408], [93.323, 153.408], [63.323, 153.408]], "arm_l": [[87.323, 121.408], [87.323, 151.408], [57.323, 151.408]], "leg_r": [[178.0, 116.0], [204.385, 153.681], [244.385, 153.681], [244.385, 164.681]], "leg_l": [[172.0, 114.0], [198.385, 151.681], [238.385, 151.681], [238.385, 162.681]]}, {"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[168.0, 137.0], [125.161, 129.446], [84.291, 122.24]], "head": [64.595, 118.767], "nose": [[55.935, 123.767], [49.873, 127.267]], "arm_r": [[90.291, 124.24], [90.291, 154.24], [60.291, 154.24]], "arm_l": [[84.291, 122.24], [84.291, 152.24], [54.291, 152.24]], "leg_r": [[174.0, 139.0], [219.301, 146.988], [258.693, 153.934], [256.783, 164.767]], "leg_l": [[168.0, 137.0], [213.301, 144.988], [252.693, 151.934], [250.783, 162.767]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[170.0, 125.5], [126.541, 123.603], [85.081, 121.792]], "head": [65.1, 120.92], "nose": [[56.44, 125.92], [50.378, 129.42]], "arm_r": [[91.081, 123.792], [91.081, 153.792], [61.081, 153.792]], "arm_l": [[85.081, 121.792], [85.081, 151.792], [55.081, 151.792]], "leg_r": [[176.0, 127.5], [214.796, 152.216], [254.644, 155.702], [253.685, 166.66]], "leg_l": [[170.0, 125.5], [208.796, 150.216], [248.644, 153.702], [247.685, 164.66]]}}}, {"name": "Reverse Crunch", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[172.0, 148.0], [128.5, 148.0], [87.0, 148.0]], "head": [67.437, 143.842], "nose": [[67.437, 133.842], [67.437, 126.842]], "arm_r": [[87.0, 148.0], [117.0, 148.0], [147.0, 148.0]], "arm_l": [[93.0, 150.0], [122.982, 151.047], [152.963, 152.094]], "leg_r": [[172.0, 148.0], [164.012, 102.699], [201.6, 116.38], [205.362, 106.043]], "leg_l": [[178.0, 150.0], [170.012, 104.699], [207.6, 118.38], [211.362, 108.043]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[166.0, 136.0], [124.618, 154.524], [82.625, 143.469]], "head": [62.637, 144.167], "nose": [[62.637, 134.167], [62.637, 127.167]], "arm_r": [[82.625, 143.469], [112.511, 146.083], [142.397, 148.698]], "arm_l": [[88.625, 145.469], [118.607, 146.516], [148.588, 147.563]], "leg_r": [[166.0, 136.0], [146.56, 94.31], [185.952, 101.256], [187.862, 90.423]], "leg_l": [[172.0, 138.0], [152.56, 96.31], [191.952, 103.256], [193.862, 92.423]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[169.0, 142.0], [126.032, 151.335], [84.408, 145.781]], "head": [64.484, 144.037], "nose": [[64.484, 134.037], [64.484, 127.037]], "arm_r": [[84.408, 145.781], [114.379, 147.089], [144.351, 148.398]], "arm_l": [[90.408, 147.781], [120.39, 148.828], [150.371, 149.875]], "leg_r": [[169.0, 142.0], [155.168, 98.129], [193.805, 108.482], [196.652, 97.857]], "leg_l": [[175.0, 144.0], [161.168, 100.129], [199.805, 110.482], [202.652, 99.857]]}}}, {"name": "Rotary", "camera": 90.0, "frames": [{"order": ["spine", "arm_l", "arm_r", "leg_l", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "far", "leg_l": "near"}, "spine": [[160.0, 122.0], [160.0, 78.5], [160.0, 37.0]], "head": [160.0, 17.0], "nose": [[170.0, 17.0], [173.709, 17.0]], "arm_r": [[155.5, 37.0], [177.096, 47.261], [199.728, 52.47]], "arm_l": [[164.5, 37.0], [186.095, 47.261], [208.727, 52.47]], "leg_r": [[155.0, 122.0], [142.321, 128.154], [138.55, 167.206], [135.701, 164.863]], "leg_l": [[165.0, 122.0], [177.679, 128.154], [181.45, 167.206], [184.299, 164.863]]}, {"order": ["spine", "arm_r", "arm_l", "leg_l", "leg_r", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[160.0, 122.0], [160.0, 78.5], [160.0, 37.0]], "head": [160.0, 17.0], "nose": [[150.0, 17.0], [146.291, 17.0]], "arm_r": [[155.5, 37.0], [133.905, 47.261], [111.273, 52.47]], "arm_l": [[164.5, 37.0], [142.904, 47.261], [120.272, 52.47]], "leg_r": [[155.0, 122.0], [142.321, 128.154], [138.55, 167.206], [135.701, 164.863]], "leg_l": [[165.0, 122.0], [177.679, 128.154], [181.45, 167.206], [184.299, 164.863]]}], "tween": {"t": 0.5, "sample": {"order": ["spine", "arm_l", "arm_r", "leg_l", "leg_r", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[160.0, 122.0], [160.0, 78.5], [160.0, 37.0]], "head": [160.0, 17.0], "nose": null, "arm_r": [[153.0, 37.0], [153.0, 47.261], [153.0, 52.47]], "arm_l": [[167.0, 37.0], [167.0, 47.261], [167.0, 52.47]], "leg_r": [[155.0, 122.0], [142.321, 128.154], [138.55, 167.206], [135.701, 164.863]], "leg_l": [[165.0, 122.0], [177.679, 128.154], [181.45, 167.206], [184.299, 164.863]]}}}, {"name": "Seated Row", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[140.0, 122.0], [142.699, 78.541], [149.593, 37.572]], "head": [153.066, 17.876], "nose": [[163.028, 17.005], [170.001, 16.395]], "arm_r": [[149.593, 37.572], [170.108, 59.462], [200.0, 62.0]], "arm_l": [[155.593, 39.572], [174.009, 63.254], [204.0, 64.0]], "leg_r": [[140.0, 122.0], [183.885, 108.212], [188.0, 148.0], [198.942, 146.868]], "leg_l": [[146.0, 124.0], [189.512, 109.077], [192.0, 149.0], [202.979, 148.316]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[140.0, 122.0], [142.699, 78.541], [149.593, 37.572]], "head": [153.066, 17.876], "nose": [[163.028, 17.005], [170.001, 16.395]], "arm_r": [[149.593, 37.572], [130.28, 60.529], [158.0, 72.0]], "arm_l": [[155.593, 39.572], [134.847, 61.243], [162.0, 74.0]], "leg_r": [[140.0, 122.0], [183.885, 108.212], [188.0, 148.0], [198.942, 146.868]], "leg_l": [[146.0, 124.0], [189.512, 109.077], [192.0, 149.0], [202.979, 148.316]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[140.0, 122.0], [142.699, 78.541], [149.593, 37.572]], "head": [153.066, 17.876], "nose": [[163.028, 17.005], [170.001, 16.395]], "arm_r": [[149.593, 37.572], [149.005, 67.567], [179.0, 67.0]], "arm_l": [[155.593, 39.572], [153.004, 69.46], [183.0, 69.0]], "leg_r": [[140.0, 122.0], [183.885, 108.212], [188.0, 148.0], [198.942, 146.868]], "leg_l": [[146.0, 124.0], [189.512, 109.077], [192.0, 149.0], [202.979, 148.316]]}}}, {"name": "Shoulder Press", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[120.0, 130.0], [103.02, 89.766], [95.132, 48.841]], "head": [98.605, 29.145], "nose": [[108.508, 27.753], [115.44, 26.779]], "arm_r": [[95.132, 48.841], [110.132, 74.822], [110.132, 44.822]], "arm_l": [[101.132, 50.841], [116.132, 76.822], [116.132, 46.822]], "leg_r": [[120.0, 130.0], [161.917, 111.054], [164.0, 151.0], [174.985, 150.427]], "leg_l": [[126.0, 132.0], [167.427, 112.004], [168.0, 152.0], [178.999, 151.842]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[120.0, 130.0], [103.02, 89.766], [95.132, 48.841]], "head": [98.605, 29.145], "nose": [[108.508, 27.753], [115.44, 26.779]], "arm_r": [[95.132, 48.841], [112.766, 24.57], [137.036, 6.937]], "arm_l": [[101.132, 50.841], [118.766, 26.57], [143.036, 8.937]], "leg_r": [[120.0, 130.0], [161.917, 111.054], [164.0, 151.0], [174.985, 150.427]], "leg_l": [[126.0, 132.0], [167.427, 112.004], [168.0, 152.0], [178.999, 151.842]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[120.0, 130.0], [103.02, 89.766], [95.132, 48.841]], "head": [98.605, 29.145], "nose": [[108.508, 27.753], [115.44, 26.779]], "arm_r": [[95.132, 48.841], [125.091, 50.411], [138.711, 23.681]], "arm_l": [[101.132, 50.841], [131.091, 52.411], [144.711, 25.681]], "leg_r": [[120.0, 130.0], [161.917, 111.054], [164.0, 151.0], [174.985, 150.427]], "leg_l": [[126.0, 132.0], [167.427, 112.004], [168.0, 152.0], [178.999, 151.842]]}}}, {"name": "Side Plank", "camera": 0.0, "frames": [{"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[174.0, 150.0], [132.629, 136.558], [93.16, 123.734]], "head": [74.139, 117.553], "nose": [[64.177, 116.682], [57.204, 116.072]], "arm_r": [[99.16, 125.734], [127.351, 135.994], [156.329, 143.759]], "arm_l": [[93.16, 123.734], [89.959, 153.562], [60.0, 152.0]], "leg_r": [[180.0, 152.0], [225.993, 152.803], [265.987, 153.501], [265.795, 164.499]], "leg_l": [[174.0, 150.0], [219.993, 150.803], [259.987, 151.501], [259.795, 162.499]]}, {"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[174.0, 137.0], [131.161, 129.446], [90.291, 122.24]], "head": [70.595, 118.767], "nose": [[60.595, 118.767], [53.595, 118.767]], "arm_r": [[96.291, 124.24], [101.501, 94.696], [106.71, 65.151]], "arm_l": [[90.291, 122.24], [89.999, 152.238], [60.0, 152.0]], "leg_r": [[180.0, 139.0], [225.301, 146.988], [264.693, 153.934], [262.783, 164.767]], "leg_l": [[174.0, 137.0], [219.301, 144.988], [258.693, 151.934], [256.783, 162.767]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"], "shade": {"arm_r": "far", "arm_l": "near", "leg_r": "far", "leg_l": "near"}, "spine": [[174.0, 143.5], [131.792, 132.976], [91.525, 122.937]], "head": [72.119, 118.098], "nose": [[62.128, 117.662], [55.135, 117.357]], "arm_r": [[97.525, 124.937], [123.506, 109.937], [148.807, 93.818]], "arm_l": [[91.525, 122.937], [89.987, 152.897], [60.0, 152.0]], "leg_r": [[180.0, 145.5], [225.788, 149.909], [265.604, 153.743], [264.55, 164.692]], "leg_l": [[174.0, 143.5], [219.788, 147.909], [259.604, 151.743], [258.55, 162.692]]}}}, {"name": "Tricep Press", "camera": 0.0, "frames": [{"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[118.0, 124.0], [109.722, 81.276], [104.688, 40.062]], "head": [106.083, 20.111], "nose": [[116.028, 19.065], [122.99, 18.334]], "arm_r": [[104.688, 40.062], [94.678, 68.343], [124.0, 62.0]], "arm_l": [[110.688, 42.062], [98.504, 69.477], [128.0, 64.0]], "leg_r": [[118.0, 124.0], [162.267, 111.494], [156.0, 151.0], [166.864, 152.724]], "leg_l": [[124.0, 126.0], [168.072, 112.823], [160.0, 152.0], [170.774, 154.22]]}, {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[118.0, 124.0], [109.722, 81.276], [104.688, 40.062]], "head": [106.083, 20.111], "nose": [[116.028, 19.065], [122.99, 18.334]], "arm_r": [[104.688, 40.062], [109.666, 69.646], [124.0, 96.0]], "arm_l": [[110.688, 42.062], [113.094, 71.965], [128.0, 98.0]], "leg_r": [[118.0, 124.0], [162.267, 111.494], [156.0, 151.0], [166.864, 152.724]], "leg_l": [[124.0, 126.0], [168.072, 112.823], [160.0, 152.0], [170.774, 154.22]]}], "tween": {"t": 0.5, "sample": {"order": ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"], "shade": {"arm_r": "near", "arm_l": "far", "leg_r": "near", "leg_l": "far"}, "spine": [[118.0, 124.0], [109.722, 81.276], [104.688, 40.062]], "head": [106.083, 20.111], "nose": [[116.028, 19.065], [122.99, 18.334]], "arm_r": [[104.688, 40.062], [95.816, 68.72], [124.0, 79.0]], "arm_l": [[110.688, 42.062], [100.046, 70.111], [128.0, 81.0]], "leg_r": [[118.0, 124.0], [162.267, 111.494], [156.0, 151.0], [166.864, 152.724]], "leg_l": [[124.0, 126.0], [168.072, 112.823], [160.0, 152.0], [170.774, 154.22]]}}}]}