Files
workouts/Exercise Library/render.py
T
rzen b8c3b326c1 Draw both arms on the supine motions and retire the hide list
Hollow Body Hold, Leg Raises, and Reverse Crunch hid the far arm
because it was never authored - a leftover from the planar rig. Both
arms are now posed, so the far arm reads as the standard light member
behind the near one, the same visual language every other exercise
uses, and the figures stay truthful from any viewpoint. With no
remaining users, the hide mechanism is deleted from both renderers,
the motion schema, and the docs.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
2026-07-06 22:19:23 -04:00

846 lines
37 KiB
Python

#!/usr/bin/env python3
"""Render Exercise Library visuals from an anatomical 3D rig.
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 (body
profiles, flipped camera, theme); `--orbit` writes orbit.gif per named
exercise (the camera sweeps 360 degrees while the motion loops — props
rotate with the figure). `--export` copies the app's bundled resources
verbatim; `--fixtures` regenerates the projected-geometry snapshots pinning
the in-app Swift solver to this pipeline. 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
# The default viewpoint is slightly elevated: the camera pitches down a
# touch, so the floor reads as a plane (drawn as a rectangle) instead of a
# line. Motions can override via "camera": {"pitch": ...}.
CAMERA_PITCH = 10.0
FLOOR_HALF_DEPTH = 30.0
PALETTES = {
"default": {
"right": "#3a3f4b", "left": "#a9afba",
"right_working": "#0d9488", "left_working": "#86cfc5",
"ground": "#b9bec9", "legend_text": "#6b7180", "head_fill": "white",
"equipment": "#c5cad4", "prop": "#6b7180",
},
"indigo": {
"right": "#3a3f4b", "left": "#a9afba",
"right_working": "#4f46e5", "left_working": "#a5b4fc",
"ground": "#b9bec9", "legend_text": "#6b7180", "head_fill": "white",
"equipment": "#c5cad4", "prop": "#6b7180",
},
}
WIDTHS = {"near": 6, "far": 5, "spine": 6, "head": 6, "nose": 4}
# 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
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.
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 profiles():
return K.load_skeleton()["profiles"]
def dirv(deg):
"""Unit vector for a y-up angle, in y-down canvas coordinates."""
r = math.radians(deg)
return (math.cos(r), -math.sin(r))
def angle_of(a, b):
"""Y-up world angle of the canvas segment a->b."""
return math.degrees(math.atan2(-(b[1] - a[1]), b[0] - a[0]))
# ------------------------------------------------------------------- solver
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 _chain_depth(pts):
return sum(p[2] for p in pts) / len(pts)
def _bucket(depth):
return round(depth / DEPTH_BUCKET)
def frame_geometry(nf, prof, cam, flipped=False, pitch=CAMERA_PITCH, mat=None):
"""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, pitch)
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]]
# Pins are canvas targets in the authored, unpitched view: solve IK flat,
# then tilt the *posed* body - the camera elevation is pure presentation,
# so contacts straddle the floor plane instead of pins going out of reach.
work, _ = K.resolve(work, prof, cam, 0.0)
work["pins"] = dict(nf["pins"]) # keep authored pins; only angles resolved
p = K.pose(work, prof, cam, pitch)
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])
# Shoulder girdle and pelvis, drawn with the spine so the limbs visibly hang
# from real width. Endpoints use each side's drawn attach (far offset included)
# so the bars meet the limbs exactly.
def attach(key, limb):
return scr(pts[key], off if shade[limb] == "far" else (0, 0))
geo["girdle"] = [attach("shoulder_l", "arm_l"), neck_b, attach("shoulder_r", "arm_r")]
geo["pelvisBar"] = [attach("hip_l", "leg_l"), pelvis, attach("hip_r", "leg_r")]
# The exercise mat: a world-space quad on the ground plane, rotating with
# the camera about the figure's vertical axis (`mat` = screen-x bounds of
# the motion's footprint in the authored view).
if mat is not None:
yg = -(GROUND_Y + 4 - anchor[1])
rc = K.mmul(K.rot_x(pitch), K.rot_y(-cam))
geo["floor"] = [scr(K.mvec(rc, (dx, yg, dz))[:2] + (0,))
for dx, dz in ((mat[0] - anchor[0], FLOOR_HALF_DEPTH),
(mat[1] - anchor[0], FLOOR_HALF_DEPTH),
(mat[1] - anchor[0], -FLOOR_HALF_DEPTH),
(mat[0] - anchor[0], -FLOOR_HALF_DEPTH))]
# 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
def mat_bounds(norms, prof, cam, pitch=CAMERA_PITCH):
"""The motion's footprint in the authored view: min/max screen x of every
figure point across the key frames, padded - the exercise mat spans it."""
lo, hi = float("inf"), float("-inf")
for nf in norms:
_, geo, _, _ = frame_geometry(nf, prof, cam, pitch=pitch)
xs = [geo["head"][0] - geo["headR"], geo["head"][0] + geo["headR"]]
for part in ("arm_r", "arm_l", "leg_r", "leg_l", "spine", "girdle", "pelvisBar"):
xs += [pt[0] for pt in geo.get(part, [])]
lo, hi = min(lo, min(xs)), max(hi, max(xs))
return lo - 12, hi + 12
def ease(t):
return 3 * t * t - 2 * t * t * t
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 += [K.lerp_frames(kf, nxt, ease(s / steps)) for s in range(1, steps)]
return frames
# -------------------------------------------------------------------- props
# Equipment layer (see SYSTEM.md): `scene` shapes and `cable`s draw behind the
# figure in the recessive equipment gray; joint-attached items (`bar`,
# `dumbbell`, `pad`) draw over the limbs in the darker prop gray, following
# the resolved hand/foot positions frame by frame.
#
# Props have world-space 3D form: everything is authored (and resolved) in the
# authored view — scene points and cable anchors carry an optional depth `z`
# (+ toward the camera), a scene line an optional `depth` extrusion half-width
# that makes it a slab — then rotated about the world-vertical axis through
# the root anchor for the orbiting presentation, exactly like the figure and
# the mat. Positions that follow the figure (joint anchors) come from the
# *rotated* geometry; fixed points, directions, and offsets (scene shapes,
# cable anchors, bar angles, pad perpendiculars, roller offsets) resolve in
# the *authored* view and rotate — never recompute them in the rotated view.
def flip_props(props, width):
"""Mirror the props horizontally, matching the flipped camera (which views
from the opposite side, so authored depths flip sign too). Joint-attached
props follow the mirrored limbs automatically; only fixed coordinates,
depths, and world angles need mirroring."""
def fx(p):
return [width - p[0], p[1], -p[2]] if len(p) > 2 else [width - p[0], p[1]]
out = []
for prop in props:
p = json.loads(json.dumps(prop))
if p["type"] == "scene":
for s in p["shapes"]:
if "z" in s:
s["z"] = -s["z"]
if s["kind"] == "line":
s["pts"] = [fx(pt) for pt in s["pts"]]
elif s["kind"] == "circle":
s["c"] = fx(s["c"])
elif p["type"] == "cable":
p["from"] = fx(p["from"])
elif p["type"] in ("bar", "pad") and "angle" in p:
p["angle"] = 180 - p["angle"]
elif p["type"] == "roller":
p["side"] = -p.get("side", 1) # mirroring flips the perpendicular's handedness
out.append(p)
return out
def prop_rotation(pitch, yaw_offset):
"""View-space rotation carrying authored-view prop geometry to the camera
`yaw_offset` degrees past the authored yaw: a rotation about the world-
vertical axis through the root anchor, conjugated by the camera elevation.
The identity at offset 0, so the authored view stays bit-exact."""
if yaw_offset == 0:
return K.IDENTITY
return K.chain(K.rot_x(pitch), K.rot_y(-yaw_offset), K.rot_x(-pitch))
def joint_points(geo, ref):
"""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_name, idx = JOINT_LIMB[name]
limb = geo.get(limb_name)
if not limb:
return None, None
pts.append(limb[idx])
if direction is None:
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),
sum(p[1] for p in pts) / len(pts)), direction)
def resolve_props(props, geo, anchor, rot=K.IDENTITY, auth_geo=None):
"""Props -> drawable primitives for one frame: (background, foreground).
`geo` is the frame's drawn (possibly orbit-rotated) geometry, `auth_geo`
the same frame at the authored camera (defaults to `geo`), `anchor` the
frame's root canvas anchor, and `rot` the `prop_rotation` between them.
Joint positions come from `geo`; everything authored — scene points,
cable anchors, bar angles, pad perpendiculars, roller offsets — resolves
against `auth_geo` and rotates through `rot`.
"""
auth = auth_geo if auth_geo is not None else geo
def place(pt, z=0.0):
"""Authored canvas point (+ depth toward the camera) -> drawn canvas."""
v = K.mvec(rot, (pt[0] - anchor[0], anchor[1] - pt[1], z))
return (anchor[0] + v[0], anchor[1] - v[1])
def swing(vec, z=0.0):
"""Authored canvas-space direction/offset -> drawn canvas (y-down)."""
v = K.mvec(rot, (vec[0], -vec[1], z))
return (v[0], -v[1])
bg, fg = [], []
for p in props or []:
t = p["type"]
if t == "scene":
for s in p["shapes"]:
color = s.get("color", "equipment")
z = s.get("z", 0.0)
if s["kind"] == "line":
depth = s.get("depth", 0.0)
pts = [(pt[0], pt[1], z + (pt[2] if len(pt) > 2 else 0.0))
for pt in s["pts"]]
if depth:
# An extruded slab: the polyline swept through +/-depth,
# filled and outlined so it degenerates to the plain
# line whenever the sweep collapses edge-on.
quad = ([place(pt, pt[2] + depth) for pt in pts]
+ [place(pt, pt[2] - depth) for pt in reversed(pts)])
bg.append({"kind": "poly", "pts": [list(q) for q in quad],
"w": s.get("w", 4), "color": color})
else:
bg.append({"kind": "line",
"pts": [list(place(pt, pt[2])) for pt in pts],
"w": s.get("w", 4), "color": color})
elif s["kind"] == "circle":
bg.append({"kind": "circle", "c": list(place(s["c"], z)),
"r": s["r"], "fill": s.get("fill", False),
"w": s.get("w", 3), "color": color})
elif t == "cable":
end, _ = joint_points(geo, p["to"])
if end:
start = place(p["from"], p["from"][2] if len(p["from"]) > 2 else 0.0)
bg.append({"kind": "line", "pts": [list(start), list(end)],
"w": p.get("w", 2), "color": "equipment"})
elif t == "roller":
# A machine roller pad seen end-on: a disc riding the limb's lower
# bone near the joint, on the `side` (+1/-1) of the bone it presses.
# The offset resolves along the authored-view bone, then rotates.
c, _ = joint_points(geo, p["at"])
_, d = joint_points(auth, p["at"])
if not c or not d:
continue
r = p.get("r", 5)
back = p.get("back", 0)
side = p.get("side", 1)
px, py = d[1] * side, -d[0] * side
off = swing((-d[0] * back + px * (r + 3), -d[1] * back + py * (r + 3)))
fg.append({"kind": "circle", "c": [c[0] + off[0], c[1] + off[1]], "r": r,
"fill": True, "color": "prop"})
elif t in ("bar", "dumbbell", "pad"):
c, _ = joint_points(geo, p["at"])
_, d = joint_points(auth, p["at"])
if not c or not d:
continue
if t == "bar" or "angle" in p:
axis = dirv(p.get("angle", 0)) # fixed authored-view angle
else:
axis = (-d[1], d[0]) # perpendicular to the lower bone
ux, uy = swing(axis) # foreshortens under orbit
h = p.get("halfLen", {"bar": 24, "dumbbell": 7, "pad": 8}[t])
a = (c[0] - ux * h, c[1] - uy * h)
b = (c[0] + ux * h, c[1] + uy * h)
fg.append({"kind": "line", "pts": [list(a), list(b)],
"w": p.get("w", {"bar": 4, "dumbbell": 3, "pad": 7}[t]),
"color": "prop"})
plate = p.get("plateR", 4.5 if t == "dumbbell" else 0)
if plate:
for e in (a, b):
fg.append({"kind": "circle", "c": list(e), "r": plate,
"fill": True, "color": "prop"})
return bg, fg
def svg_prims(prims, colors):
lines = []
for p in prims:
color = colors[p["color"]]
if p["kind"] == "line":
d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in p["pts"])
lines.append(f' <path d="{d}" stroke="{color}" stroke-width="{p.get("w", 4)}"'
f' stroke-linecap="round" stroke-linejoin="round"/>')
elif p["kind"] == "poly":
d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in p["pts"]) + " Z"
lines.append(f' <path d="{d}" fill="{color}" stroke="{color}"'
f' stroke-width="{p.get("w", 4)}"'
f' stroke-linecap="round" stroke-linejoin="round"/>')
elif p["kind"] == "circle":
cx, cy = p["c"]
if p.get("fill"):
lines.append(f' <circle cx="{cx:.1f}" cy="{cy:.1f}" r="{p["r"]}" fill="{color}"/>')
else:
lines.append(f' <circle cx="{cx:.1f}" cy="{cy:.1f}" r="{p["r"]}"'
f' stroke="{color}" stroke-width="{p.get("w", 3)}"/>')
return lines
def draw_prims(d, prims, colors, scale):
for p in prims:
color = colors[p["color"]]
if p["kind"] in ("line", "poly"):
pts = [(x * scale, y * scale) for x, y in p["pts"]]
if p["kind"] == "poly":
d.polygon(pts, fill=color)
pts = pts + pts[:1]
w = p.get("w", 4) * scale
d.line(pts, fill=color, width=w, joint="curve")
for x, y in (pts[0], pts[-1]):
d.ellipse([x - w / 2, y - w / 2, x + w / 2, y + w / 2], fill=color)
elif p["kind"] == "circle":
cx, cy = p["c"][0] * scale, p["c"][1] * scale
r = p["r"] * scale
if p.get("fill"):
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color)
else:
d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=color,
width=p.get("w", 3) * scale)
# ------------------------------------------------------------------- drawing
def floor_svg(geo, colors):
"""The exercise mat: a world-space quad on the ground plane, sized to the
motion's footprint and rotating with the camera."""
quad = geo.get("floor")
if not quad:
return ""
d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in quad) + " Z"
return (f' <path d="{d}" fill="none" stroke="{colors["ground"]}"'
f' stroke-width="3" stroke-linejoin="round"/>')
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
def quad_points(p0, ctrl, p2, n=24):
pts = []
for i in range(n + 1):
t = i / n
pts.append(((1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * ctrl[0] + t ** 2 * p2[0],
(1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * ctrl[1] + t ** 2 * p2[1]))
return pts
def svg_for_frame(name, geo, order, shade, working, colors, prims=((), ())):
bg, fg = prims
w, h = CANVAS
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" fill="none">',
f' <title>{name}</title>',
floor_svg(geo, colors)]
parts += svg_prims(bg, colors)
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) = 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, shade)
if part == "spine":
for bar in ("girdle", "pelvisBar"):
bd = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in geo[bar])
parts.append(f' <path id="{bar}" d="{bd}" stroke="{color}" stroke-width="5"'
f' stroke-linecap="round" stroke-linejoin="round"/>')
(ax, ay), (cx, cy), (bx, by) = geo["spine"]
d = f"M {ax:.1f} {ay:.1f} Q {cx:.1f} {cy:.1f} {bx:.1f} {by:.1f}"
else:
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 - 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 + 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, order, shade, working, colors, scale=2, font=None, prims=((), ())):
from PIL import Image, ImageDraw
bg, fg = prims
w, h = CANVAS[0] * scale, CANVAS[1] * scale
img = Image.new("RGB", (w, h), "white")
d = ImageDraw.Draw(img)
def line(pts, color, width):
pts = [(x * scale, y * scale) for x, y in pts]
d.line(pts, fill=color, width=width * scale, joint="curve")
r = width * scale / 2
for x, y in (pts[0], pts[-1]):
d.ellipse([x - r, y - r, x + r, y + r], fill=color)
if geo.get("floor"):
line(geo["floor"] + geo["floor"][:1], colors["ground"], 3)
draw_prims(d, bg, colors, scale)
for part in order:
if part == "head":
draw_prims(d, fg, colors, scale)
hx, hy = geo["head"]
r, sw = geo["headR"] * scale, WIDTHS["head"] * scale
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(geo["nose"]), colors["right"], WIDTHS["nose"])
continue
if part not in geo:
continue
color, width = part_style(part, working, colors, shade)
if part == "spine":
line(geo["girdle"], color, 5)
line(geo["pelvisBar"], color, 5)
pts = quad_points(*geo["spine"]) if part == "spine" else geo[part]
line(pts, color, width)
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 + 19 * scale, 16 * scale - 11 * scale / 2 - 2), "near",
fill=colors["legend_text"], font=font)
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)
def legend_font(scale=2):
from PIL import ImageFont
try:
return ImageFont.load_default(size=11 * scale)
except TypeError:
return ImageFont.load_default()
# --------------------------------------------------------------------- main
def load_motion(folder):
return json.loads((folder / "motion.json").read_text())
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)
pitch = float(motion.get("camera", {}).get("pitch", CAMERA_PITCH))
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])
return norms, prof, cam, pitch, props
def render_exercise(folder, figure="neutral", flip=False, strict=False):
motion = load_motion(folder)
working = set(motion.get("working", []))
norms, prof, cam, pitch, props = prepare(motion, figure, flip, strict)
mat = mat_bounds(norms, prof, cam, pitch)
def geometry(nf):
_, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch, mat)
return geo, order, shade
resolved = []
key_cells = []
for nf in norms:
out, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch, mat)
resolved.append(out)
key_cells.append((geo, order, shade, resolve_props(props, geo, nf["root"]["pos"])))
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"], geo, order, shade, working, colors, prims)
for geo, order, shade, prims in key_cells]
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 = []
for nf in timeline(resolved):
geo, order, shade = geometry(nf)
prims = resolve_props(props, geo, nf["root"]["pos"])
imgs.append(draw_geo(geo, order, shade, working, colors, font=font, prims=prims))
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")
except ImportError:
print(f" {motion['name']}: Pillow missing — SVGs written, preview.gif skipped")
def render_orbit(folder, figure="neutral"):
"""An orbiting demo of what the app shows: the camera sweeps 360 degrees
while the motion loops (or swings a `camera.sweep` pendulum when the
motion authors one). Props rotate with the figure about the root anchor."""
motion = load_motion(folder)
working = set(motion.get("working", []))
norms, prof, cam, pitch, props = prepare(motion, figure)
resolved = [frame_geometry(nf, prof, cam, pitch=pitch)[0] for nf in norms]
mat = mat_bounds(norms, prof, cam, pitch)
sweep = motion.get("camera", {}).get("sweep")
font = legend_font()
colors = PALETTES["default"]
ticks = timeline(resolved)
imgs = []
for i, nf in enumerate(ticks):
off = (sweep * math.sin(2 * math.pi * i / len(ticks)) if sweep
else 360.0 * i / len(ticks))
# Pins are canvas targets in the AUTHORED view: resolve the pose there,
# then rotate the posed body - never re-pin in the rotated view. The
# authored-view geometry also feeds the props' directions and offsets.
posed, auth_geo, _, _ = frame_geometry(nf, prof, cam, pitch=pitch)
posed["pins"] = {}
_, geo, order, shade = frame_geometry(posed, prof, cam + off, pitch=pitch, mat=mat)
prims = resolve_props(props, geo, nf["root"]["pos"],
prop_rotation(pitch, off), auth_geo)
imgs.append(draw_geo(geo, order, shade, working, colors, font=font, prims=prims))
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):
font = legend_font()
cells = []
for folder in folders:
motion = load_motion(folder)
working = set(motion.get("working", []))
norms, prof, cam, pitch, props = prepare(motion, figure)
mat = mat_bounds(norms, prof, cam, pitch)
for i, nf in enumerate(norms, start=1):
_, geo, order, shade = frame_geometry(nf, prof, cam, pitch=pitch, mat=mat)
prims = resolve_props(props, geo, nf["root"]["pos"])
cells.append((f"{motion['name']} {i}/{len(norms)}",
draw_geo(geo, order, shade, working, PALETTES["default"],
font=font, prims=prims)))
save_sheet(cells, Path(out) if out else LIB / "contact-sheet.png")
def demo_sheet(folder):
"""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 = set(motion.get("working", []))
idx = motion.get("primary", 1) - 1
font = legend_font()
variants = [("neutral", "neutral", False, "default"),
("female profile", "female", False, "default"),
("male profile", "male", False, "default"),
("flipped camera", "neutral", True, "default"),
("themed (indigo)", "neutral", False, "indigo")]
cells = []
for label, figure, flip, palette in variants:
norms, prof, cam, pitch, props = prepare(motion, figure, flip)
mat = mat_bounds(norms, prof, cam, pitch)
_, geo, order, shade = frame_geometry(norms[idx], prof, cam, flip, pitch, mat)
prims = resolve_props(props, geo, norms[idx]["root"]["pos"])
cells.append((f"{motion['name']}{label}",
draw_geo(geo, order, shade, working, PALETTES[palette],
font=font, prims=prims)))
save_sheet(cells, LIB / "demo-sheet.png", cols=3)
def save_sheet(cells, path, cols=4):
from PIL import Image, ImageDraw
rows = (len(cells) + cols - 1) // cols
cw, ch, cap = CANVAS[0], CANVAS[1], 22
sheet = Image.new("RGB", (cols * (cw + 8) + 8, rows * (ch + cap + 8) + 8), "white")
d = ImageDraw.Draw(sheet)
for i, (label, img) in enumerate(cells):
x, y = 8 + (i % cols) * (cw + 8), 8 + (i // cols) * (ch + cap + 8)
sheet.paste(img, (x, y))
d.text((x + 6, y + ch + 3), label, fill=PALETTES["default"]["right"])
sheet.save(path)
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 write_fixtures(folders):
"""Regenerate `WorkoutsTests/Fixtures/figure-fixtures.json` — the projected
geometry snapshots that pin the Swift solver to this pipeline: per exercise
and key frame the draw order, shading, spine, head, nose, and limb chains,
plus a mid-tween sample and (for a spread of prop flavors) orbit samples
taken through the presentation path — resolve at the authored camera, clear
the pins, rotate the posed body — with the resolved prop primitives."""
ORBIT_SAMPLED = {"Bird Dog", "Lat Pull Down", "Leg Extension", "Abductor"}
OFFSETS = (37.0, 180.0)
T = 0.5
def sample(geo, order, shade, prims=None):
out = {"order": order, "shade": shade,
"spine": [list(p) for p in geo["spine"]],
"head": list(geo["head"])}
if "nose" in geo:
out["nose"] = [list(geo["nose"][0]), list(geo["nose"][1])]
for limb in K.LIMBS:
out[limb] = [list(p) for p in geo[limb]]
if prims is not None:
out["props"] = {"bg": prims[0], "fg": prims[1]}
return out
exercises = []
for folder in folders:
motion = load_motion(folder)
norms, prof, cam, pitch, props = prepare(motion, "neutral")
resolved, frames = [], []
for nf in norms:
out, geo, order, shade = frame_geometry(nf, prof, cam)
resolved.append(out)
frames.append(sample(geo, order, shade))
mid = K.lerp_frames(resolved[0], resolved[1], ease(T))
_, geo, order, shade = frame_geometry(mid, prof, cam)
entry = {"name": motion["name"], "camera": cam, "frames": frames,
"tween": {"t": T, "sample": sample(geo, order, shade)}}
if motion["name"] in ORBIT_SAMPLED:
anchor = norms[0]["root"]["pos"]
posed, auth_geo, _, _ = frame_geometry(norms[0], prof, cam)
posed["pins"] = {}
orbit = []
for off in OFFSETS:
_, geo, order, shade = frame_geometry(posed, prof, cam + off)
prims = resolve_props(props, geo, anchor,
prop_rotation(CAMERA_PITCH, off), auth_geo)
orbit.append({"yaw": cam + off,
"sample": sample(geo, order, shade, prims)})
entry["orbit"] = orbit
exercises.append(entry)
out_path = LIB.parent / "WorkoutsTests" / "Fixtures" / "figure-fixtures.json"
out_path.write_text(json.dumps(
{"profile": "neutral", "pitch": CAMERA_PITCH, "exercises": exercises}))
print(f" {out_path.name} ({len(exercises)} exercises)")
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("--")]
figure = "neutral"
sheet = None # False = off, None+flag = default path, str = custom path
for f in flags:
if f.startswith("--figure="):
figure = f.split("=", 1)[1]
elif f.startswith("--sheet"):
sheet = f.split("=", 1)[1] if "=" in f else True
folders = ([LIB / n for n in names] if names
else sorted(p.parent for p in LIB.glob("*/motion.json")))
if "--export" in flags:
export_app_resources(folders)
return
if "--fixtures" in flags:
write_fixtures(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,
strict="--strict" in flags)
if sheet:
contact_sheet(folders, figure=figure, out=None if sheet is True else sheet)
if "--demo" in flags:
demo_sheet(folders[0] if names else LIB / "Bird Dog")
if __name__ == "__main__":
main()