diff --git a/Exercise Library/Abductor/frames/frame-1.svg b/Exercise Library/Abductor/frames/frame-1.svg index 5670ff6..83eac3d 100644 --- a/Exercise Library/Abductor/frames/frame-1.svg +++ b/Exercise Library/Abductor/frames/frame-1.svg @@ -3,15 +3,14 @@ - - - - + - + - + + + diff --git a/Exercise Library/Abductor/frames/frame-2.svg b/Exercise Library/Abductor/frames/frame-2.svg index c611420..43b81b2 100644 --- a/Exercise Library/Abductor/frames/frame-2.svg +++ b/Exercise Library/Abductor/frames/frame-2.svg @@ -3,15 +3,14 @@ - - - - + - + - + + + diff --git a/Exercise Library/Abductor/motion.json b/Exercise Library/Abductor/motion.json index e986689..626a2a5 100644 --- a/Exercise Library/Abductor/motion.json +++ b/Exercise Library/Abductor/motion.json @@ -1,17 +1,17 @@ { "name": "Abductor", "primary": 2, + "figure": "frontal", "working": ["leg_r", "leg_l"], "hide": [], "props": [ {"type": "scene", "shapes": [ {"kind": "rect", "x": 152, "y": 56, "w": 16, "h": 72, "r": 5}, {"kind": "rect", "x": 130, "y": 126, "w": 60, "h": 10, "r": 3}, - {"kind": "line", "pts": [[136, 78], [136, 108]], "w": 4, "color": "prop"}, - {"kind": "line", "pts": [[188, 78], [188, 108]], "w": 4, "color": "prop"}, - {"kind": "line", "pts": [[213, 110], [213, 132]], "w": 9, "color": "prop"}, - {"kind": "line", "pts": [[107, 110], [107, 132]], "w": 9, "color": "prop"} - ]} + {"kind": "line", "pts": [[160, 136], [160, 150]], "w": 6} + ]}, + {"type": "pad", "at": ["knee_r"], "angle": 90, "halfLen": 13, "w": 6}, + {"type": "pad", "at": ["knee_l"], "angle": 90, "halfLen": 13, "w": 6} ], "frames": [ { @@ -19,16 +19,14 @@ "root": [160, 116], "spine": [90, 90], "neck": 90, "arm_r": [-48, -82], "arm_l": [-132, -98], - "leg_r": [-22, 0], "leg_l": [-158, 0], - "pins": {"foot_r": [180, 156], "foot_l": [140, 156]} + "leg_r": [-75, -90], "leg_l": [-105, -90] }, { "hold": 0.5, "tween": 1.1, "root": [160, 116], "spine": [90, 90], "neck": 90, "arm_r": [-48, -82], "arm_l": [-132, -98], - "leg_r": [-22, 0], "leg_l": [-158, 0], - "pins": {"foot_r": [216, 156], "foot_l": [104, 156]} + "leg_r": [-30, -90], "leg_l": [-150, -90] } ] } diff --git a/Exercise Library/Abductor/preview.gif b/Exercise Library/Abductor/preview.gif index 88b6a83..e0b7127 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 c611420..43b81b2 100644 --- a/Exercise Library/Abductor/visual.svg +++ b/Exercise Library/Abductor/visual.svg @@ -3,15 +3,14 @@ - - - - + - + - + + + diff --git a/Exercise Library/Adductor/frames/frame-1.svg b/Exercise Library/Adductor/frames/frame-1.svg index 26f2537..a3f62cf 100644 --- a/Exercise Library/Adductor/frames/frame-1.svg +++ b/Exercise Library/Adductor/frames/frame-1.svg @@ -3,15 +3,14 @@ - - - - + - + - + + + diff --git a/Exercise Library/Adductor/frames/frame-2.svg b/Exercise Library/Adductor/frames/frame-2.svg index 1432983..2893d9e 100644 --- a/Exercise Library/Adductor/frames/frame-2.svg +++ b/Exercise Library/Adductor/frames/frame-2.svg @@ -3,15 +3,14 @@ - - - - + - + - + + + diff --git a/Exercise Library/Adductor/motion.json b/Exercise Library/Adductor/motion.json index c012ce9..348c15e 100644 --- a/Exercise Library/Adductor/motion.json +++ b/Exercise Library/Adductor/motion.json @@ -1,17 +1,17 @@ { "name": "Adductor", "primary": 2, + "figure": "frontal", "working": ["leg_r", "leg_l"], "hide": [], "props": [ {"type": "scene", "shapes": [ {"kind": "rect", "x": 152, "y": 56, "w": 16, "h": 72, "r": 5}, {"kind": "rect", "x": 130, "y": 126, "w": 60, "h": 10, "r": 3}, - {"kind": "line", "pts": [[136, 78], [136, 108]], "w": 4, "color": "prop"}, - {"kind": "line", "pts": [[188, 78], [188, 108]], "w": 4, "color": "prop"}, - {"kind": "line", "pts": [[197, 110], [197, 132]], "w": 9, "color": "prop"}, - {"kind": "line", "pts": [[123, 110], [123, 132]], "w": 9, "color": "prop"} - ]} + {"kind": "line", "pts": [[160, 136], [160, 150]], "w": 6} + ]}, + {"type": "pad", "at": ["knee_r"], "angle": 90, "halfLen": 13, "w": 6}, + {"type": "pad", "at": ["knee_l"], "angle": 90, "halfLen": 13, "w": 6} ], "frames": [ { @@ -19,16 +19,14 @@ "root": [160, 116], "spine": [90, 90], "neck": 90, "arm_r": [-48, -82], "arm_l": [-132, -98], - "leg_r": [-22, 0], "leg_l": [-158, 0], - "pins": {"foot_r": [216, 156], "foot_l": [104, 156]} + "leg_r": [-30, -90], "leg_l": [-150, -90] }, { "hold": 0.5, "tween": 1.1, "root": [160, 116], "spine": [90, 90], "neck": 90, "arm_r": [-48, -82], "arm_l": [-132, -98], - "leg_r": [-22, 0], "leg_l": [-158, 0], - "pins": {"foot_r": [180, 156], "foot_l": [140, 156]} + "leg_r": [-75, -90], "leg_l": [-105, -90] } ] } diff --git a/Exercise Library/Adductor/preview.gif b/Exercise Library/Adductor/preview.gif index 763a32d..404badd 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 1432983..2893d9e 100644 --- a/Exercise Library/Adductor/visual.svg +++ b/Exercise Library/Adductor/visual.svg @@ -3,15 +3,14 @@ - - - - + - + - + + + diff --git a/Exercise Library/Arm Curl/frames/frame-1.svg b/Exercise Library/Arm Curl/frames/frame-1.svg index 369d1c0..b5d1566 100644 --- a/Exercise Library/Arm Curl/frames/frame-1.svg +++ b/Exercise Library/Arm Curl/frames/frame-1.svg @@ -2,14 +2,14 @@ Arm Curl - - - + + + - + - + diff --git a/Exercise Library/Arm Curl/frames/frame-2.svg b/Exercise Library/Arm Curl/frames/frame-2.svg index 093d0c8..d98260d 100644 --- a/Exercise Library/Arm Curl/frames/frame-2.svg +++ b/Exercise Library/Arm Curl/frames/frame-2.svg @@ -2,14 +2,14 @@ Arm Curl - - - + + + - + - + diff --git a/Exercise Library/Arm Curl/motion.json b/Exercise Library/Arm Curl/motion.json index cf63762..03f5648 100644 --- a/Exercise Library/Arm Curl/motion.json +++ b/Exercise Library/Arm Curl/motion.json @@ -6,8 +6,8 @@ "props": [ {"type": "scene", "shapes": [ {"kind": "line", "pts": [[116, 134], [150, 134]], "w": 8}, - {"kind": "line", "pts": [[146, 42], [176, 68]], "w": 8}, - {"kind": "line", "pts": [[176, 68], [176, 128]], "w": 5} + {"kind": "line", "pts": [[136, 52], [164, 70]], "w": 8}, + {"kind": "line", "pts": [[164, 70], [164, 128]], "w": 5} ]}, {"type": "bar", "at": ["hand_r", "hand_l"], "angle": 90, "halfLen": 6} ], @@ -16,7 +16,7 @@ "hold": 0.4, "tween": 1.0, "root": [130, 124], "spine": [88, 86], "neck": 82, "gaze": -18, - "arm_r": [-35, -52], "arm_l": [-35, -52], + "arm_r": [-35, -58], "arm_l": [-35, -58], "leg_r": [-8, -72], "leg_l": [-8, -72], "pins": {"foot_r": [176, 150], "foot_l": [180, 151]} }, @@ -24,7 +24,7 @@ "hold": 0.4, "tween": 1.2, "root": [130, 124], "spine": [88, 86], "neck": 82, "gaze": -18, - "arm_r": [-35, 138], "arm_l": [-35, 138], + "arm_r": [-35, 50], "arm_l": [-35, 50], "leg_r": [-8, -72], "leg_l": [-8, -72], "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 217d0d3..4072f74 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 093d0c8..d98260d 100644 --- a/Exercise Library/Arm Curl/visual.svg +++ b/Exercise Library/Arm Curl/visual.svg @@ -2,14 +2,14 @@ Arm Curl - - - + + + - + - + diff --git a/Exercise Library/Bird Dog/frames/frame-1.svg b/Exercise Library/Bird Dog/frames/frame-1.svg index d6800eb..eeb9333 100644 --- a/Exercise Library/Bird Dog/frames/frame-1.svg +++ b/Exercise Library/Bird Dog/frames/frame-1.svg @@ -1,11 +1,11 @@ Bird Dog - + - + diff --git a/Exercise Library/Bird Dog/frames/frame-2.svg b/Exercise Library/Bird Dog/frames/frame-2.svg index 3a6a308..d842d2e 100644 --- a/Exercise Library/Bird Dog/frames/frame-2.svg +++ b/Exercise Library/Bird Dog/frames/frame-2.svg @@ -1,11 +1,11 @@ Bird Dog - + - + diff --git a/Exercise Library/Bird Dog/frames/frame-3.svg b/Exercise Library/Bird Dog/frames/frame-3.svg new file mode 100644 index 0000000..eeb9333 --- /dev/null +++ b/Exercise Library/Bird Dog/frames/frame-3.svg @@ -0,0 +1,17 @@ + + Bird Dog + + + + + + + + + + + R + + L + + diff --git a/Exercise Library/Bird Dog/frames/frame-4.svg b/Exercise Library/Bird Dog/frames/frame-4.svg new file mode 100644 index 0000000..cc10e51 --- /dev/null +++ b/Exercise Library/Bird Dog/frames/frame-4.svg @@ -0,0 +1,17 @@ + + Bird Dog + + + + + + + + + + + R + + L + + diff --git a/Exercise Library/Bird Dog/motion.json b/Exercise Library/Bird Dog/motion.json index 4c04ef0..15336d5 100644 --- a/Exercise Library/Bird Dog/motion.json +++ b/Exercise Library/Bird Dog/motion.json @@ -1,7 +1,7 @@ { "name": "Bird Dog", "primary": 2, - "working": ["arm_r", "leg_l"], + "working": ["arm_r", "leg_l", "arm_l", "leg_r"], "frames": [ { "hold": 0.5, "tween": 0.8, @@ -18,6 +18,22 @@ "arm_r": [161, 161], "arm_l": [-90, -90], "leg_r": [-83, 0], "leg_l": [15, 15], "pins": {"hand_l": [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]} + }, + { + "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]} } ] } diff --git a/Exercise Library/Bird Dog/preview.gif b/Exercise Library/Bird Dog/preview.gif index a151f67..e1f900f 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 3a6a308..d842d2e 100644 --- a/Exercise Library/Bird Dog/visual.svg +++ b/Exercise Library/Bird Dog/visual.svg @@ -1,11 +1,11 @@ Bird Dog - + - + diff --git a/Exercise Library/Calfs/frames/frame-1.svg b/Exercise Library/Calfs/frames/frame-1.svg index c09ea7c..c5fae77 100644 --- a/Exercise Library/Calfs/frames/frame-1.svg +++ b/Exercise Library/Calfs/frames/frame-1.svg @@ -5,14 +5,14 @@ - - + + diff --git a/Exercise Library/Calfs/frames/frame-2.svg b/Exercise Library/Calfs/frames/frame-2.svg index c3ffa53..e0a3604 100644 --- a/Exercise Library/Calfs/frames/frame-2.svg +++ b/Exercise Library/Calfs/frames/frame-2.svg @@ -5,14 +5,14 @@ - - + + diff --git a/Exercise Library/Calfs/motion.json b/Exercise Library/Calfs/motion.json index 2d07937..ce2a646 100644 --- a/Exercise Library/Calfs/motion.json +++ b/Exercise Library/Calfs/motion.json @@ -9,10 +9,10 @@ {"kind": "line", "pts": [[138, 128], [138, 150]], "w": 5}, {"kind": "line", "pts": [[120, 150], [156, 150]], "w": 5}, {"kind": "rect", "x": 198, "y": 147, "w": 34, "h": 6, "r": 1}, - {"kind": "line", "pts": [[183, 62], [183, 124]], "w": 4}, - {"kind": "rect", "x": 170, "y": 121, "w": 27, "h": 7, "r": 2}, + {"kind": "line", "pts": [[183, 62], [183, 122]], "w": 4}, {"kind": "line", "pts": [[164, 76], [183, 76]], "w": 3, "color": "prop"} ]}, + {"type": "pad", "at": ["knee_r", "knee_l"], "angle": 0, "halfLen": 14, "w": 7}, {"type": "pad", "at": ["foot_r", "foot_l"], "angle": 15, "halfLen": 7, "w": 5} ], "frames": [ diff --git a/Exercise Library/Calfs/preview.gif b/Exercise Library/Calfs/preview.gif index 237e744..80d183c 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 c3ffa53..e0a3604 100644 --- a/Exercise Library/Calfs/visual.svg +++ b/Exercise Library/Calfs/visual.svg @@ -5,14 +5,14 @@ - - + + diff --git a/Exercise Library/Dead Bug/frames/frame-1.svg b/Exercise Library/Dead Bug/frames/frame-1.svg index 57ef8cc..5209905 100644 --- a/Exercise Library/Dead Bug/frames/frame-1.svg +++ b/Exercise Library/Dead Bug/frames/frame-1.svg @@ -1,11 +1,11 @@ Dead Bug - + - + diff --git a/Exercise Library/Dead Bug/frames/frame-2.svg b/Exercise Library/Dead Bug/frames/frame-2.svg index 8fe67f6..2dd4abf 100644 --- a/Exercise Library/Dead Bug/frames/frame-2.svg +++ b/Exercise Library/Dead Bug/frames/frame-2.svg @@ -1,11 +1,11 @@ Dead Bug - + - + diff --git a/Exercise Library/Dead Bug/frames/frame-3.svg b/Exercise Library/Dead Bug/frames/frame-3.svg new file mode 100644 index 0000000..5209905 --- /dev/null +++ b/Exercise Library/Dead Bug/frames/frame-3.svg @@ -0,0 +1,17 @@ + + Dead Bug + + + + + + + + + + + R + + L + + diff --git a/Exercise Library/Dead Bug/frames/frame-4.svg b/Exercise Library/Dead Bug/frames/frame-4.svg new file mode 100644 index 0000000..f3be8a0 --- /dev/null +++ b/Exercise Library/Dead Bug/frames/frame-4.svg @@ -0,0 +1,17 @@ + + Dead Bug + + + + + + + + + + + R + + L + + diff --git a/Exercise Library/Dead Bug/motion.json b/Exercise Library/Dead Bug/motion.json index 974d769..74bc740 100644 --- a/Exercise Library/Dead Bug/motion.json +++ b/Exercise Library/Dead Bug/motion.json @@ -1,7 +1,7 @@ { "name": "Dead Bug", "primary": 2, - "working": ["arm_r", "leg_l"], + "working": ["arm_r", "leg_l", "arm_l", "leg_r"], "frames": [ { "hold": 0.5, "tween": 0.8, @@ -16,6 +16,20 @@ "spine": [180, 180], "neck": 168, "gaze": 90, "arm_r": [135, 135], "arm_l": [90, 90], "leg_r": [64, -10], "leg_l": [16, 16] + }, + { + "hold": 0.5, "tween": 0.8, + "root": [185, 148], + "spine": [180, 180], "neck": 168, "gaze": 90, + "arm_r": [90, 90], "arm_l": [90, 90], + "leg_r": [64, -10], "leg_l": [64, -10] + }, + { + "hold": 0.9, "tween": 0.8, + "root": [185, 148], + "spine": [180, 180], "neck": 168, "gaze": 90, + "arm_r": [90, 90], "arm_l": [135, 135], + "leg_r": [16, 16], "leg_l": [64, -10] } ] } diff --git a/Exercise Library/Dead Bug/preview.gif b/Exercise Library/Dead Bug/preview.gif index 5bec5b6..bad6a1f 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 8fe67f6..2dd4abf 100644 --- a/Exercise Library/Dead Bug/visual.svg +++ b/Exercise Library/Dead Bug/visual.svg @@ -1,11 +1,11 @@ Dead Bug - + - + diff --git a/Exercise Library/Lat Pull Down/frames/frame-1.svg b/Exercise Library/Lat Pull Down/frames/frame-1.svg index d235eb9..c9d12ed 100644 --- a/Exercise Library/Lat Pull Down/frames/frame-1.svg +++ b/Exercise Library/Lat Pull Down/frames/frame-1.svg @@ -2,16 +2,15 @@ Lat Pull Down - - - - - + + + + - + - + diff --git a/Exercise Library/Lat Pull Down/frames/frame-2.svg b/Exercise Library/Lat Pull Down/frames/frame-2.svg index 427b7cd..fc83429 100644 --- a/Exercise Library/Lat Pull Down/frames/frame-2.svg +++ b/Exercise Library/Lat Pull Down/frames/frame-2.svg @@ -2,16 +2,15 @@ Lat Pull Down - - - - - + + + + - + - + diff --git a/Exercise Library/Lat Pull Down/motion.json b/Exercise Library/Lat Pull Down/motion.json index e4d0031..6d4170a 100644 --- a/Exercise Library/Lat Pull Down/motion.json +++ b/Exercise Library/Lat Pull Down/motion.json @@ -6,30 +6,29 @@ "props": [ {"type": "scene", "shapes": [ {"kind": "line", "pts": [[126, 130], [162, 130]], "w": 8}, - {"kind": "line", "pts": [[124, 130], [124, 56]], "w": 5}, - {"kind": "line", "pts": [[124, 56], [198, 12]], "w": 5}, - {"kind": "rect", "x": 170, "y": 112, "w": 40, "h": 7, "r": 3} + {"kind": "line", "pts": [[144, 132], [144, 150]], "w": 5}, + {"kind": "circle", "c": [272, 19], "r": 4, "fill": true, "color": "prop"} ]}, - {"type": "cable", "from": [198, 8], "to": ["hand_r", "hand_l"]}, - {"type": "bar", "at": ["hand_r", "hand_l"], "halfLen": 28} + {"type": "cable", "from": [272, 19], "to": ["hand_r", "hand_l"], "w": 4}, + {"type": "bar", "at": ["hand_r", "hand_l"], "halfLen": 24} ], "frames": [ { "hold": 0.4, "tween": 1.0, "root": [150, 122], "spine": [93, 90], "neck": 85, "gaze": 55, - "arm_r": [20, 10], "arm_l": [20, 10], + "arm_r": [35, 35], "arm_l": [35, 35], "leg_r": [-12, -80], "leg_l": [-12, -80], - "pins": {"hand_r": [198, 24], "hand_l": [202, 26], + "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": [-115, -20], "arm_l": [-115, -20], + "arm_r": [-38, 38], "arm_l": [-38, 38], "leg_r": [-12, -80], "leg_l": [-12, -80], - "pins": {"hand_r": [165, 66], "hand_l": [169, 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 daab8d5..d432080 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 427b7cd..fc83429 100644 --- a/Exercise Library/Lat Pull Down/visual.svg +++ b/Exercise Library/Lat Pull Down/visual.svg @@ -2,16 +2,15 @@ Lat Pull Down - - - - - + + + + - + - + diff --git a/Exercise Library/SYSTEM.md b/Exercise Library/SYSTEM.md index e3123ef..d1899bb 100644 --- a/Exercise Library/SYSTEM.md +++ b/Exercise Library/SYSTEM.md @@ -13,7 +13,10 @@ changing data, never artwork. 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. + `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. @@ -22,6 +25,7 @@ changing data, never artwork. { "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 "frames": [ @@ -88,7 +92,10 @@ scale drawings of the machine. - **`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 - `at` (a single extremity, or the midpoint of `["hand_r", "hand_l"]`). + `at` (a single joint, or the midpoint of a list). Joints are the extremities + (`hand_r`, `foot_l`, …) plus the mid joints (`elbow_r`, `knee_l`, …), so a + machine pad can ride a knee (`["knee_r", "knee_l"]`) or span a shin + (`["knee_r", "foot_r"]`). `bar` lies at a fixed world `angle` (default 0 = horizontal — in side view a two-handed bar is drawn horizontal by convention); `dumbbell` and `pad` default to perpendicular to the lower bone (forearm/shin), or take an diff --git a/Exercise Library/Seated Row/frames/frame-1.svg b/Exercise Library/Seated Row/frames/frame-1.svg index c5388f1..6d59643 100644 --- a/Exercise Library/Seated Row/frames/frame-1.svg +++ b/Exercise Library/Seated Row/frames/frame-1.svg @@ -5,10 +5,10 @@ - + - + diff --git a/Exercise Library/Seated Row/motion.json b/Exercise Library/Seated Row/motion.json index 5e582b0..3e53395 100644 --- a/Exercise Library/Seated Row/motion.json +++ b/Exercise Library/Seated Row/motion.json @@ -17,7 +17,7 @@ "hold": 0.4, "tween": 1.0, "root": [140, 122], "spine": [85, 82], "neck": 80, "gaze": 5, - "arm_r": [-10, -25], "arm_l": [-10, -25], + "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]} diff --git a/Exercise Library/Seated Row/preview.gif b/Exercise Library/Seated Row/preview.gif index 7adf014..ee9a5ec 100644 Binary files a/Exercise Library/Seated Row/preview.gif and b/Exercise Library/Seated Row/preview.gif differ diff --git a/Exercise Library/Tricep Press/frames/frame-1.svg b/Exercise Library/Tricep Press/frames/frame-1.svg index 9bc4e3b..db4abcc 100644 --- a/Exercise Library/Tricep Press/frames/frame-1.svg +++ b/Exercise Library/Tricep Press/frames/frame-1.svg @@ -5,12 +5,12 @@ - + - + - + diff --git a/Exercise Library/Tricep Press/frames/frame-2.svg b/Exercise Library/Tricep Press/frames/frame-2.svg index a77e7ee..e98eb78 100644 --- a/Exercise Library/Tricep Press/frames/frame-2.svg +++ b/Exercise Library/Tricep Press/frames/frame-2.svg @@ -5,12 +5,12 @@ - + - + - + diff --git a/Exercise Library/Tricep Press/motion.json b/Exercise Library/Tricep Press/motion.json index fe227fa..8d34971 100644 --- a/Exercise Library/Tricep Press/motion.json +++ b/Exercise Library/Tricep Press/motion.json @@ -17,17 +17,19 @@ "hold": 0.4, "tween": 1.1, "root": [118, 124], "spine": [100, 98], "neck": 86, "gaze": 6, - "arm_r": [-78, 30], "arm_l": [-78, 30], + "arm_r": [-108, 12], "arm_l": [-108, 12], "leg_r": [-24, -74], "leg_l": [-24, -74], - "pins": {"foot_r": [156, 151], "foot_l": [160, 152]} + "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": [-78, -88], "arm_l": [-78, -88], + "arm_r": [-81, -61], "arm_l": [-81, -61], "leg_r": [-24, -74], "leg_l": [-24, -74], - "pins": {"foot_r": [156, 151], "foot_l": [160, 152]} + "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 512378d..55d6f0c 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 a77e7ee..e98eb78 100644 --- a/Exercise Library/Tricep Press/visual.svg +++ b/Exercise Library/Tricep Press/visual.svg @@ -5,12 +5,12 @@ - + - + - + diff --git a/Exercise Library/body.json b/Exercise Library/body.json index e19cb7f..87a8fe9 100644 --- a/Exercise Library/body.json +++ b/Exercise Library/body.json @@ -10,6 +10,17 @@ "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, diff --git a/Exercise Library/contact-sheet.png b/Exercise Library/contact-sheet.png index 4ccc472..f2f3838 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 420f883..f557c8e 100644 Binary files a/Exercise Library/demo-sheet.png and b/Exercise Library/demo-sheet.png differ diff --git a/Exercise Library/render.py b/Exercise Library/render.py index 33b1b68..187ecde 100644 --- a/Exercise Library/render.py +++ b/Exercise Library/render.py @@ -57,7 +57,14 @@ LIMBS = { # limb -> (attach joint, [bone length keys], pin key) ANGLE_KEYS = ["spine", "neck", "gaze", "arm_r", "arm_l", "leg_r", "leg_l"] -JOINT_LIMB = {"hand_r": "arm_r", "hand_l": "arm_l", "foot_r": "leg_r", "foot_l": "leg_l"} +# Prop joint refs -> (limb, chain index): extremities are index 2, mid joints +# (elbows/knees) index 1, so equipment can ride either joint. +JOINT_LIMB = { + "hand_r": ("arm_r", 2), "elbow_r": ("arm_r", 1), + "hand_l": ("arm_l", 2), "elbow_l": ("arm_l", 1), + "foot_r": ("leg_r", 2), "knee_r": ("leg_r", 1), + "foot_l": ("leg_l", 2), "knee_l": ("leg_l", 1), +} def bodies(): @@ -244,17 +251,19 @@ def flip_props(props, width): def joint_points(geo, ref): - """Resolve a joint ref — `"hand_r"` or a `["hand_r", "hand_l"]` midpoint — - to (point, lower-bone unit direction). None when the limb isn't drawn.""" + """Resolve a joint ref — `"hand_r"`, `"knee_l"`, or a midpoint list like + `["knee_r", "foot_r"]` — to (point, unit direction of the bone ending at + the first joint). None when the limb isn't drawn.""" names = ref if isinstance(ref, list) else [ref] pts, direction = [], None for name in names: - limb = geo.get(JOINT_LIMB[name]) + limb_name, idx = JOINT_LIMB[name] + limb = geo.get(limb_name) if not limb: return None, None - pts.append(limb[-1]) + pts.append(limb[idx]) if direction is None: - a, b = limb[-2], limb[-1] + a, b = limb[idx - 1], limb[idx] d = math.hypot(b[0] - a[0], b[1] - a[1]) or 1.0 direction = ((b[0] - a[0]) / d, (b[1] - a[1]) / d) return ((sum(p[0] for p in pts) / len(pts), @@ -487,7 +496,9 @@ def export_app_resources(folders): def render_exercise(folder, figure="neutral", flip=False): motion = load_motion(folder) - body = bodies()[figure] + # 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", [])) props = motion.get("props", []) @@ -521,10 +532,11 @@ def contact_sheet(folders, figure="neutral", out=None): from PIL import Image, ImageDraw font = legend_font() - body = bodies()[figure] + 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): diff --git a/Scripts/generate_starter_splits.swift b/Scripts/generate_starter_splits.swift index c3fae5b..8415690 100644 --- a/Scripts/generate_starter_splits.swift +++ b/Scripts/generate_starter_splits.swift @@ -70,29 +70,32 @@ struct SplitDocument: Codable { // MARK: - Seed data (canonical source for Workouts/Resources/StarterSplits/*.json) -struct Ex { let name: String; var weight = 0; var sets = 4; var reps = 10; var load = 1; var dur = 0 } +// `machine: true` emits `machineSettings: []` — marks the exercise machine-based +// (empty = nothing recorded yet), which lights up the machine-settings UI without +// the user having to flip the editor toggle first. +struct Ex { let name: String; var weight = 0; var sets = 4; var reps = 10; var load = 1; var dur = 0; var machine = false } struct Sp { let name: String; let color: String; let icon: String; let activity: Int; let ex: [Ex] } let seeds: [Sp] = [ Sp(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: 0, ex: [ - Ex(name: "Lat Pull Down", weight: 110), - Ex(name: "Seated Row", weight: 90), - Ex(name: "Shoulder Press", weight: 40), - Ex(name: "Chest Press", weight: 40), - Ex(name: "Tricep Press", weight: 100), - Ex(name: "Arm Curl", weight: 40), + Ex(name: "Lat Pull Down", weight: 110, machine: true), + Ex(name: "Seated Row", weight: 90, machine: true), + Ex(name: "Shoulder Press", weight: 40, machine: true), + Ex(name: "Chest Press", weight: 40, machine: true), + Ex(name: "Tricep Press", weight: 100, machine: true), + Ex(name: "Arm Curl", weight: 40, machine: true), ]), Sp(name: "Core", color: "orange", icon: "figure.core.training", activity: 3, ex: [ - Ex(name: "Abdominal", weight: 40), - Ex(name: "Rotary", weight: 40), + Ex(name: "Abdominal", weight: 40, machine: true), + Ex(name: "Rotary", weight: 40, machine: true), ]), Sp(name: "Lower Body", color: "green", icon: "figure.run", activity: 0, ex: [ - Ex(name: "Leg Press", weight: 140), - Ex(name: "Leg Curl", weight: 40), - Ex(name: "Leg Extension", weight: 80), - Ex(name: "Abductor", weight: 130), - Ex(name: "Adductor", weight: 130), - Ex(name: "Calfs", weight: 100), + Ex(name: "Leg Press", weight: 140, machine: true), + Ex(name: "Leg Curl", weight: 40, machine: true), + Ex(name: "Leg Extension", weight: 80, machine: true), + Ex(name: "Abductor", weight: 130, machine: true), + Ex(name: "Adductor", weight: 130, machine: true), + Ex(name: "Calfs", weight: 100, machine: true), ]), Sp(name: "Bodyweight Core", color: "teal", icon: "figure.core.training", activity: 3, ex: [ Ex(name: "Cat-Cow", sets: 1, reps: 10, load: 0), @@ -128,7 +131,8 @@ for (order, sp) in seeds.enumerated() { ExerciseDocument( id: deterministicULID(label: "starter.\(slug).ex\(idx)"), name: e.name, order: idx, sets: e.sets, reps: e.reps, weight: e.weight, - loadType: e.load, durationSeconds: e.dur) + loadType: e.load, durationSeconds: e.dur, + machineSettings: e.machine ? [] : nil) }, activityType: sp.activity) let data = try encoder.encode(doc) diff --git a/Workouts/ExerciseFigure/ExerciseFigureView.swift b/Workouts/ExerciseFigure/ExerciseFigureView.swift index 91d5602..24d7e07 100644 --- a/Workouts/ExerciseFigure/ExerciseFigureView.swift +++ b/Workouts/ExerciseFigure/ExerciseFigureView.swift @@ -140,16 +140,27 @@ struct ExerciseFigureView: View { // MARK: Props - /// The extremity point a prop follows (plus the lower-bone unit direction for - /// perpendicular items). Nil when any referenced limb isn't drawn this frame. + /// 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`. + 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), + "foot_r": (.legR, 2), "knee_r": (.legR, 1), + "foot_l": (.legL, 2), "knee_l": (.legL, 1), + ] + + /// The joint point a prop follows (plus the unit direction of the bone ending + /// at the first joint, for perpendicular items). Nil when any referenced limb + /// isn't drawn this frame. private func jointAnchor(_ geo: FigureGeometry, _ ref: PropJointRef) -> (point: CGPoint, direction: CGVector)? { var points: [CGPoint] = [] var direction: CGVector? for name in ref.names { - guard let limb = FigureLimb.allCases.first(where: { $0.pinKey == name }), - let chain = geo.limbs[limb], chain.count >= 2 else { return nil } - let a = chain[chain.count - 2] - let b = chain[chain.count - 1] + guard let joint = Self.propJoints[name], + let chain = geo.limbs[joint.limb], chain.count > joint.index else { return nil } + let a = chain[joint.index - 1] + let b = chain[joint.index] points.append(b) if direction == nil { let d = max(hypot(b.x - a.x, b.y - a.y), 1) diff --git a/Workouts/ExerciseFigure/ExerciseInfo.swift b/Workouts/ExerciseFigure/ExerciseInfo.swift index 5e8843c..3e8ae8b 100644 --- a/Workouts/ExerciseFigure/ExerciseInfo.swift +++ b/Workouts/ExerciseFigure/ExerciseInfo.swift @@ -22,6 +22,13 @@ struct ExerciseInfo { var targets: [String] var sections: [Section] + /// Whether the authored `Type:` identifies this as a machine exercise — the + /// library convention is that every machine entry's type starts "Machine-based". + /// Drives the machine-settings sections' visibility before any settings exist. + var isMachineBased: Bool { + type?.localizedCaseInsensitiveContains("machine") == true + } + struct Section { var title: String var items: [Item] diff --git a/Workouts/ExerciseFigure/ExerciseMotion.swift b/Workouts/ExerciseFigure/ExerciseMotion.swift index 0b62dc2..f16ba56 100644 --- a/Workouts/ExerciseFigure/ExerciseMotion.swift +++ b/Workouts/ExerciseFigure/ExerciseMotion.swift @@ -35,6 +35,9 @@ 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. @@ -45,8 +48,9 @@ struct ExerciseMotion: Codable { let frames: [MotionKeyFrame] } -/// A prop's joint reference: one extremity (`"hand_r"`) or the midpoint of two -/// (`["foot_r", "foot_l"]`). Extremity keys match the pin keys. +/// A prop's joint reference: one joint (`"hand_r"`, `"knee_l"`, `"elbow_r"`, …) +/// or the midpoint of two (`["foot_r", "foot_l"]`, `["knee_r", "foot_r"]`). +/// Extremity keys match the pin keys; elbows/knees are the mid joints. enum PropJointRef: Codable { case single(String) case midpoint([String]) @@ -180,7 +184,7 @@ enum ExerciseMotionLibrary { let bodyData = try? Data(contentsOf: bodyURL), let motion = try? JSONDecoder().decode(ExerciseMotion.self, from: motionData), let profiles = try? JSONDecoder().decode([String: ExerciseBodyProfile].self, from: bodyData), - let body = profiles["neutral"] + let body = profiles[motion.figure ?? "neutral"] else { return nil } return Resources(motion: motion, body: body) } diff --git a/Workouts/Resources/ExerciseMotions/Abductor.motion.json b/Workouts/Resources/ExerciseMotions/Abductor.motion.json index e986689..626a2a5 100644 --- a/Workouts/Resources/ExerciseMotions/Abductor.motion.json +++ b/Workouts/Resources/ExerciseMotions/Abductor.motion.json @@ -1,17 +1,17 @@ { "name": "Abductor", "primary": 2, + "figure": "frontal", "working": ["leg_r", "leg_l"], "hide": [], "props": [ {"type": "scene", "shapes": [ {"kind": "rect", "x": 152, "y": 56, "w": 16, "h": 72, "r": 5}, {"kind": "rect", "x": 130, "y": 126, "w": 60, "h": 10, "r": 3}, - {"kind": "line", "pts": [[136, 78], [136, 108]], "w": 4, "color": "prop"}, - {"kind": "line", "pts": [[188, 78], [188, 108]], "w": 4, "color": "prop"}, - {"kind": "line", "pts": [[213, 110], [213, 132]], "w": 9, "color": "prop"}, - {"kind": "line", "pts": [[107, 110], [107, 132]], "w": 9, "color": "prop"} - ]} + {"kind": "line", "pts": [[160, 136], [160, 150]], "w": 6} + ]}, + {"type": "pad", "at": ["knee_r"], "angle": 90, "halfLen": 13, "w": 6}, + {"type": "pad", "at": ["knee_l"], "angle": 90, "halfLen": 13, "w": 6} ], "frames": [ { @@ -19,16 +19,14 @@ "root": [160, 116], "spine": [90, 90], "neck": 90, "arm_r": [-48, -82], "arm_l": [-132, -98], - "leg_r": [-22, 0], "leg_l": [-158, 0], - "pins": {"foot_r": [180, 156], "foot_l": [140, 156]} + "leg_r": [-75, -90], "leg_l": [-105, -90] }, { "hold": 0.5, "tween": 1.1, "root": [160, 116], "spine": [90, 90], "neck": 90, "arm_r": [-48, -82], "arm_l": [-132, -98], - "leg_r": [-22, 0], "leg_l": [-158, 0], - "pins": {"foot_r": [216, 156], "foot_l": [104, 156]} + "leg_r": [-30, -90], "leg_l": [-150, -90] } ] } diff --git a/Workouts/Resources/ExerciseMotions/Adductor.motion.json b/Workouts/Resources/ExerciseMotions/Adductor.motion.json index c012ce9..348c15e 100644 --- a/Workouts/Resources/ExerciseMotions/Adductor.motion.json +++ b/Workouts/Resources/ExerciseMotions/Adductor.motion.json @@ -1,17 +1,17 @@ { "name": "Adductor", "primary": 2, + "figure": "frontal", "working": ["leg_r", "leg_l"], "hide": [], "props": [ {"type": "scene", "shapes": [ {"kind": "rect", "x": 152, "y": 56, "w": 16, "h": 72, "r": 5}, {"kind": "rect", "x": 130, "y": 126, "w": 60, "h": 10, "r": 3}, - {"kind": "line", "pts": [[136, 78], [136, 108]], "w": 4, "color": "prop"}, - {"kind": "line", "pts": [[188, 78], [188, 108]], "w": 4, "color": "prop"}, - {"kind": "line", "pts": [[197, 110], [197, 132]], "w": 9, "color": "prop"}, - {"kind": "line", "pts": [[123, 110], [123, 132]], "w": 9, "color": "prop"} - ]} + {"kind": "line", "pts": [[160, 136], [160, 150]], "w": 6} + ]}, + {"type": "pad", "at": ["knee_r"], "angle": 90, "halfLen": 13, "w": 6}, + {"type": "pad", "at": ["knee_l"], "angle": 90, "halfLen": 13, "w": 6} ], "frames": [ { @@ -19,16 +19,14 @@ "root": [160, 116], "spine": [90, 90], "neck": 90, "arm_r": [-48, -82], "arm_l": [-132, -98], - "leg_r": [-22, 0], "leg_l": [-158, 0], - "pins": {"foot_r": [216, 156], "foot_l": [104, 156]} + "leg_r": [-30, -90], "leg_l": [-150, -90] }, { "hold": 0.5, "tween": 1.1, "root": [160, 116], "spine": [90, 90], "neck": 90, "arm_r": [-48, -82], "arm_l": [-132, -98], - "leg_r": [-22, 0], "leg_l": [-158, 0], - "pins": {"foot_r": [180, 156], "foot_l": [140, 156]} + "leg_r": [-75, -90], "leg_l": [-105, -90] } ] } diff --git a/Workouts/Resources/ExerciseMotions/Arm Curl.motion.json b/Workouts/Resources/ExerciseMotions/Arm Curl.motion.json index cf63762..03f5648 100644 --- a/Workouts/Resources/ExerciseMotions/Arm Curl.motion.json +++ b/Workouts/Resources/ExerciseMotions/Arm Curl.motion.json @@ -6,8 +6,8 @@ "props": [ {"type": "scene", "shapes": [ {"kind": "line", "pts": [[116, 134], [150, 134]], "w": 8}, - {"kind": "line", "pts": [[146, 42], [176, 68]], "w": 8}, - {"kind": "line", "pts": [[176, 68], [176, 128]], "w": 5} + {"kind": "line", "pts": [[136, 52], [164, 70]], "w": 8}, + {"kind": "line", "pts": [[164, 70], [164, 128]], "w": 5} ]}, {"type": "bar", "at": ["hand_r", "hand_l"], "angle": 90, "halfLen": 6} ], @@ -16,7 +16,7 @@ "hold": 0.4, "tween": 1.0, "root": [130, 124], "spine": [88, 86], "neck": 82, "gaze": -18, - "arm_r": [-35, -52], "arm_l": [-35, -52], + "arm_r": [-35, -58], "arm_l": [-35, -58], "leg_r": [-8, -72], "leg_l": [-8, -72], "pins": {"foot_r": [176, 150], "foot_l": [180, 151]} }, @@ -24,7 +24,7 @@ "hold": 0.4, "tween": 1.2, "root": [130, 124], "spine": [88, 86], "neck": 82, "gaze": -18, - "arm_r": [-35, 138], "arm_l": [-35, 138], + "arm_r": [-35, 50], "arm_l": [-35, 50], "leg_r": [-8, -72], "leg_l": [-8, -72], "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 4c04ef0..15336d5 100644 --- a/Workouts/Resources/ExerciseMotions/Bird Dog.motion.json +++ b/Workouts/Resources/ExerciseMotions/Bird Dog.motion.json @@ -1,7 +1,7 @@ { "name": "Bird Dog", "primary": 2, - "working": ["arm_r", "leg_l"], + "working": ["arm_r", "leg_l", "arm_l", "leg_r"], "frames": [ { "hold": 0.5, "tween": 0.8, @@ -18,6 +18,22 @@ "arm_r": [161, 161], "arm_l": [-90, -90], "leg_r": [-83, 0], "leg_l": [15, 15], "pins": {"hand_l": [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]} + }, + { + "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]} } ] } diff --git a/Workouts/Resources/ExerciseMotions/Calfs.motion.json b/Workouts/Resources/ExerciseMotions/Calfs.motion.json index 2d07937..ce2a646 100644 --- a/Workouts/Resources/ExerciseMotions/Calfs.motion.json +++ b/Workouts/Resources/ExerciseMotions/Calfs.motion.json @@ -9,10 +9,10 @@ {"kind": "line", "pts": [[138, 128], [138, 150]], "w": 5}, {"kind": "line", "pts": [[120, 150], [156, 150]], "w": 5}, {"kind": "rect", "x": 198, "y": 147, "w": 34, "h": 6, "r": 1}, - {"kind": "line", "pts": [[183, 62], [183, 124]], "w": 4}, - {"kind": "rect", "x": 170, "y": 121, "w": 27, "h": 7, "r": 2}, + {"kind": "line", "pts": [[183, 62], [183, 122]], "w": 4}, {"kind": "line", "pts": [[164, 76], [183, 76]], "w": 3, "color": "prop"} ]}, + {"type": "pad", "at": ["knee_r", "knee_l"], "angle": 0, "halfLen": 14, "w": 7}, {"type": "pad", "at": ["foot_r", "foot_l"], "angle": 15, "halfLen": 7, "w": 5} ], "frames": [ diff --git a/Workouts/Resources/ExerciseMotions/Dead Bug.motion.json b/Workouts/Resources/ExerciseMotions/Dead Bug.motion.json index 974d769..74bc740 100644 --- a/Workouts/Resources/ExerciseMotions/Dead Bug.motion.json +++ b/Workouts/Resources/ExerciseMotions/Dead Bug.motion.json @@ -1,7 +1,7 @@ { "name": "Dead Bug", "primary": 2, - "working": ["arm_r", "leg_l"], + "working": ["arm_r", "leg_l", "arm_l", "leg_r"], "frames": [ { "hold": 0.5, "tween": 0.8, @@ -16,6 +16,20 @@ "spine": [180, 180], "neck": 168, "gaze": 90, "arm_r": [135, 135], "arm_l": [90, 90], "leg_r": [64, -10], "leg_l": [16, 16] + }, + { + "hold": 0.5, "tween": 0.8, + "root": [185, 148], + "spine": [180, 180], "neck": 168, "gaze": 90, + "arm_r": [90, 90], "arm_l": [90, 90], + "leg_r": [64, -10], "leg_l": [64, -10] + }, + { + "hold": 0.9, "tween": 0.8, + "root": [185, 148], + "spine": [180, 180], "neck": 168, "gaze": 90, + "arm_r": [90, 90], "arm_l": [135, 135], + "leg_r": [16, 16], "leg_l": [64, -10] } ] } diff --git a/Workouts/Resources/ExerciseMotions/Lat Pull Down.motion.json b/Workouts/Resources/ExerciseMotions/Lat Pull Down.motion.json index e4d0031..6d4170a 100644 --- a/Workouts/Resources/ExerciseMotions/Lat Pull Down.motion.json +++ b/Workouts/Resources/ExerciseMotions/Lat Pull Down.motion.json @@ -6,30 +6,29 @@ "props": [ {"type": "scene", "shapes": [ {"kind": "line", "pts": [[126, 130], [162, 130]], "w": 8}, - {"kind": "line", "pts": [[124, 130], [124, 56]], "w": 5}, - {"kind": "line", "pts": [[124, 56], [198, 12]], "w": 5}, - {"kind": "rect", "x": 170, "y": 112, "w": 40, "h": 7, "r": 3} + {"kind": "line", "pts": [[144, 132], [144, 150]], "w": 5}, + {"kind": "circle", "c": [272, 19], "r": 4, "fill": true, "color": "prop"} ]}, - {"type": "cable", "from": [198, 8], "to": ["hand_r", "hand_l"]}, - {"type": "bar", "at": ["hand_r", "hand_l"], "halfLen": 28} + {"type": "cable", "from": [272, 19], "to": ["hand_r", "hand_l"], "w": 4}, + {"type": "bar", "at": ["hand_r", "hand_l"], "halfLen": 24} ], "frames": [ { "hold": 0.4, "tween": 1.0, "root": [150, 122], "spine": [93, 90], "neck": 85, "gaze": 55, - "arm_r": [20, 10], "arm_l": [20, 10], + "arm_r": [35, 35], "arm_l": [35, 35], "leg_r": [-12, -80], "leg_l": [-12, -80], - "pins": {"hand_r": [198, 24], "hand_l": [202, 26], + "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": [-115, -20], "arm_l": [-115, -20], + "arm_r": [-38, 38], "arm_l": [-38, 38], "leg_r": [-12, -80], "leg_l": [-12, -80], - "pins": {"hand_r": [165, 66], "hand_l": [169, 68], + "pins": {"hand_r": [194, 35], "hand_l": [198, 37], "foot_r": [193, 150], "foot_l": [197, 151]} } ] diff --git a/Workouts/Resources/ExerciseMotions/Seated Row.motion.json b/Workouts/Resources/ExerciseMotions/Seated Row.motion.json index 5e582b0..3e53395 100644 --- a/Workouts/Resources/ExerciseMotions/Seated Row.motion.json +++ b/Workouts/Resources/ExerciseMotions/Seated Row.motion.json @@ -17,7 +17,7 @@ "hold": 0.4, "tween": 1.0, "root": [140, 122], "spine": [85, 82], "neck": 80, "gaze": 5, - "arm_r": [-10, -25], "arm_l": [-10, -25], + "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]} diff --git a/Workouts/Resources/ExerciseMotions/Tricep Press.motion.json b/Workouts/Resources/ExerciseMotions/Tricep Press.motion.json index fe227fa..8d34971 100644 --- a/Workouts/Resources/ExerciseMotions/Tricep Press.motion.json +++ b/Workouts/Resources/ExerciseMotions/Tricep Press.motion.json @@ -17,17 +17,19 @@ "hold": 0.4, "tween": 1.1, "root": [118, 124], "spine": [100, 98], "neck": 86, "gaze": 6, - "arm_r": [-78, 30], "arm_l": [-78, 30], + "arm_r": [-108, 12], "arm_l": [-108, 12], "leg_r": [-24, -74], "leg_l": [-24, -74], - "pins": {"foot_r": [156, 151], "foot_l": [160, 152]} + "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": [-78, -88], "arm_l": [-78, -88], + "arm_r": [-81, -61], "arm_l": [-81, -61], "leg_r": [-24, -74], "leg_l": [-24, -74], - "pins": {"foot_r": [156, 151], "foot_l": [160, 152]} + "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 index e19cb7f..87a8fe9 100644 --- a/Workouts/Resources/ExerciseMotions/body.json +++ b/Workouts/Resources/ExerciseMotions/body.json @@ -10,6 +10,17 @@ "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, diff --git a/Workouts/Resources/StarterSplits/Core.split.json b/Workouts/Resources/StarterSplits/Core.split.json index 76a2d71..ff69798 100644 --- a/Workouts/Resources/StarterSplits/Core.split.json +++ b/Workouts/Resources/StarterSplits/Core.split.json @@ -7,6 +7,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT00C4K0NY1NRCFPB3XY", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Abdominal", "order" : 0, "reps" : 10, @@ -17,6 +20,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT001AY3SXTEYHA94MHZ", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Rotary", "order" : 1, "reps" : 10, diff --git a/Workouts/Resources/StarterSplits/Lower Body.split.json b/Workouts/Resources/StarterSplits/Lower Body.split.json index 1c7c5dc..a960c69 100644 --- a/Workouts/Resources/StarterSplits/Lower Body.split.json +++ b/Workouts/Resources/StarterSplits/Lower Body.split.json @@ -7,6 +7,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT00GV3336R9C25W4X2A", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Leg Press", "order" : 0, "reps" : 10, @@ -17,6 +20,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT00Z28DJ7PSP7Q11CJH", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Leg Curl", "order" : 1, "reps" : 10, @@ -27,6 +33,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT00D53Q4QMWAE1BHXR6", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Leg Extension", "order" : 2, "reps" : 10, @@ -37,6 +46,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT006HW2KWNA5BCCDCJE", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Abductor", "order" : 3, "reps" : 10, @@ -47,6 +59,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT00H5B5YD16868Y95ZQ", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Adductor", "order" : 4, "reps" : 10, @@ -57,6 +72,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT002S88DB6NKYZS4EAW", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Calfs", "order" : 5, "reps" : 10, diff --git a/Workouts/Resources/StarterSplits/Upper Body.split.json b/Workouts/Resources/StarterSplits/Upper Body.split.json index 8c768ce..37b38a7 100644 --- a/Workouts/Resources/StarterSplits/Upper Body.split.json +++ b/Workouts/Resources/StarterSplits/Upper Body.split.json @@ -7,6 +7,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT00N1B90PA7K5ABWSSP", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Lat Pull Down", "order" : 0, "reps" : 10, @@ -17,6 +20,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT00R81QRWGCCQG933FZ", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Seated Row", "order" : 1, "reps" : 10, @@ -27,6 +33,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT000FX4241G5MPG4WG2", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Shoulder Press", "order" : 2, "reps" : 10, @@ -37,6 +46,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT00AD5TSVJPZZVA2S41", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Chest Press", "order" : 3, "reps" : 10, @@ -47,6 +59,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT006QZ6XT95301HKJT8", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Tricep Press", "order" : 4, "reps" : 10, @@ -57,6 +72,9 @@ "durationSeconds" : 0, "id" : "01DXF6DT009J5H4Z0XPHBT1H0K", "loadType" : 1, + "machineSettings" : [ + + ], "name" : "Arm Curl", "order" : 5, "reps" : 10, diff --git a/Workouts/Sync/SyncEngine.swift b/Workouts/Sync/SyncEngine.swift index 92469b7..fa54d6e 100644 --- a/Workouts/Sync/SyncEngine.swift +++ b/Workouts/Sync/SyncEngine.swift @@ -311,6 +311,24 @@ final class SyncEngine { do { try context.save() } catch { report("Cache save failed", error) } } + /// Push edited machine settings onto the originating split's exercise (matched + /// by name — logs reference exercises by name only), following the seed + /// clone-on-edit redirect so the live clone is updated. Skips silently when the + /// split is gone or no exercise matches, and when nothing changed (so a pristine + /// seed isn't needlessly forked). Never caches the split's id across the save, + /// since saving a seed mints a new one. + func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, splitID: String?) async { + guard let splitID else { return } + let liveID = currentSplitID(for: splitID) + guard let split = CacheMapper.fetchSplit(id: liveID, in: context) else { return } + var splitDoc = SplitDocument(from: split) + guard let idx = splitDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return } + guard splitDoc.exercises[idx].machineSettings != settings else { return } + splitDoc.exercises[idx].machineSettings = settings + splitDoc.updatedAt = Date() + await save(split: splitDoc) + } + func save(workout doc: WorkoutDocument) async { guard let store else { return } do { diff --git a/Workouts/Views/Exercises/ExerciseLibraryView.swift b/Workouts/Views/Exercises/ExerciseLibraryView.swift index e48d156..ecc3243 100644 --- a/Workouts/Views/Exercises/ExerciseLibraryView.swift +++ b/Workouts/Views/Exercises/ExerciseLibraryView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SwiftData /// The Exercises library screen, pushed from Settings → Library: every exercise with /// a bundled motion rig (the same catalog the picker offers), each opening its @@ -21,22 +22,73 @@ struct ExerciseLibraryView: View { } } -/// One library exercise, in the standard half-and-half exercise layout: the authored -/// reference content (summary, targets, setup/execution/cues…) scrolls in the top -/// half, the looping form-guide figure holds the bottom. Falls back to a full-screen -/// figure when no `info.md` is bundled. +/// One library exercise, in the standard half-and-half exercise layout: the top half +/// scrolls the user's machine settings (their setup cheat-sheet), the authored +/// reference content (summary, targets, setup/execution/cues…), and the weight +/// progression chart; the looping form-guide figure holds the bottom. struct ExerciseLibraryDetailView: View { let exerciseName: String + /// This exercise wherever it appears in the user's splits — the source of the + /// recorded machine settings. + @Query private var exercises: [Exercise] + + /// One completed weighted log is enough to justify the progression chart; + /// bodyweight and timed exercises never accrue one, so they skip the chart + /// instead of plotting a flat zero line. + @Query private var weightedLogs: [WorkoutLog] + + init(exerciseName: String) { + self.exerciseName = exerciseName + let name = exerciseName + _exercises = Query(filter: #Predicate { $0.name == name }) + let completedRaw = WorkoutStatus.completed.rawValue + _weightedLogs = Query(filter: #Predicate { + $0.exerciseName == name && $0.statusRaw == completedRaw && $0.weight > 0 + }) + } + private var info: ExerciseInfo? { ExerciseInfoLibrary.info(for: exerciseName) } + /// Distinct recorded machine-settings lists for this exercise. Usually one; when + /// splits disagree, each distinct list keeps its split's name as a label. + private var settingsGroups: [(label: String?, settings: [MachineSetting])] { + var groups: [(label: String?, settings: [MachineSetting])] = [] + for exercise in exercises { + guard let settings = exercise.machineSettings, !settings.isEmpty else { continue } + guard !groups.contains(where: { $0.settings == settings }) else { continue } + groups.append((exercise.split?.name, settings)) + } + if groups.count == 1 { groups[0].label = nil } + return groups + } + var body: some View { VStack(spacing: 0) { - if let info { - ExerciseInfoContent(info: info) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Show the card whenever settings are recorded — or, for an + // exercise the authored info identifies as machine-based, as an + // empty state, so the feature is discoverable before first use. + if !settingsGroups.isEmpty { + MachineSettingsCard(groups: settingsGroups) + } else if info?.isMachineBased == true { + MachineSettingsCard(groups: []) + } + if let info { + ExerciseInfoContent(info: info) + } + if !weightedLogs.isEmpty { + WeightProgressionChartView(exerciseName: exerciseName) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + ExerciseFigureSlot(exerciseName: exerciseName) } .navigationTitle(exerciseName) @@ -44,38 +96,76 @@ struct ExerciseLibraryDetailView: View { } } +/// The user's recorded machine comfort settings, set apart from the reference text +/// as a card: name–value rows, grouped per split when the recorded lists differ. +/// Empty `groups` renders the not-yet-recorded state (shown for machine-based +/// exercises so the feature is visible before first use). +private struct MachineSettingsCard: View { + let groups: [(label: String?, settings: [MachineSetting])] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Your Machine Settings") + .font(.headline) + if groups.isEmpty { + Text("None recorded yet. Mark the exercise as machine-based in its editor, or use the settings button on the workout screen.") + .font(.callout) + .foregroundStyle(.secondary) + } + ForEach(Array(groups.enumerated()), id: \.offset) { _, group in + VStack(alignment: .leading, spacing: 4) { + if let label = group.label { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(Array(group.settings.enumerated()), id: \.offset) { _, setting in + HStack(alignment: .firstTextBaseline) { + Text(setting.name) + .foregroundStyle(.secondary) + Spacer() + Text(setting.value) + .fontWeight(.semibold) + .monospacedDigit() + } + .font(.callout) + } + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) + } +} + /// Renders a parsed `ExerciseInfo`: summary, target chips, then each authored /// section with its numbered steps or bullets. private struct ExerciseInfoContent: View { let info: ExerciseInfo var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - if !info.summary.isEmpty { - Text(info.summary) - .font(.callout) - .foregroundStyle(.secondary) - } + VStack(alignment: .leading, spacing: 16) { + if !info.summary.isEmpty { + Text(info.summary) + .font(.callout) + .foregroundStyle(.secondary) + } - if !info.targets.isEmpty { - TargetChips(targets: info.targets) - } + if !info.targets.isEmpty { + TargetChips(targets: info.targets) + } - ForEach(info.sections, id: \.title) { section in - VStack(alignment: .leading, spacing: 6) { - Text(section.title) - .font(.headline) - ForEach(Array(section.items.enumerated()), id: \.offset) { index, item in - itemRow(item, ordinal: ordinal(for: item, at: index, in: section)) - } + ForEach(info.sections, id: \.title) { section in + VStack(alignment: .leading, spacing: 6) { + Text(section.title) + .font(.headline) + ForEach(Array(section.items.enumerated()), id: \.offset) { index, item in + itemRow(item, ordinal: ordinal(for: item, at: index, in: section)) } } } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, maxHeight: .infinity) } /// Steps number themselves by their position among the section's steps, so an diff --git a/Workouts/Views/WorkoutLogs/ExerciseView.swift b/Workouts/Views/WorkoutLogs/ExerciseView.swift index 76eda64..7ee17a6 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseView.swift @@ -12,6 +12,7 @@ import SwiftData import Charts struct ExerciseView: View { + @Environment(SyncEngine.self) private var sync @Environment(AppServices.self) private var services let workout: Workout @@ -23,6 +24,7 @@ struct ExerciseView: View { @State private var doc: WorkoutDocument @State private var showingPlanEdit = false @State private var showingNotesEdit = false + @State private var showingMachineSettings = false /// `seedDoc` lets the caller hand over an in-memory document (e.g. the parent's /// working copy right after adding an exercise) so the screen doesn't wait on @@ -41,6 +43,9 @@ struct ExerciseView: View { var body: some View { Group { if let log { + // Deliberately NOT the half-and-half figure layout: this screen is + // dense with the plan, notes, settings, and the progression chart — + // the form guide lives in the run flow and the exercise library. content(for: log) } else { // The just-added log hasn't reached the cache yet; refresh shortly. @@ -54,6 +59,16 @@ struct ExerciseView: View { .sheet(isPresented: $showingNotesEdit) { NotesEditView(workout: workout, logID: logID) } + .sheet(isPresented: $showingMachineSettings) { + if let log { + MachineSettingsSheet( + exerciseName: log.exerciseName, + initialSettings: log.machineSettings ?? [] + ) { updated in + applyMachineSettings(updated) + } + } + } .onAppear { refreshDocIfNeeded() // Take over this run: the watch parks and locks it while we're editing here. @@ -94,6 +109,42 @@ struct ExerciseView: View { } } + // MARK: - Machine Settings — shown when the log carries settings, or for + // any exercise the authored library identifies as machine-based, so the + // section is discoverable before settings are first recorded. + if log.machineSettings != nil + || ExerciseInfoLibrary.info(for: log.exerciseName)?.isMachineBased == true { + let settings = log.machineSettings ?? [] + Section { + if settings.isEmpty { + Text("No settings recorded") + .foregroundColor(.secondary) + .italic() + } else { + ForEach(Array(settings.enumerated()), id: \.offset) { _, setting in + HStack(alignment: .firstTextBaseline) { + Text(setting.name) + .foregroundStyle(.secondary) + Spacer() + Text(setting.value) + .fontWeight(.semibold) + .monospacedDigit() + } + } + } + } header: { + HStack { + Text("Machine Settings") + Spacer() + Button("Edit") { + showingMachineSettings = true + } + .font(.subheadline) + .textCase(.none) + } + } + } + // MARK: - Notes Section (Read-only with Edit button) Section { if let notes = log.notes, !notes.isEmpty { @@ -116,9 +167,12 @@ struct ExerciseView: View { } } - // MARK: - Progress Tracking Chart - Section(header: Text("Progress Tracking")) { - WeightProgressionChartView(exerciseName: log.exerciseName) + // MARK: - Progress Tracking Chart (weighted exercises only — a weight + // chart is meaningless for bodyweight/timed logs) + if LoadType(rawValue: log.loadType) == .weight { + Section(header: Text("Progress Tracking")) { + WeightProgressionChartView(exerciseName: log.exerciseName) + } } } // Pull plan/notes edits made in the sheets back into the live doc. @@ -130,6 +184,23 @@ struct ExerciseView: View { } } + /// Apply edited machine settings: update the log's plan-time snapshot (persisted + /// with the workout), then write the same settings back to the originating + /// split's exercise as the durable default — same sequencing as the workout + /// list's machine sheet. + private func applyMachineSettings(_ settings: [MachineSetting]) { + guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } + doc.logs[i].machineSettings = settings + doc.updatedAt = Date() + let snapshot = doc + let exerciseName = doc.logs[i].exerciseName + let splitID = doc.splitID + Task { + await sync.save(workout: snapshot) + await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID) + } + } + /// If the requested log isn't in the working doc yet (just-added race), pull a /// fresh copy from the cache entity once it catches up. private func refreshDocIfNeeded() { diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index e4152f9..c465712 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -334,28 +334,10 @@ struct WorkoutLogListView: View { let splitID = doc.splitID Task { await sync.save(workout: workoutSnapshot) - await writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID) + await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID) } } - /// Push the settings onto the originating split's exercise (matched by name — - /// logs reference exercises by name only), following the seed clone-on-edit - /// redirect so the live clone is updated. Skips silently when the split is gone - /// or no exercise matches, and when nothing changed (so a pristine seed isn't - /// needlessly forked). Never caches the split's id across the save, since saving - /// a seed mints a new one. - private func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, splitID: String?) async { - guard let splitID else { return } - let liveID = sync.currentSplitID(for: splitID) - guard let split = CacheMapper.fetchSplit(id: liveID, in: modelContext) else { return } - var splitDoc = SplitDocument(from: split) - guard let idx = splitDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return } - guard splitDoc.exercises[idx].machineSettings != settings else { return } - splitDoc.exercises[idx].machineSettings = settings - splitDoc.updatedAt = Date() - await sync.save(split: splitDoc) - } - /// Recompute the workout's status/end from its logs, then persist. private func save() { doc.recomputeStatusFromLogs() @@ -549,8 +531,9 @@ struct SplitExercisePickerSheet: View { /// machine-based log (non-nil `machineSettings`), so there's no machine toggle — /// just the ordered rows (reusing `MachineSettingsEditor`) plus Save/Cancel. Save /// hands the edited array back to the caller, which persists it to the log and -/// writes it back to the split's exercise as the new default. -private struct MachineSettingsSheet: View { +/// writes it back to the split's exercise as the new default. Shared with +/// `ExerciseView`'s Machine Settings section. +struct MachineSettingsSheet: View { @Environment(\.dismiss) private var dismiss let exerciseName: String diff --git a/WorkoutsTests/ExerciseInfoTests.swift b/WorkoutsTests/ExerciseInfoTests.swift index 46c73b3..83ef7aa 100644 --- a/WorkoutsTests/ExerciseInfoTests.swift +++ b/WorkoutsTests/ExerciseInfoTests.swift @@ -12,6 +12,7 @@ struct ExerciseInfoTests { #expect(info.summary.contains("machine bench press")) #expect(info.category == "Main circuit") #expect(info.type == "Machine-based horizontal press") + #expect(info.isMachineBased) #expect(info.targets == ["Pectorals", "anterior deltoids", "triceps"]) #expect(info.sections.map(\.title) == ["Setup", "Execution", "Cues", "Common Mistakes", "Progression"]) diff --git a/WorkoutsTests/ExerciseLibraryQueryTests.swift b/WorkoutsTests/ExerciseLibraryQueryTests.swift new file mode 100644 index 0000000..0be1cf5 --- /dev/null +++ b/WorkoutsTests/ExerciseLibraryQueryTests.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData +import Testing +@testable import Workouts + +/// Exercises the exact fetches `ExerciseLibraryDetailView` builds its machine-settings +/// card and progression-chart gate from, against an in-memory store — locking the +/// predicate shapes to the cache schema. +@MainActor +struct ExerciseLibraryQueryTests { + + private func makeContext() throws -> ModelContext { + let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self]) + // cloudKitDatabase: .none matches WorkoutsModelContainer — see SeedLibraryTests. + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none) + let container = try ModelContainer(for: schema, configurations: [config]) + return ModelContext(container) + } + + @Test func machineSettingsFetchFindsRecordedSettings() throws { + let context = try makeContext() + let split = Split(id: "S1", name: "Upper Body", color: "indigo", + systemImage: "dumbbell.fill", order: 0, + createdAt: .now, updatedAt: .now, jsonRelativePath: "Splits/S1.json") + let exercise = Exercise(id: "E1", name: "Chest Press", order: 0, sets: 3, reps: 10, + weight: 100, loadType: LoadType.weight.rawValue, + durationTotalSeconds: 0, + machineSettings: [MachineSetting(name: "Seat Height", value: "4")]) + exercise.split = split + context.insert(split) + context.insert(exercise) + try context.save() + + let name = "Chest Press" + let fetched = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.name == name } + )) + #expect(fetched.count == 1) + #expect(fetched.first?.machineSettings == [MachineSetting(name: "Seat Height", value: "4")]) + #expect(fetched.first?.split?.name == "Upper Body") + } + + @Test func weightedLogFetchGatesChart() throws { + let context = try makeContext() + let workout = Workout(id: "W1", splitID: nil, splitName: "Upper Body", + start: .now, end: .now, statusRaw: WorkoutStatus.completed.rawValue, + createdAt: .now, updatedAt: .now, + jsonRelativePath: "Workouts/2026/07/W1.json") + let weighted = WorkoutLog(id: "L1", exerciseName: "Chest Press", order: 0, sets: 3, + reps: 10, weight: 100, loadType: LoadType.weight.rawValue, + durationTotalSeconds: 0, currentStateIndex: 3, + statusRaw: WorkoutStatus.completed.rawValue, notes: nil, date: .now) + let bodyweight = WorkoutLog(id: "L2", exerciseName: "Plank", order: 1, sets: 3, + reps: 0, weight: 0, loadType: LoadType.duration.rawValue, + durationTotalSeconds: 60, currentStateIndex: 3, + statusRaw: WorkoutStatus.completed.rawValue, notes: nil, date: .now) + weighted.workout = workout + bodyweight.workout = workout + context.insert(workout) + context.insert(weighted) + context.insert(bodyweight) + try context.save() + + let completedRaw = WorkoutStatus.completed.rawValue + + let chestPress = "Chest Press" + let chestLogs = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.exerciseName == chestPress && $0.statusRaw == completedRaw && $0.weight > 0 } + )) + #expect(chestLogs.count == 1) + + let plank = "Plank" + let plankLogs = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.exerciseName == plank && $0.statusRaw == completedRaw && $0.weight > 0 } + )) + #expect(plankLogs.isEmpty) + } +} diff --git a/WorkoutsTests/ExerciseMotionTests.swift b/WorkoutsTests/ExerciseMotionTests.swift index 780d5fb..93063b1 100644 --- a/WorkoutsTests/ExerciseMotionTests.swift +++ b/WorkoutsTests/ExerciseMotionTests.swift @@ -11,8 +11,10 @@ struct ExerciseMotionTests { @Test func bundledBirdDogResourcesDecode() throws { let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog")) #expect(resources.motion.name == "Bird Dog") - #expect(resources.motion.frames.count == 2) - #expect(resources.motion.working == ["arm_r", "leg_l"]) + // 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). + #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) }