Add presentation-only camera.zoom so standing motions fit the canvas

The rig is ~211 canvas units tall standing but the canvas has 152 above
the ground line - every motion to date is seated or on the floor. A
per-motion "camera": {"zoom": ...} now scales the drawn output (figure,
props, mat, stroke widths) about the ground-center anchor in both the
reference renderer and the in-app view. Pure view transform: pins, prop
coordinates, and the Swift-solver fixtures stay in full-size authored
units; zoom 1 is byte-identical to before.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
2026-07-07 11:43:00 -04:00
parent eadaa075c4
commit e4bedc5bda
4 changed files with 101 additions and 22 deletions
+10
View File
@@ -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.750.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
+75 -22
View File
@@ -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' <path d="{d}" fill="none" stroke="{colors["ground"]}"'
f' stroke-width="3" stroke-linejoin="round"/>')
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'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" fill="none">',
f' <title>{name}</title>',
floor_svg(geo, colors)]
@@ -481,25 +524,26 @@ def svg_for_frame(name, geo, order, shade, working, colors, prims=((), ())):
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"]}"/>')
f' fill="{colors["head_fill"]}" stroke="{colors["right"]}" stroke-width="{WIDTHS["head"] * zf:g}"/>')
if "nose" in geo:
(sx, sy), (ex, ey) = geo["nose"]
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"/>')
f' stroke="{colors["right"]}" stroke-width="{WIDTHS["nose"] * zf:g}" stroke-linecap="round"/>')
continue
if part not in geo:
continue
color, width = part_style(part, working, colors, shade)
width *= zf
if part == "spine":
for bar in ("girdle", "pelvisBar"):
bd = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in geo[bar])
parts.append(f' <path id="{bar}" d="{bd}" stroke="{color}" stroke-width="5"'
parts.append(f' <path id="{bar}" d="{bd}" stroke="{color}" stroke-width="{5 * zf:g}"'
f' stroke-linecap="round" stroke-linejoin="round"/>')
(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}"'
parts.append(f' <path id="{part}" d="{d}" stroke="{color}" stroke-width="{width:g}"'
f' stroke-linecap="round" stroke-linejoin="round"/>')
lx = w - 96
parts.append(f' <g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="{colors["legend_text"]}">')
@@ -517,35 +561,37 @@ def draw_geo(geo, order, shade, working, colors, scale=2, font=None, prims=((),
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=width * scale, joint="curve")
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)
if geo.get("floor"):
line(geo["floor"] + geo["floor"][:1], colors["ground"], 3)
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, WIDTHS["head"] * scale
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"])
line(list(geo["nose"]), colors["right"], WIDTHS["nose"] * zf)
continue
if part not in geo:
continue
color, width = part_style(part, working, colors, shade)
width *= zf
if part == "spine":
line(geo["girdle"], color, 5)
line(geo["pelvisBar"], color, 5)
line(geo["girdle"], color, 5 * zf)
line(geo["pelvisBar"], color, 5 * zf)
pts = quad_points(*geo["spine"]) if part == "spine" else geo[part]
line(pts, color, width)
@@ -575,12 +621,13 @@ def load_motion(folder):
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."""
"""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):
@@ -596,13 +643,13 @@ def prepare(motion, figure="neutral", flip=False, strict=False):
if flip:
norms = [mirror_frame(nf, CANVAS[0]) for nf in norms]
props = flip_props(props, CANVAS[0])
return norms, prof, cam, pitch, props
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 = prepare(motion, figure, flip, strict)
norms, prof, cam, pitch, props, zoom = prepare(motion, figure, flip, strict)
mat = mat_bounds(norms, prof, cam, pitch)
@@ -615,7 +662,9 @@ def render_exercise(folder, figure="neutral", flip=False, strict=False):
for nf in norms:
out, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch, mat)
resolved.append(out)
key_cells.append((geo, order, shade, resolve_props(props, geo, nf["root"]["pos"])))
prims = resolve_props(props, geo, nf["root"]["pos"])
geo, prims = apply_zoom(geo, prims, zoom)
key_cells.append((geo, order, shade, prims))
frames_dir = folder / "frames"
frames_dir.mkdir(exist_ok=True)
@@ -634,6 +683,7 @@ def render_exercise(folder, figure="neutral", flip=False, strict=False):
for nf in timeline(resolved):
geo, order, shade = geometry(nf)
prims = resolve_props(props, geo, nf["root"]["pos"])
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)
@@ -648,7 +698,7 @@ def render_orbit(folder, figure="neutral"):
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 = prepare(motion, figure)
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")
@@ -667,6 +717,7 @@ def render_orbit(folder, figure="neutral"):
_, 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)
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)
@@ -679,11 +730,12 @@ def contact_sheet(folders, figure="neutral", out=None):
for folder in folders:
motion = load_motion(folder)
working = set(motion.get("working", []))
norms, prof, cam, pitch, props = prepare(motion, figure)
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"])
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)))
@@ -705,10 +757,11 @@ def demo_sheet(folder):
("themed (indigo)", "neutral", False, "indigo")]
cells = []
for label, figure, flip, palette in variants:
norms, prof, cam, pitch, props = prepare(motion, figure, flip)
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"])
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)))
@@ -780,7 +833,7 @@ def write_fixtures(folders):
exercises = []
for folder in folders:
motion = load_motion(folder)
norms, prof, cam, pitch, props = prepare(motion, "neutral")
norms, prof, cam, pitch, props, _zoom = prepare(motion, "neutral")
resolved, frames = [], []
for nf in norms:
out, geo, order, shade = frame_geometry(nf, prof, cam)
@@ -24,6 +24,9 @@ struct FigureAnimation {
let working: Set<String>
/// Equipment layer (see SYSTEM.md "The props layer").
let props: [MotionProp]
/// Presentation zoom about the ground-center anchor standing motions author
/// full-size anatomy taller than the canvas and zoom out to fit (see SYSTEM.md).
let zoom: Double
init?(exerciseName: String) {
guard
@@ -33,6 +36,7 @@ struct FigureAnimation {
self.timeline = timeline
self.working = Set(resources.motion.working ?? [])
self.props = resources.motion.props ?? []
self.zoom = resources.motion.camera?.zoom ?? 1
}
/// Seconds per full camera revolution.
@@ -96,6 +100,14 @@ struct ExerciseFigureView: View {
y: (size.height - Self.designSize.height * scale) / 2)
ctx.scaleBy(x: scale, y: scale)
// Presentation zoom about the ground-center anchor, mirroring the reference
// renderer's `apply_zoom` (stroke widths scale with the geometry).
if figure.zoom != 1 {
ctx.translateBy(x: Self.designSize.width / 2, y: Self.groundY)
ctx.scaleBy(x: figure.zoom, y: figure.zoom)
ctx.translateBy(x: -Self.designSize.width / 2, y: -Self.groundY)
}
let geo = figure.geometry(at: time)
// The exercise mat: a ground-plane quad sized to the motion's footprint,
@@ -106,6 +106,10 @@ struct MotionCamera: Codable {
let yaw: Double?
/// Camera elevation override; nil uses the standard slightly-raised viewpoint.
let pitch: Double?
/// Presentation-only zoom about the ground-center anchor (nil = 1). Standing
/// motions author full-size anatomy taller than the canvas and zoom out
/// to fit; the solver and fixtures stay in full-size canvas units.
let zoom: Double?
}
/// A prop's joint reference: one joint (`"hand_r"`, `"knee_l"`, `"elbow_r"`, )