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:
@@ -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
|
||||
|
||||
+75
-22
@@ -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"`, …)
|
||||
|
||||
Reference in New Issue
Block a user