Files
workouts/Exercise Library/render.py
T
rzen 669ecf1259 Surface machine settings in the exercise library and refine machine rigs
Library detail screens now lead with the user's recorded machine settings
(per-split when they disagree, empty-state card for machine-based entries)
and append the weight progression chart. Starter seeds mark machine
exercises with an empty machineSettings list so the settings UI lights up
before first use. The figure rig gains a frontal body profile for face-on
machines, props that can ride mid joints (knees/elbows), and an
alternating four-frame Bird Dog loop.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
2026-07-06 18:35:15 -04:00

610 lines
25 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"]
# 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 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"`, `"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):
"""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)
# 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", []))
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()
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)))
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()