#!/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 `.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", }, "indigo": { "right": "#3a3f4b", "left": "#a9afba", "right_working": "#4f46e5", "left_working": "#a5b4fc", "ground": "#b9bec9", "legend_text": "#6b7180", "head_fill": "white", }, } 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"] 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] nose_tip = walk(head, [frame["gaze"]], [body["headR"] + 7])[1] geo = {"head": head, "nose": nose_tip, "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"]]} 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 # ------------------------------------------------------------------- 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): w, h = CANVAS parts = [f'', f' {name}', f' '] for part in PART_ORDER: if part == "head": hx, hy = geo["head"] (sx, sy), (ex, ey) = nose_segment(geo) parts.append(f' ') parts.append(f' ') 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' ') lx = w - 78 parts.append(f' ') parts.append(f' ') parts.append(f' R') parts.append(f' ') parts.append(f' L') parts.append(' ') parts.append('') return "\n".join(parts) + "\n" def draw_geo(geo, working, colors, scale=2, font=None): from PIL import Image, ImageDraw 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) for part in PART_ORDER: if part == "head": 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) 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 `.motion.json` per library entry. Unique basenames — Xcode copies resources flat, so the per-entry motion.json 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") 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", [])) 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) 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) 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"): 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", [])) 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))) save_sheet(cells, 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] geo = geometry(normalize(kf, body, flip), body, hide, flip) cells.append((f"{motion['name']} — {label}", draw_geo(geo, working, PALETTES[palette], font=font))) 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" for f in flags: if f.startswith("--figure="): figure = f.split("=", 1)[1] 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" in flags: contact_sheet(folders, figure=figure) if "--demo" in flags: demo_sheet(folders[0] if names else LIB / "Bird Dog") if __name__ == "__main__": main()