#!/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. `--check` is compute-only: it simulates the full tween loop per exercise and fails hard on solver inconsistencies (pin drift, teleports, angle wraps, authored-vs-resolved pose contradictions, sunk geometry, resolved ROM) — `--export` always runs it first and refuses to ship a failing exercise. 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} # Depth shading is a smooth per-vertex gradient, not a near/far switch: every # drawn joint is blended between the near and far colors by its own camera depth # relative to its limb pair's central plane, so the ink flows along a bone that # reaches in depth and fades to a shared mid-tone where the two members cross # (face-on, or mid-orbit) instead of snapping. A joint reaches full contrast # once it sits this fraction of the shoulder/pelvis half-width in front of (or # behind) that plane, so a straight limb in profile is still fully dark/light. SHADE_SPAN_FRAC = 0.6 # 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 = {} 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]) # Per-vertex depth shading: each drawn joint is toned by its OWN camera depth, # not the whole limb's average, so the ink flows smoothly along a limb that # reaches toward or away from the camera instead of the bone snapping to one # flat tone. Each pair is measured against its central depth plane and # normalized by the profile half-width (x SHADE_SPAN_FRAC), chosen so a # straight limb in a profile view still reaches full near-dark / far-light # (the per-vertex average reproduces the old flat per-limb tone) while the # two members still fade to a shared mid-tone as they cross. The binary # `shade` above stays untouched - it alone drives the far offset and order. nearness = {} for right, left in PAIRS: zr = [v[2] for v in pts[right]] zl = [v[2] for v in pts[left]] ref = (sum(zr) / len(zr) + sum(zl) / len(zl)) / 2 half = prof.get("shoulderHalf" if "arm" in right else "hipHalf", 10) * p["k"] span = half * SHADE_SPAN_FRAC for limb, zs in ((right, zr), (left, zl)): nearness[limb] = [(0.5 + 0.5 * max(-1.0, min(1.0, (z - ref) / span))) if span else 0.5 for z in zs] # 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))) geo["nearness"] = nearness 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 ZOOM_ANCHOR = (CANVAS[0] / 2.0, float(GROUND_Y)) def apply_zoom(geo, prims, zoom): """Presentation-only camera zoom about the ground-center anchor: scales the drawn figure, equipment, and stroke widths so standing-height motions fit the canvas. Applied after all solving — authored coordinates, pins, props, and the fixture geometry stay in full-size canvas units (the in-app renderer applies the same transform at draw time).""" if zoom == 1.0: return geo, prims ax, ay = ZOOM_ANCHOR def pt(p): return (ax + (p[0] - ax) * zoom, ay + (p[1] - ay) * zoom) g = dict(geo) g["zoom"] = geo.get("zoom", 1.0) * zoom g["headR"] = geo["headR"] * zoom g["head"] = pt(geo["head"]) for key in ("spine", "girdle", "pelvisBar", "floor") + tuple(K.LIMBS): if key in g: g[key] = [pt(p) for p in geo[key]] if "nose" in geo: g["nose"] = (pt(geo["nose"][0]), pt(geo["nose"][1])) def zp(prim): q = dict(prim) if "pts" in q: q["pts"] = [list(pt(p)) for p in q["pts"]] if "c" in q: q["c"] = list(pt(q["c"])) if "r" in q: q["r"] = q["r"] * zoom if "w" in q: q["w"] = q["w"] * zoom return q bg, fg = prims return g, ([zp(p) for p in bg], [zp(p) for p in fg]) 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, pitch=CAMERA_PITCH): """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`. `pitch` is the camera elevation, needed by `axis` props whose world-space direction projects through it (like the floor quad). """ 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 p.get("axis") == "z": # A cross-body rod (barbell, pull-up bar): its true 3D axis is # the world left-right axis, projected through the camera # elevation like the floor quad — a small vertical sliver # end-on in a profile view (the plates read nearly concentric, # seen from slightly above), full span face-on, swinging with # the figure in between, symmetric at 0 and 180. pr = math.radians(pitch) ux, uy = swing((0.0, math.sin(pr)), math.cos(pr)) elif t == "bar" or "angle" in p: axis = dirv(p.get("angle", 0)) # fixed authored-view angle ux, uy = swing(axis) # foreshortens under orbit else: axis = (-d[1], d[0]) # perpendicular to the lower bone ux, uy = swing(axis) 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' ') elif p["kind"] == "poly": d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in p["pts"]) + " Z" lines.append(f' ') elif p["kind"] == "circle": cx, cy = p["c"] if p.get("fill"): lines.append(f' ') else: lines.append(f' ') 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 = round(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=round(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' ') def _lerp_hex(a, b, t): """Blend two "#rrggbb" colors; t=0 -> a, t=1 -> b.""" a, b = a.lstrip("#"), b.lstrip("#") ar, ag, ab = int(a[0:2], 16), int(a[2:4], 16), int(a[4:6], 16) br, bg, bb = int(b[0:2], 16), int(b[2:4], 16), int(b[4:6], 16) return "#%02x%02x%02x" % (round(ar + (br - ar) * t), round(ag + (bg - ag) * t), round(ab + (bb - ab) * t)) def shade_ink(part, working, colors, t): """The ink at nearness `t` (1 = near/dark, 0 = far/light): the plain near/far grays, or the accent teals when `part` is a working part.""" far, near = ("left_working", "right_working") if part in working else ("left", "right") return _lerp_hex(colors[far], colors[near], t) def shade_width(t): """Stroke width tracking nearness (far 5 -> near 6).""" return WIDTHS["far"] + (WIDTHS["near"] - WIDTHS["far"]) * t def part_style(part, working, colors, nearness): """Single-tone style for the spine and its bars — the near ink (or working teal), at the spine width. (Limbs shade per vertex; see `svg_for_frame`.)""" t = nearness.get(part, 1.0) if isinstance(t, list): t = sum(t) / len(t) if t else 1.0 return shade_ink(part, working, colors, t), WIDTHS["spine"] 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 zf = geo.get("zoom", 1.0) defs, parts = [], [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' ') if "nose" in geo: (sx, sy), (ex, ey) = geo["nose"] parts.append(f' ') continue if part not in geo: continue if part == "spine": color, _ = part_style(part, working, colors, geo["nearness"]) for bar in ("girdle", "pelvisBar"): bd = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in geo[bar]) parts.append(f' ') (ax, ay), (cx, cy), (bx, by) = geo["spine"] parts.append(f' ') continue # A limb shades per vertex: each bone segment is its own round-capped path, # stroked with a linear gradient between its two joints' depth tones so the # ink flows smoothly along the limb. Adjacent segments share a joint tone, # so the round caps meet seamlessly. chain = geo[part] tones = geo["nearness"].get(part) or [1.0] * len(chain) parts.append(f' ') for i in range(len(chain) - 1): (x0, y0), (x1, y1) = chain[i], chain[i + 1] c0 = shade_ink(part, working, colors, tones[i]) c1 = shade_ink(part, working, colors, tones[i + 1]) sw = shade_width((tones[i] + tones[i + 1]) / 2) * zf if c0 == c1 or (abs(x1 - x0) < 0.01 and abs(y1 - y0) < 0.01): stroke = c0 else: gid = f"grad-{part}-{i}" defs.append(f' ' f'' f'') stroke = f"url(#{gid})" parts.append(f' ') parts.append(' ') lx = w - 96 parts.append(f' ') parts.append(f' ') parts.append(f' near') parts.append(f' ') parts.append(f' far') parts.append(' ') parts.append('') header = [f'', f' {name}'] if defs: header += [' '] + defs + [' '] return "\n".join(header + 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 zf = geo.get("zoom", 1.0) 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=round(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) def gradient_line(pts, tones, part): """Stroke a limb chain with a per-vertex depth gradient: subdivide each bone into short steps whose ink and width interpolate between the two joints' tones, capping every sample so the stroke stays continuous.""" steps = 12 prev = None for i in range(len(pts) - 1): (x0, y0), (x1, y1) = pts[i], pts[i + 1] for s in range(steps + 1): u = s / steps x, y = (x0 + (x1 - x0) * u) * scale, (y0 + (y1 - y0) * u) * scale t = tones[i] + (tones[i + 1] - tones[i]) * u col = shade_ink(part, working, colors, t) r = shade_width(t) * zf * scale / 2 if prev is not None: d.line([prev, (x, y)], fill=col, width=round(2 * r)) d.ellipse([x - r, y - r, x + r, y + r], fill=col) prev = (x, y) if geo.get("floor"): line(geo["floor"] + geo["floor"][:1], colors["ground"], 3 * zf) 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, round(WIDTHS["head"] * zf * 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"] * zf) continue if part not in geo: continue if part == "spine": color, _ = part_style(part, working, colors, geo["nearness"]) line(geo["girdle"], color, 5 * zf) line(geo["pelvisBar"], color, 5 * zf) line(quad_points(*geo["spine"]), color, WIDTHS["spine"] * zf) continue tones = geo["nearness"].get(part) or [1.0] * len(geo[part]) gradient_line(geo[part], tones, part) 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, pitch, props, zoom), 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)) zoom = float(motion.get("camera", {}).get("zoom", 1.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, pitch, props, zoom def render_exercise(folder, figure="neutral", flip=False, strict=False): motion = load_motion(folder) working = set(motion.get("working", [])) norms, prof, cam, pitch, props, zoom = 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) prims = resolve_props(props, geo, nf["root"]["pos"], pitch=pitch) geo, prims = apply_zoom(geo, prims, zoom) key_cells.append((geo, order, shade, prims)) 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"], pitch=pitch) geo, prims = apply_zoom(geo, prims, zoom) 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, zoom = 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, pitch=pitch) geo, prims = apply_zoom(geo, prims, zoom) 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, zoom = 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"], pitch=pitch) geo, prims = apply_zoom(geo, prims, zoom) 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, zoom = 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"], pitch=pitch) geo, prims = apply_zoom(geo, prims, zoom) 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 `.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 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, _zoom = 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 _dof_values(frame, joint_map): return {f"{name}.{dof}": frame[name][dof] for name, jt in joint_map.items() for dof in K.JOINT_DOFS[jt]} def _resolved_rom_issues(nf, joints, slack): """`K.validate_rom`'s DoF walk, re-implemented here with a slack margin (kinematics.py itself stays untouched). Returns (name, dof, angle, lo, hi, over) tuples, `over` the degrees past the slack-widened range.""" issues = [] def check(joint_type, name, value): for dof, angle in value.items(): lo_hi = joints.get(joint_type, {}).get(dof) if not lo_hi: continue lo, hi = lo_hi if angle < lo - slack: issues.append((name, dof, angle, lo, hi, lo - slack - angle)) elif angle > hi + slack: issues.append((name, dof, angle, lo, hi, angle - hi - slack)) for i, seg in enumerate(nf["spine"], start=1): check("spine", f"spine{i}", seg) for key, jt in K.FRAME_JOINTS.items(): check(jt, key, nf[key]) return issues # ---------------------------------------------------------------------- check # `--check` fail-fast validation: simulate the exact per-tick pipeline # render_exercise drives (prepare -> frame_geometry per key frame -> timeline # -> frame_geometry per tick) at pitch 0 with no mat — the camera elevation # and the exercise mat are presentation-only, while pins and the ground line # are authored flat — and check invariants the solver has historically # swallowed silently. Compute-only: never touches frames/, preview.gif, or # any other output file. PIN_TO_LIMB = {pin: limb for limb, (_attach, _sigma, pin) in K.LIMBS.items()} LIMB_JOINT_NAMES = { "arm_r": ("shoulder", "elbow", "hand"), "arm_l": ("shoulder", "elbow", "hand"), "leg_r": ("hip", "knee", "ankle", "toe"), "leg_l": ("hip", "knee", "ankle", "toe"), } SPINE_POINT_NAMES = ("pelvis", "ctrl", "neck") # Joints whose DoFs a lerp actually sweeps between adjacent key frames. WRAP_JOINTS = { "shoulder_r": "shoulder", "shoulder_l": "shoulder", "hip_r": "hip", "hip_l": "hip", "elbow_r": "elbow", "elbow_l": "elbow", "knee_r": "knee", "knee_l": "knee", "ankle_r": "ankle", "ankle_l": "ankle", "neck": "neck", } # Joints IK can actually rewrite (the pinned-limb pairs) — the ones worth # comparing authored vs resolved for silent pin-vs-pose contradictions. DRIFT_JOINTS = { "shoulder_r": "shoulder", "shoulder_l": "shoulder", "hip_r": "hip", "hip_l": "hip", "elbow_r": "elbow", "elbow_l": "elbow", "knee_r": "knee", "knee_l": "knee", } PIN_TOL = 3.0 # canvas units: drawn extremity vs its authored pin. # Sized just above the solver's own rotation-gating # residual band (~2.8px worst-case for a correctly # authored on-pin branch; see BRANCH_REPRO_TOL in # kinematics.py) so the gate fails authoring errors, # not solver-inherent precision. PIN_UNREACHABLE_TOL = 4.0 # canvas units: drawn extremity vs the reach-clamped target CONTINUITY_TOL = 16.0 # canvas units: any drawn joint, tick to tick WRAP_TOL = 180.0 # degrees: any angle DoF, adjacent resolved key frames DRIFT_TOL = 45.0 # degrees: resolved vs authored, on pinned-limb DoFs GROUND_TOL = 2.0 # canvas units of slack below GROUND_Y ROM_SLACK = 5.0 # degrees of slack for the resolved-ROM re-check def _dist(a, b): return math.hypot(a[0] - b[0], a[1] - b[1]) def _pt(p): return f"({p[0]:.0f},{p[1]:.0f})" def _joint_points(geo): """Every drawn joint worth a continuity check: head, spine, and every point of every limb chain (attach through extremity, incl. toes).""" pts = {"head": geo["head"]} for nm, p in zip(SPINE_POINT_NAMES, geo["spine"]): pts[f"spine.{nm}"] = p for limb, names in LIMB_JOINT_NAMES.items(): for nm, p in zip(names, geo[limb]): pts[f"{limb}.{nm}"] = p return pts def _ground_points(geo): """`_joint_points` plus the shoulder-girdle and pelvis bar endpoints.""" pts = dict(_joint_points(geo)) pts["girdle.l"], pts["girdle.r"] = geo["girdle"][0], geo["girdle"][2] pts["pelvisBar.l"], pts["pelvisBar.r"] = geo["pelvisBar"][0], geo["pelvisBar"][2] return pts def _limb_reach(prof, limb): a, b = ((prof["upperArm"], prof["foreArm"]) if limb.startswith("arm") else (prof["thigh"], prof["shin"])) return a + b - 0.01 # mirrors solve_limb's own reach clamp def _add(violations, cls, joint, where, mag, unit, noun, detail): violations.append({"cls": cls, "joint": joint, "where": where, "mag": mag, "unit": unit, "noun": noun, "detail": detail}) def check_exercise(folder, figure="neutral"): """Simulate the complete render loop for one exercise — resolve every key frame, build the tween timeline, re-resolve every tick — at pitch 0 with no mat, collecting violations of the six invariants (pin fidelity, pin-unreachable, continuity, wrap, authored-vs-resolved drift, resolved ROM). Returns (name, [violation, ...]); an empty list means it passed. """ name = folder.name violations = [] try: motion = load_motion(folder) name = motion["name"] skel = K.load_skeleton() norms, prof, cam, _pitch, _props, _zoom = prepare(motion, figure) resolved = [frame_geometry(nf, prof, cam, pitch=0.0)[0] for nf in norms] n = len(resolved) # 6. RESOLVED ROM — the authored-frame ROM gate, re-run post-IK. for i, rf in enumerate(resolved, start=1): for jname, dof, angle, lo, hi, over in _resolved_rom_issues(rf, skel["joints"], ROM_SLACK): joint, where = f"{jname}.{dof}", f"keyframe {i}" _add(violations, "ROM", joint, where, over, "deg", "keyframes", f"ROM {joint}: {where} resolves to {angle:.1f}, outside " f"[{lo}, {hi}] (+/-{ROM_SLACK:g} slack) by {over:.1f}deg") # 4. AUTHORED-VS-RESOLVED DRIFT — pins silently overriding the pose. for i in range(n): for jkey, jt in DRIFT_JOINTS.items(): for dof in K.JOINT_DOFS[jt]: a, b = norms[i][jkey][dof], resolved[i][jkey][dof] diff = abs(a - b) if diff > DRIFT_TOL: joint, where = f"{jkey}.{dof}", f"keyframe {i + 1}" _add(violations, "DRIFT", joint, where, diff, "deg", "keyframes", f"DRIFT {joint}: {where} authored {a:.1f} vs resolved " f"{b:.1f} — {diff:.1f}deg") # 3. WRAP — adjacent resolved key frames, including the loop closure. if n > 1: for i in range(n): j = (i + 1) % n da = _dof_values(resolved[i], WRAP_JOINTS) db = _dof_values(resolved[j], WRAP_JOINTS) where = f"keyframe {i + 1}->{j + 1}" for key in da: diff = abs(da[key] - db[key]) if diff > WRAP_TOL: _add(violations, "WRAP", key, where, diff, "deg", "keyframe pairs", f"WRAP {key}: {where} {da[key]:.1f} -> {db[key]:.1f} — " f"{diff:.1f}deg") # 1/2/5. Per-tick pass: PIN / PIN-UNREACHABLE, CONTINUITY, GROUND. ticks = timeline(resolved) total = len(ticks) prev = None for t, nf in enumerate(ticks): _work, geo, _order, _shade = frame_geometry(nf, prof, cam, pitch=0.0) where = f"tick {t + 1}/{total}" for pin_name, pin_xy in nf["pins"].items(): limb = PIN_TO_LIMB.get(pin_name) if limb is None: continue attach, drawn = geo[limb][0], geo[limb][2] d_attach_pin = _dist(attach, pin_xy) reach = _limb_reach(prof, limb) if d_attach_pin > reach: over = d_attach_pin - reach # Sub-PIN_TOL overreach parks the extremity invisibly short # of its pin (authors place pins at full extension); only a # visible shortfall is an authoring error. if over > PIN_TOL: _add(violations, "PIN-UNREACHABLE", limb, where, over, "px", "ticks", f"PIN-UNREACHABLE {limb}: {where} pin {_pt(pin_xy)} is " f"{d_attach_pin:.1f}px from attach, reach {reach:.1f} — " f"{over:.1f}px over") scale = reach / d_attach_pin target = (attach[0] + (pin_xy[0] - attach[0]) * scale, attach[1] + (pin_xy[1] - attach[1]) * scale) tol = PIN_UNREACHABLE_TOL else: target, tol = pin_xy, PIN_TOL d = _dist(drawn, target) if d > tol: _add(violations, "PIN", limb, where, d, "px", "ticks", f"PIN {limb}: {where} drawn {_pt(drawn)} vs pin " f"{_pt(target)} — {d:.1f}px") jp = _joint_points(geo) gp = _ground_points(geo) for joint, p in gp.items(): if p[1] > GROUND_Y + GROUND_TOL: depth = p[1] - (GROUND_Y + GROUND_TOL) _add(violations, "GROUND", joint, where, depth, "px", "ticks", f"GROUND {joint}: {where} at y={p[1]:.1f} — {depth:.1f}px " f"below ground") if prev is not None: trans = f"tick {t}->{t + 1}/{total}" for joint, p in jp.items(): if joint not in prev: continue d = _dist(prev[joint], p) if d > CONTINUITY_TOL: _add(violations, "CONTINUITY", joint, trans, d, "px", "ticks", f"CONTINUITY {joint}: {trans} moved {d:.1f}px") prev = jp except Exception as e: # a solver bug must not crash the whole check run _add(violations, "ERROR", "-", "-", float("inf"), "", "", f"ERROR: {type(e).__name__}: {e}") return name, violations def _report_exercise(name, violations): print(f" {name}: FAIL ({len(violations)} violation(s))") groups = {} for v in violations: groups.setdefault((v["cls"], v["joint"]), []).append(v) for (cls, joint), vs in sorted(groups.items()): vs.sort(key=lambda v: -v["mag"]) worst = vs[0] if len(vs) == 1: print(f" {worst['detail']}") else: print(f" {cls} {joint}: {len(vs)} {worst['noun']}, " f"max {worst['mag']:.1f}{worst['unit']} (worst {worst['where']})") def run_check(folders, figure="neutral"): """`--check`: simulate every render tick for each exercise and fail hard on the six invariant classes. Prints a compact per-exercise violation block (silence = pass) and a final tally; returns True iff every exercise passed clean.""" total_violations = 0 failed = 0 for folder in folders: name, violations = check_exercise(folder, figure) total_violations += len(violations) if violations: failed += 1 _report_exercise(name, violations) print(f"{len(folders)} exercises checked, {failed} failed, " f"{total_violations} violations") return failed == 0 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 "--check" in flags: sys.exit(0 if run_check(folders, figure=figure) else 1) if "--export" in flags: if not run_check(folders, figure=figure): print(" --export aborted: fix the --check violations above first.") sys.exit(1) 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()