Files
workouts/Exercise Library/render.py
T
rzen fce8fa4c17 Author the machine exercise circuit with props and corrected hip machine motions
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
2026-07-06 16:29:31 -04:00

598 lines
24 KiB
Python

#!/usr/bin/env python3
"""Render Exercise Library visuals from an articulated 2D 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.
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.
"""
import json
import math
import sys
from pathlib import Path
LIB = Path(__file__).parent
CANVAS = (320, 180)
GROUND_Y = 152
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 = {"right": 6, "left": 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"]
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"]
JOINT_LIMB = {"hand_r": "arm_r", "hand_l": "arm_l", "foot_r": "leg_r", "foot_l": "leg_l"}
def bodies():
return json.loads((LIB / "body.json").read_text())
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 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 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()}
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 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 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
# ------------------------------------------------------------------ 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)]
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.
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."""
def fx(p):
return [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 s["kind"] == "line":
s["pts"] = [fx(pt) for pt in s["pts"]]
elif s["kind"] == "circle":
s["c"] = fx(s["c"])
elif s["kind"] == "rect":
s["x"] = width - s["x"] - s["w"]
elif p["type"] == "cable":
p["from"] = fx(p["from"])
elif p["type"] in ("bar", "pad") and "angle" in p:
p["angle"] = 180 - p["angle"]
out.append(p)
return out
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."""
names = ref if isinstance(ref, list) else [ref]
pts, direction = [], None
for name in names:
limb = geo.get(JOINT_LIMB[name])
if not limb:
return None, None
pts.append(limb[-1])
if direction is None:
a, b = limb[-2], limb[-1]
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):
"""Props -> drawable primitives for one frame: (background, foreground)."""
bg, fg = [], []
for p in props or []:
t = p["type"]
if t == "scene":
for s in p["shapes"]:
bg.append(dict(s, color=s.get("color", "equipment")))
elif t == "cable":
end, _ = joint_points(geo, p["to"])
if end:
bg.append({"kind": "line", "pts": [list(p["from"]), list(end)],
"w": p.get("w", 2), "color": "equipment"})
elif t in ("bar", "dumbbell", "pad"):
c, d = joint_points(geo, p["at"])
if not c:
continue
if t == "bar" or "angle" in p:
ux, uy = dirv(p.get("angle", 0)) # fixed world angle
else:
ux, uy = -d[1], d[0] # perpendicular to the lower bone
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": [a, 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"] == "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)}"/>')
elif p["kind"] == "rect":
lines.append(f' <rect x="{p["x"]}" y="{p["y"]}" width="{p["w"]}" height="{p["h"]}"'
f' rx="{p.get("r", 2)}" fill="{colors[p.get("color", "equipment")]}"/>')
return lines
def draw_prims(d, prims, colors, scale):
for p in prims:
color = colors[p["color"]]
if p["kind"] == "line":
pts = [(x * scale, y * scale) for x, y in p["pts"]]
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)
elif p["kind"] == "rect":
x, y = p["x"] * scale, p["y"] * scale
d.rounded_rectangle([x, y, x + p["w"] * scale, y + p["h"] * scale],
radius=p.get("r", 2) * scale, fill=color)
# ------------------------------------------------------------------- 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]
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 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):
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">',
f' <title>{name}</title>',
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:
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)
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)
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}"
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 - 78
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(' </g>')
parts.append('</svg>')
return "\n".join(parts) + "\n"
def draw_geo(geo, working, colors, scale=2, font=None, props=None):
from PIL import Image, ImageDraw
bg, fg = resolve_props(props, geo)
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)
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:
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(nose_segment(geo)), colors["right"], WIDTHS["nose"])
continue
if part not in geo:
continue
color, width = part_style(part, working, colors)
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)
if font:
d.text((lx * scale + 22 * scale, 16 * scale - 11 * scale / 2 - 2), "R",
fill=colors["legend_text"], font=font)
d.text((lx * scale + 62 * scale, 16 * scale - 11 * scale / 2 - 2), "L",
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 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)
body = bodies()[figure]
working = set(motion.get("working", []))
hide = set(motion.get("hide", []))
props = motion.get("props", [])
if flip:
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]
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]
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[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 contact_sheet(folders, figure="neutral", out=None):
from PIL import Image, ImageDraw
font = legend_font()
body = bodies()[figure]
cells = []
for folder in folders:
motion = load_motion(folder)
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)))
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."""
motion = load_motion(folder)
working, hide = set(motion.get("working", [])), set(motion.get("hide", []))
kf = motion["frames"][motion.get("primary", 1) - 1]
font = legend_font()
variants = [("neutral", "neutral", False, "default"),
("female profile", "female", False, "default"),
("flipped", "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)
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)
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)")
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
for folder in folders:
render_exercise(folder, figure=figure, flip="--flip" 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()