Fourteen Planet Fitness machine exercises join the rig library, each with authored motion, info page, and schematic equipment via the new props layer (scene shapes, cables, bars, pads) rendered in lockstep by render.py and the in-app figure renderer. Abductor and Adductor are authored face-on with the real seated machine motion — knees-bent legs swinging apart/together against knee pads — replacing the earlier middle-split depiction. The watch target now bundles the figure renderer and motion rigs too. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
7.4 KiB
Exercise Visual System
Exercise visuals are produced by an articulated 2D rig: one shared stick body posed per exercise by joint angles. Nothing is drawn by hand — a body profile plus a motion script resolve through forward kinematics into every frame, so figures are always in proportion, and the whole library can be re-proportioned (male/female), flipped, rotated in-plane, or re-themed by changing data, never artwork.
The rig
body.json— proportion profiles. Each profile is a table of bone lengths:headR,neck,spine1/spine2(two chained segments so the spine can curve),upperArm/foreArm,thigh/shin, plusleftOffset(the small offset that separates left-limb attachments visually).neutralis the default; add profiles to add figures.<Exercise>/motion.json— the exercise script: key frames of absolute joint angles (degrees, y-up:0=forward/right,90=up,180=back/left,-90=down), arootpelvis position, and timing.
{
"name": "Bird Dog",
"primary": 2, // 1-based frame used for visual.svg
"working": ["arm_r", "leg_l"], // parts drawn in the accent color
"hide": [], // limbs fully occluded in this view
"frames": [
{
"hold": 0.5, // seconds held at this key frame
"tween": 0.8, // seconds animating to the NEXT frame
"root": [190, 106], // pelvis, canvas coords
"spine": [171, 171], // pelvis→mid, mid→neck angles
"neck": 187, "gaze": 205, // head direction; nose tick direction
"arm_r": [-90, -90], // upper-arm, forearm angles
"arm_l": [-90, -90],
"leg_r": [-83, 0], // thigh, shin angles
"leg_l": [-83, 0],
"pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
}
]
}
- Pins (IK) — a planted hand/foot names a target point
(
hand_r/hand_l/foot_r/foot_l); the renderer solves the two-bone chain analytically so the extremity holds that point exactly, using the authored angles only to pick the elbow/knee bend direction. A pin active in two consecutive key frames stays planted throughout the tween (plank forearms, side-plank support arm); a pin present in only one frame releases naturally (bird-dog arm lifting off). - Tweening happens in angle space (shortest path), so limbs swing in
natural arcs and bone lengths never distort. The last frame tweens back to
the first (looping). Asymmetric timing carries technique: leg raises lower
slowly (
tween1.4 s down, 0.6 s up). - Face-on figures —
gazeis 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.
The props layer
Machines and free weights are data too: an optional top-level "props" array
adds an equipment layer around the figure. scene shapes and cables draw
behind the figure in a recessive gray; joint-attached items (bar,
dumbbell, pad) draw over the limbs in a darker gray and follow the
resolved hand/foot positions every frame — a pinned foot pressing a pad
carries the platform with it through the tween for free. The figure stays the
hero: props are schematic silhouettes (a seat, a backrest, one handle), never
scale drawings of the machine.
"props": [
{"type": "scene", "shapes": [
{"kind": "line", "pts": [[134, 123], [96, 36]], "w": 9},
{"kind": "rect", "x": 54, "y": 104, "w": 40, "h": 8, "r": 3},
{"kind": "circle", "c": [142, 77], "r": 3.5, "fill": true, "color": "prop"}
]},
{"type": "cable", "from": [190, 8], "to": ["hand_r", "hand_l"]},
{"type": "bar", "at": ["hand_r", "hand_l"], "halfLen": 26, "plateR": 0},
{"type": "dumbbell", "at": "hand_r"},
{"type": "pad", "at": ["foot_r", "foot_l"], "angle": 88, "halfLen": 20, "w": 6}
]
scene— static shapes in canvas coordinates:line(polyline, stroke widthw),circle(fill: falsefor an outline),rect(filled, corner radiusr). A shape may set"color": "prop"to use the darker attached-item gray (e.g. a fixed handle the hands rest on).cable— a thin line from a fixed anchorfromto a moving jointto; the machine's pulley line.bar/dumbbell/pad— a segment centered on the joint(s) inat(a single extremity, or the midpoint of["hand_r", "hand_l"]).barlies at a fixed worldangle(default 0 = horizontal — in side view a two-handed bar is drawn horizontal by convention);dumbbellandpaddefault to perpendicular to the lower bone (forearm/shin), or take an explicitangle.plateRputs 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.jsonand 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.pyreference renders embed a smallR —/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 (
gazeangle); 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
hideare fully occluded in this view and not drawn.
Rendering
cd "Exercise Library"
python3 render.py # all exercises: frames/*.svg, preview.gif, visual.svg
python3 render.py "Bird Dog" # one exercise
python3 render.py --sheet # + contact-sheet.png of every key frame
python3 render.py --demo # + demo-sheet.png: profile / flip / theme variants
python3 render.py --figure=female # render with another body profile
python3 render.py --flip # mirror the figure (faces the other way)
python3 render.py --export # copy body.json + <Name>.motion.json + <Name>.info.md app resources
render.py needs only Pillow (for GIFs/sheets; the SVGs have no dependency).
The library lives at the repo root, outside every target's source folders —
same-named files per entry (info.md, visual.svg) would collide in Xcode's
flat resource copy, so the library itself never enters the app bundle. Only
the --export copies ship: body.json plus uniquely-named
<Name>.motion.json and <Name>.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.