#!/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). `--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 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): """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 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. def flip_props(props, width): """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 = [] 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' ') elif p["kind"] == "circle": cx, cy = p["c"] if p.get("fill"): lines.append(f' ') else: lines.append(f' ') elif p["kind"] == "rect": lines.append(f' ') 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, 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, props=None): bg, fg = resolve_props(props, geo) w, h = CANVAS parts = [f'', f' {name}', f' '] parts += svg_prims(bg, colors) for part in order: if part == "head": parts += svg_prims(fg, colors) hx, hy = geo["head"] parts.append(f' ') if "nose" in geo: (sx, sy), (ex, ey) = geo["nose"] parts.append(f' ') continue if part not in geo: continue 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}" else: d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in geo[part]) parts.append(f' ') lx = w - 96 parts.append(f' ') parts.append(f' ') parts.append(f' near') parts.append(f' ') parts.append(f' far') parts.append(' ') parts.append('') return "\n".join(parts) + "\n" 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) 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 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) 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) 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, 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"], 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 = [] 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") except ImportError: 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): font = legend_font() cells = [] for folder in folders: motion = load_motion(folder) working, hide = set(motion.get("working", [])), set(motion.get("hide", [])) 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 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", [])) 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, 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, 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): 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 `.motion.json` and one `.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("--")] 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 "--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()