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 @@ Bird Dog - - + + - - + + - - R - - L + + near + + far 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 @@ Bird Dog - - + + - - + + - - R - - L + + near + + far 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 @@ Bird Dog - - + + - - + + - - R - - L + + near + + far 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 @@ Bird Dog - - + + - - + + - - R - - L + + near + + far 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 @@ Bird Dog - - + + - - + + - - R - - L + + near + + far 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 @@ Cat-Cow - - + + - - + + - - R - - L + + near + + far 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 @@ Cat-Cow - - + + - - + + - - R - - L + + near + + far 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 @@ Cat-Cow - - + + - - + + - - R - - L + + near + + far 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 @@ Hollow Body Hold - + - + - - R - - L + + near + + far 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 @@ Hollow Body Hold - + - + - - R - - L + + near + + far 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 @@ Hollow Body Hold - + - + - - R - - L + + near + + far 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 @@ Plank - - + + - - + + - - R - - L + + near + + far 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 @@ Plank - - + + - - + + - - R - - L + + near + + far 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 @@ Plank - - + + - - + + - - R - - L + + near + + far 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 @@ Side Plank - - + + - - + + - - R - - L + + near + + far 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 @@ Side Plank - - + + - - + + - - R - - L + + near + + far 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 @@ Side Plank - - + + - - + + - - R - - L + + near + + far 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'', @@ -384,20 +338,20 @@ def svg_for_frame(name, geo, working, colors, props=None): f' '] parts += svg_prims(bg, colors) - for part in PART_ORDER: + for part in order: if part == "head": parts += svg_prims(fg, colors) hx, hy = geo["head"] parts.append(f' ') if "nose" in geo: - (sx, sy), (ex, ey) = nose_segment(geo) + (sx, sy), (ex, ey) = geo["nose"] parts.append(f' ') continue if part not in geo: continue - color, width = part_style(part, working, colors) + color, width = part_style(part, working, colors, shade) if part == "spine": (ax, ay), (cx, cy), (bx, by) = geo["spine"] d = f"M {ax:.1f} {ay:.1f} Q {cx:.1f} {cy:.1f} {bx:.1f} {by:.1f}" @@ -405,18 +359,18 @@ def svg_for_frame(name, geo, working, colors, props=None): d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in geo[part]) parts.append(f' ') - lx = w - 78 + lx = w - 96 parts.append(f' ') - parts.append(f' ') - parts.append(f' R') - parts.append(f' ') - parts.append(f' L') + parts.append(f' ') + parts.append(f' near') + parts.append(f' ') + parts.append(f' far') parts.append(' ') parts.append('') 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]]}}}]}