Files
workouts/Exercise Library/SYSTEM.md
T
rzen b82054b81a View the figure from a slight elevation and refine the leg-machine rollers
The default camera pitches down 10 degrees, so the floor reads as a
plane (drawn as a rectangle) and near/far contacts straddle it.
Elevation is pure presentation - IK pins solve in the flat authored
view and the posed body tilts, the same pattern as the orbit, so
authored canvas targets never go out of reach. The leg-extension
roller moves up onto the shin above the ankle and the leg-curl roller
tucks under the heel. Fixtures and reference test values regenerated
for the pitched camera.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
2026-07-06 21:20:07 -04:00

11 KiB
Raw Blame History

Exercise Visual System

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

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 profilesneutral, 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.

<Exercise>/motion.json — the exercise script:

{
  "name": "Bird Dog",
  "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, "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 canvas target (hand_r/hand_l/foot_r/foot_l); the renderer solves the two-bone 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).
  • Elevation — the default viewpoint pitches down 10° (CAMERA_PITCH; override per motion via "camera": {"pitch": ...}), so the floor reads as a plane — drawn as a rectangle — and near/far contacts straddle it. Elevation is pure presentation: pins solve in the flat authored view and the posed body tilts, so authored canvas targets never go out of reach.
  • 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, with the shoulder girdle and pelvis drawn as bars across the attach points (near-full width face-on, a shoulder/hip nub in profile); 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

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 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). 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 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 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.
  • roller — a machine roller pad seen end-on: a filled disc riding the limb's lower bone near the joint in at, on the side (+1/1) of the bone it presses — a leg extension's instep roller (side: 1), a leg curl's heel roller (side: -1). r is the radius, back slides it along the bone away from the joint.

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: 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: skeleton.json plus uniquely-named <Name>.motion.json and <Name>.info.md files in 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.