Rebuild the exercise figure on an anatomical 3D skeleton

The library's planar world-angle rig becomes a genuine 3D anatomical
model: skeleton.json holds bone-length profiles (real shoulder/pelvis
widths, feet, neutral/female/male) and per-joint ROM; motions pose
joints with anatomical angles (flexion/abduction/rotation from neutral
standing) under a per-exercise orthographic camera, resolved by
kinematics.py (3D FK, analytic two-bone IK with anatomical write-back)
and validated against physiological ranges. All 20 sagittal motions
were migrated by planar decomposition with 0.00 px golden parity against
the old renderer — relabeled to true anatomy, since shading is now
near-dark/far-light by camera depth rather than by limb suffix — and
the face-on machines are re-authored honestly: Abductor/Adductor with
real hip abduction (the foreshortened "frontal" profile is retired) and
Rotary with genuine spine axial rotation. Figures gain articulated
feet; profiles swap without touching a single motion script; --orbit
sweeps the camera 360° while a motion loops.

The in-app SwiftUI renderer (iOS + watch) is ported to the same model
and consumes the exported motions verbatim; figure-fixtures.json pins
its geometry to the Python pipeline within 0.5 px across every
exercise, key frame, tween, and orbit sample. Also makes the watch
bridge logger nonisolated for the newer SDK's stricter isolation
checking.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
2026-07-06 20:10:50 -04:00
parent 6521de8f17
commit 3c7a790e9d
153 changed files with 2827 additions and 1686 deletions
+251 -226
View File
@@ -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 `<Name>.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 ab."""
"""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'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" fill="none">',
@@ -384,20 +338,20 @@ def svg_for_frame(name, geo, working, colors, props=None):
f' <line x1="16" y1="{GROUND_Y + 4}" x2="{w - 16}" y2="{GROUND_Y + 4}"'
f' stroke="{colors["ground"]}" stroke-width="3" stroke-linecap="round"/>']
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' <circle id="head" cx="{hx:.1f}" cy="{hy:.1f}" r="{geo["headR"]}"'
f' fill="{colors["head_fill"]}" stroke="{colors["right"]}" stroke-width="{WIDTHS["head"]}"/>')
if "nose" in geo:
(sx, sy), (ex, ey) = nose_segment(geo)
(sx, sy), (ex, ey) = geo["nose"]
parts.append(f' <line id="nose" x1="{sx:.1f}" y1="{sy:.1f}" x2="{ex:.1f}" y2="{ey:.1f}"'
f' stroke="{colors["right"]}" stroke-width="{WIDTHS["nose"]}" stroke-linecap="round"/>')
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' <path id="{part}" d="{d}" stroke="{color}" stroke-width="{width}"'
f' stroke-linecap="round" stroke-linejoin="round"/>')
lx = w - 78
lx = w - 96
parts.append(f' <g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="{colors["legend_text"]}">')
parts.append(f' <line x1="{lx}" y1="16" x2="{lx + 16}" y2="16" stroke="{colors["right"]}" stroke-width="4" stroke-linecap="round"/>')
parts.append(f' <text x="{lx + 22}" y="20">R</text>')
parts.append(f' <line x1="{lx + 40}" y1="16" x2="{lx + 56}" y2="16" stroke="{colors["left"]}" stroke-width="4" stroke-linecap="round"/>')
parts.append(f' <text x="{lx + 62}" y="20">L</text>')
parts.append(f' <line x1="{lx}" y1="16" x2="{lx + 14}" y2="16" stroke="{colors["right"]}" stroke-width="4" stroke-linecap="round"/>')
parts.append(f' <text x="{lx + 19}" y="20">near</text>')
parts.append(f' <line x1="{lx + 49}" y1="16" x2="{lx + 63}" y2="16" stroke="{colors["left"]}" stroke-width="4" stroke-linecap="round"/>')
parts.append(f' <text x="{lx + 68}" y="20">far</text>')
parts.append(' </g>')
parts.append('</svg>')
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 `<Name>.motion.json`
and one `<Name>.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
`<Name>.motion.json` and one `<Name>.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: