View the figure from a slight elevation and refine the leg-machine rollers

The default camera pitches down 10 degrees, so the floor reads as a
plane (drawn as a rectangle) and near/far contacts straddle it.
Elevation is pure presentation - IK pins solve in the flat authored
view and the posed body tilts, the same pattern as the orbit, so
authored canvas targets never go out of reach. The leg-extension
roller moves up onto the shin above the ankle and the leg-curl roller
tucks under the heel. Fixtures and reference test values regenerated
for the pitched camera.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
2026-07-06 21:20:07 -04:00
parent 5e4980f0d7
commit b82054b81a
108 changed files with 838 additions and 778 deletions
+39 -18
View File
@@ -29,6 +29,11 @@ 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": {
@@ -97,7 +102,7 @@ def _bucket(depth):
return round(depth / DEPTH_BUCKET)
def frame_geometry(nf, prof, cam, flipped=False):
def frame_geometry(nf, prof, cam, flipped=False, pitch=CAMERA_PITCH):
"""Resolve one normalized frame into drawable 2D geometry.
Returns (nf with IK-resolved angles and original pins, geo, order, shade):
@@ -108,7 +113,7 @@ def frame_geometry(nf, prof, cam, flipped=False):
offset, scaled by how side-on the view is, so overlapping limbs stay
distinguishable in profile views.
"""
p0 = K.pose(nf, prof, cam)
p0 = K.pose(nf, prof, cam, pitch)
shade, order_parts = {}, []
for right, left in PAIRS:
dr, dl = _chain_depth(p0["points"][right]), _chain_depth(p0["points"][left])
@@ -128,8 +133,12 @@ def frame_geometry(nf, prof, cam, flipped=False):
if shade[limb] == "far" and pin in work["pins"]:
work["pins"][pin] = [work["pins"][pin][0] - off[0],
work["pins"][pin][1] - off[1]]
work, p = K.resolve(work, prof, cam)
# 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"]
@@ -336,6 +345,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 part_style(part, working, colors, shade):
"""Near pair members draw in the dark ink, far members in the light one;
the spine is always dark. Working parts swap ink for the accent teals."""
@@ -360,8 +378,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>',
f' <line x1="16" y1="{GROUND_Y + 4}" x2="{w - 16}" y2="{GROUND_Y + 4}"'
f' stroke="{colors["ground"]}" stroke-width="3" stroke-linecap="round"/>']
floor_rect_svg(colors)]
parts += svg_prims(bg, colors)
for part in order:
if part == "head":
@@ -414,7 +431,10 @@ 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)
line([(16, GROUND_Y + 4), (CANVAS[0] - 16, GROUND_Y + 4)], colors["ground"], 3)
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)
draw_prims(d, bg, colors, scale)
for part in order:
if part == "head":
@@ -466,6 +486,7 @@ def prepare(motion, figure="neutral", flip=False, strict=False):
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))
norms = [K.normalize_frame(kf) for kf in motion["frames"]]
issues = []
for i, nf in enumerate(norms, start=1):
@@ -481,17 +502,17 @@ 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, props
return norms, prof, cam, pitch, props
def render_exercise(folder, figure="neutral", flip=False, strict=False):
motion = load_motion(folder)
working = set(motion.get("working", []))
hide = set(motion.get("hide", []))
norms, prof, cam, props = prepare(motion, figure, flip, strict)
norms, prof, cam, pitch, props = prepare(motion, figure, flip, strict)
def geometry(nf):
_, geo, order, shade = frame_geometry(nf, prof, cam, flip)
_, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch)
for limb in hide:
geo.pop(limb, None)
return geo, order, shade
@@ -499,7 +520,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)
out, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch)
for limb in hide:
geo.pop(limb, None)
resolved.append(out)
@@ -536,8 +557,8 @@ def render_orbit(folder, figure="neutral"):
motion = load_motion(folder)
working = set(motion.get("working", []))
hide = set(motion.get("hide", []))
norms, prof, cam, props = prepare(motion, figure)
resolved = [frame_geometry(nf, prof, cam)[0] for nf in norms]
norms, prof, cam, pitch, props = prepare(motion, figure)
resolved = [frame_geometry(nf, prof, cam, pitch=pitch)[0] for nf in norms]
font = legend_font()
colors = PALETTES["default"]
ticks = timeline(resolved)
@@ -546,9 +567,9 @@ def render_orbit(folder, figure="neutral"):
yaw = cam + 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.
posed, _, _, _ = frame_geometry(nf, prof, cam)
posed, _, _, _ = frame_geometry(nf, prof, cam, pitch=pitch)
posed["pins"] = {}
_, geo, order, shade = frame_geometry(posed, prof, yaw)
_, geo, order, shade = frame_geometry(posed, prof, yaw, pitch=pitch)
for limb in hide:
geo.pop(limb, None)
imgs.append(draw_geo(geo, order, shade, working, colors, font=font, props=props))
@@ -563,9 +584,9 @@ def contact_sheet(folders, figure="neutral", out=None):
for folder in folders:
motion = load_motion(folder)
working, hide = set(motion.get("working", [])), set(motion.get("hide", []))
norms, prof, cam, props = prepare(motion, figure)
norms, prof, cam, pitch, props = prepare(motion, figure)
for i, nf in enumerate(norms, start=1):
_, geo, order, shade = frame_geometry(nf, prof, cam)
_, geo, order, shade = frame_geometry(nf, prof, cam, pitch=pitch)
for limb in hide:
geo.pop(limb, None)
cells.append((f"{motion['name']} {i}/{len(norms)}",
@@ -589,8 +610,8 @@ def demo_sheet(folder):
("themed (indigo)", "neutral", False, "indigo")]
cells = []
for label, figure, flip, palette in variants:
norms, prof, cam, props = prepare(motion, figure, flip)
_, geo, order, shade = frame_geometry(norms[idx], prof, cam, flip)
norms, prof, cam, pitch, props = prepare(motion, figure, flip)
_, geo, order, shade = frame_geometry(norms[idx], prof, cam, flip, pitch)
for limb in hide:
geo.pop(limb, None)
cells.append((f"{motion['name']}{label}",