diff --git a/Exercise Library/SYSTEM.md b/Exercise Library/SYSTEM.md
index f67e280..0b2c1d9 100644
--- a/Exercise Library/SYSTEM.md
+++ b/Exercise Library/SYSTEM.md
@@ -80,6 +80,16 @@ never touches a motion script** — proportions are the skeleton's problem.
camera also orbits while the motion loops (`--orbit`; the in-app renderer
slowly orbits **every** exercise, machines included — props have
world-space 3D form and turn with the figure).
+- **Zoom** — the skeleton is ~211 canvas units tall standing but the canvas
+ has only 152 above the ground line, so standing motions author full-size
+ anatomy (coordinates may run past the canvas top) and set
+ `"camera": {"zoom": 0.7}` to fit. Zoom is **presentation-only**: a uniform
+ scale of the drawn output (geometry, props, mat, stroke widths) about the
+ ground-center anchor `(160, 152)`, applied after all solving — pins, prop
+ coordinates, and the Swift-solver fixtures stay in full-size authored
+ units, and the ground line maps to itself so planted feet stay planted.
+ Typical values: ~0.7 standing tall, ~0.75–0.85 hanging or seated with arms
+ overhead; omit it (1) for lying, kneeling, seated, and bent-over motions.
- **Elevation & the mat** — the default viewpoint pitches down 10°
(`CAMERA_PITCH`; override per motion via `"camera": {"pitch": ...}`), and
the ground is drawn as an **exercise mat**: a world-space quad sized to the
diff --git a/Exercise Library/render.py b/Exercise Library/render.py
index 7c1c04f..14d08c4 100644
--- a/Exercise Library/render.py
+++ b/Exercise Library/render.py
@@ -210,6 +210,48 @@ def mat_bounds(norms, prof, cam, pitch=CAMERA_PITCH):
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
@@ -423,7 +465,7 @@ def draw_prims(d, prims, colors, scale):
if p["kind"] == "poly":
d.polygon(pts, fill=color)
pts = pts + pts[:1]
- w = p.get("w", 4) * scale
+ 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)
@@ -434,7 +476,7 @@ def draw_prims(d, prims, colors, scale):
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)
+ width=round(p.get("w", 3) * scale))
# ------------------------------------------------------------------- drawing
@@ -447,7 +489,7 @@ def floor_svg(geo, colors):
return ""
d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in quad) + " Z"
return (f' ')
+ f' stroke-width="{3 * geo.get("zoom", 1.0):g}" stroke-linejoin="round"/>')
def part_style(part, working, colors, shade):
@@ -472,6 +514,7 @@ def quad_points(p0, ctrl, p2, n=24):
def svg_for_frame(name, geo, order, shade, working, colors, prims=((), ())):
bg, fg = prims
w, h = CANVAS
+ zf = geo.get("zoom", 1.0)
parts = [f'