Draw the ground as an exercise mat that rotates with the figure

The floor rectangle was screen-locked, which broke the illusion the
moment the camera orbited. It is now a world-space quad on the ground
plane, sized to each motion's projected footprint across its key frames
and rotated through the same camera as the figure - a long rectangle in
profile, a parallelogram mid-orbit, end-on when face-on. Both renderers
in lockstep; fixtures unaffected (the mat is a pure addition).

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
2026-07-06 21:31:36 -04:00
parent b82054b81a
commit 6d9eff1a71
98 changed files with 177 additions and 107 deletions
+48 -18
View File
@@ -102,7 +102,7 @@ def _bucket(depth):
return round(depth / DEPTH_BUCKET)
def frame_geometry(nf, prof, cam, flipped=False, pitch=CAMERA_PITCH):
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):
@@ -167,6 +167,18 @@ def frame_geometry(nf, prof, cam, flipped=False, pitch=CAMERA_PITCH):
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"]
@@ -183,6 +195,19 @@ def frame_geometry(nf, prof, cam, flipped=False, pitch=CAMERA_PITCH):
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
def ease(t):
return 3 * t * t - 2 * t * t * t
@@ -345,13 +370,15 @@ def draw_prims(d, prims, colors, scale):
# ------------------------------------------------------------------- drawing
def floor_rect_svg(colors):
"""The floor plane under the elevated camera: a rectangle straddling the
ground line (the old horizontal line was the pitch-zero degenerate case)."""
fh = FLOOR_HALF_DEPTH * math.sin(math.radians(CAMERA_PITCH))
return (f' <rect x="16" y="{GROUND_Y + 4 - fh:.1f}" width="{CANVAS[0] - 32}"'
f' height="{2 * fh:.1f}" rx="3" fill="none"'
f' stroke="{colors["ground"]}" stroke-width="3"/>')
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' <path d="{d}" fill="none" stroke="{colors["ground"]}"'
f' stroke-width="3" stroke-linejoin="round"/>')
def part_style(part, working, colors, shade):
@@ -378,7 +405,7 @@ def svg_for_frame(name, geo, order, shade, working, colors, props=None):
w, h = CANVAS
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" fill="none">',
f' <title>{name}</title>',
floor_rect_svg(colors)]
floor_svg(geo, colors)]
parts += svg_prims(bg, colors)
for part in order:
if part == "head":
@@ -431,10 +458,8 @@ def draw_geo(geo, order, shade, working, colors, scale=2, font=None, props=None)
for x, y in (pts[0], pts[-1]):
d.ellipse([x - r, y - r, x + r, y + r], fill=color)
fh = FLOOR_HALF_DEPTH * math.sin(math.radians(CAMERA_PITCH))
d.rounded_rectangle([16 * scale, (GROUND_Y + 4 - fh) * scale,
(CANVAS[0] - 16) * scale, (GROUND_Y + 4 + fh) * scale],
radius=3 * scale, outline=colors["ground"], width=3 * scale)
if geo.get("floor"):
line(geo["floor"] + geo["floor"][:1], colors["ground"], 3)
draw_prims(d, bg, colors, scale)
for part in order:
if part == "head":
@@ -511,8 +536,10 @@ def render_exercise(folder, figure="neutral", flip=False, strict=False):
hide = set(motion.get("hide", []))
norms, prof, cam, pitch, props = 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)
_, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch, mat)
for limb in hide:
geo.pop(limb, None)
return geo, order, shade
@@ -520,7 +547,7 @@ def render_exercise(folder, figure="neutral", flip=False, strict=False):
resolved = []
key_geos = []
for nf in norms:
out, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch)
out, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch, mat)
for limb in hide:
geo.pop(limb, None)
resolved.append(out)
@@ -559,6 +586,7 @@ def render_orbit(folder, figure="neutral"):
hide = set(motion.get("hide", []))
norms, prof, cam, pitch, props = prepare(motion, figure)
resolved = [frame_geometry(nf, prof, cam, pitch=pitch)[0] for nf in norms]
mat = mat_bounds(norms, prof, cam, pitch)
font = legend_font()
colors = PALETTES["default"]
ticks = timeline(resolved)
@@ -569,7 +597,7 @@ def render_orbit(folder, figure="neutral"):
# then rotate the posed body - never re-pin in the rotated view.
posed, _, _, _ = frame_geometry(nf, prof, cam, pitch=pitch)
posed["pins"] = {}
_, geo, order, shade = frame_geometry(posed, prof, yaw, pitch=pitch)
_, geo, order, shade = frame_geometry(posed, prof, yaw, pitch=pitch, mat=mat)
for limb in hide:
geo.pop(limb, None)
imgs.append(draw_geo(geo, order, shade, working, colors, font=font, props=props))
@@ -585,8 +613,9 @@ def contact_sheet(folders, figure="neutral", out=None):
motion = load_motion(folder)
working, hide = set(motion.get("working", [])), set(motion.get("hide", []))
norms, prof, cam, pitch, props = 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)
_, geo, order, shade = frame_geometry(nf, prof, cam, pitch=pitch, mat=mat)
for limb in hide:
geo.pop(limb, None)
cells.append((f"{motion['name']} {i}/{len(norms)}",
@@ -611,7 +640,8 @@ def demo_sheet(folder):
cells = []
for label, figure, flip, palette in variants:
norms, prof, cam, pitch, props = prepare(motion, figure, flip)
_, geo, order, shade = frame_geometry(norms[idx], prof, cam, flip, pitch)
mat = mat_bounds(norms, prof, cam, pitch)
_, geo, order, shade = frame_geometry(norms[idx], prof, cam, flip, pitch, mat)
for limb in hide:
geo.pop(limb, None)
cells.append((f"{motion['name']}{label}",