#!/usr/bin/env python3
"""Render Exercise Library visuals from an anatomical 3D rig.
A skeleton profile (skeleton.json: bone lengths incl. shoulder/pelvis widths
and feet, plus per-joint ROM) and a per-exercise motion script (motion.json:
key frames of anatomical joint angles - flexion/abduction/rotation measured
from neutral standing - a root anchor + trunk orientation, optional IK pins,
timing, and a camera) resolve through 3D forward kinematics and orthographic
projection into stick-figure frames. See SYSTEM.md for the format and the
visual language, kinematics.py for the math and conventions.
Outputs per exercise: frames/frame-N.svg, preview.gif (tweened, looping),
visual.svg (the primary frame). `--sheet` writes contact-sheet.png of every
key frame; `--demo` writes demo-sheet.png showing rig customizations (body
profiles, flipped camera, theme); `--orbit` writes orbit.gif per named
exercise (the camera sweeps 360 degrees while the motion loops — props
rotate with the figure). `--export` copies the app's bundled resources
verbatim; `--fixtures` regenerates the projected-geometry snapshots pinning
the in-app Swift solver to this pipeline. `--check` is compute-only: it
simulates the full tween loop per exercise and fails hard on solver
inconsistencies (pin drift, teleports, angle wraps, authored-vs-resolved
pose contradictions, sunk geometry, resolved ROM) — `--export` always runs
it first and refuses to ship a failing exercise. SVGs need no dependencies;
GIFs/sheets need Pillow.
"""
import copy
import json
import math
import sys
from pathlib import Path
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": {
"right": "#3a3f4b", "left": "#a9afba",
"right_working": "#0d9488", "left_working": "#86cfc5",
"ground": "#b9bec9", "legend_text": "#6b7180", "head_fill": "white",
"equipment": "#c5cad4", "prop": "#6b7180",
},
"indigo": {
"right": "#3a3f4b", "left": "#a9afba",
"right_working": "#4f46e5", "left_working": "#a5b4fc",
"ground": "#b9bec9", "legend_text": "#6b7180", "head_fill": "white",
"equipment": "#c5cad4", "prop": "#6b7180",
},
}
WIDTHS = {"near": 6, "far": 5, "spine": 6, "head": 6, "nose": 4}
# Depth shading is a smooth per-vertex gradient, not a near/far switch: every
# drawn joint is blended between the near and far colors by its own camera depth
# relative to its limb pair's central plane, so the ink flows along a bone that
# reaches in depth and fades to a shared mid-tone where the two members cross
# (face-on, or mid-orbit) instead of snapping. A joint reaches full contrast
# once it sits this fraction of the shoulder/pelvis half-width in front of (or
# behind) that plane, so a straight limb in profile is still fully dark/light.
SHADE_SPAN_FRAC = 0.6
# Draw order is by camera depth (far parts first, head always on top, filled
# opaque so overhead arms are occluded by the face). Depths are bucketed so
# side views stay stable; ties fall back to this fixed rank.
FIXED_RANK = {"arm_l": 0, "leg_l": 1, "spine": 2, "arm_r": 3, "leg_r": 4}
DEPTH_BUCKET = 3.0
PAIRS = (("arm_r", "arm_l"), ("leg_r", "leg_l"))
# Prop joint refs -> (limb, chain index): extremities are index 2, mid joints
# (elbows/knees) index 1, so equipment can ride either joint.
JOINT_LIMB = {
"hand_r": ("arm_r", 2), "elbow_r": ("arm_r", 1),
"hand_l": ("arm_l", 2), "elbow_l": ("arm_l", 1),
"foot_r": ("leg_r", 2), "knee_r": ("leg_r", 1),
"foot_l": ("leg_l", 2), "knee_l": ("leg_l", 1),
}
def profiles():
return K.load_skeleton()["profiles"]
def dirv(deg):
"""Unit vector for a y-up angle, in y-down canvas coordinates."""
r = math.radians(deg)
return (math.cos(r), -math.sin(r))
def angle_of(a, b):
"""Y-up world angle of the canvas segment a->b."""
return math.degrees(math.atan2(-(b[1] - a[1]), b[0] - a[0]))
# ------------------------------------------------------------------- solver
def mirror_frame(nf, width):
"""Mirror a normalized frame's canvas anchors for the flipped camera."""
out = copy.deepcopy(nf)
out["root"]["pos"][0] = width - out["root"]["pos"][0]
out["pins"] = {k: [width - x, y] for k, (x, y) in out["pins"].items()}
return out
def _chain_depth(pts):
return sum(p[2] for p in pts) / len(pts)
def _bucket(depth):
return round(depth / DEPTH_BUCKET)
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):
geo maps parts to canvas points, order is the depth-sorted draw order,
shade maps each limb to "near"/"far" (near pair members draw dark and in
front - the visual language; canvas-right wins depth ties in face-on
views). The far member of each pair also gets the profile's readability
offset, scaled by how side-on the view is, so overlapping limbs stay
distinguishable in profile views.
"""
p0 = K.pose(nf, prof, cam, pitch)
shade = {}
for right, left in PAIRS:
dr, dl = _chain_depth(p0["points"][right]), _chain_depth(p0["points"][left])
if _bucket(dr) == _bucket(dl):
ax_r = p0["points"][right][0][0] # view x == canvas offset from anchor
near = right if ax_r >= p0["points"][left][0][0] else left
else:
near = right if dr > dl else left
shade[right] = "near" if near == right else "far"
shade[left] = "near" if near == left else "far"
fo = prof.get("farOffset", [6, 2])
off = ((-fo[0] if flipped else fo[0]) * p0["k"], fo[1] * p0["k"])
work = copy.deepcopy(nf)
for limb, (_attach, _sigma, pin) in K.LIMBS.items():
if shade[limb] == "far" and pin in work["pins"]:
work["pins"][pin] = [work["pins"][pin][0] - off[0],
work["pins"][pin][1] - off[1]]
# 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"]
def scr(v, limb_off=(0, 0)):
return (anchor[0] + v[0] + limb_off[0], anchor[1] - v[1] + limb_off[1])
pts = p["points"]
pelvis, mid, neck_b = scr(pts["pelvis"]), scr(pts["mid"]), scr(pts["neckB"])
geo = {"headR": prof["headR"], "head": scr(pts["head"]),
"spine": [pelvis,
(2 * mid[0] - (pelvis[0] + neck_b[0]) / 2,
2 * mid[1] - (pelvis[1] + neck_b[1]) / 2),
neck_b]}
depths = {"spine": _chain_depth([pts["pelvis"], pts["mid"], pts["neckB"]])}
for limb in K.LIMBS:
limb_off = off if shade[limb] == "far" else (0, 0)
geo[limb] = [scr(v, limb_off) for v in pts[limb]]
depths[limb] = _chain_depth(pts[limb])
# Per-vertex depth shading: each drawn joint is toned by its OWN camera depth,
# not the whole limb's average, so the ink flows smoothly along a limb that
# reaches toward or away from the camera instead of the bone snapping to one
# flat tone. Each pair is measured against its central depth plane and
# normalized by the profile half-width (x SHADE_SPAN_FRAC), chosen so a
# straight limb in a profile view still reaches full near-dark / far-light
# (the per-vertex average reproduces the old flat per-limb tone) while the
# two members still fade to a shared mid-tone as they cross. The binary
# `shade` above stays untouched - it alone drives the far offset and order.
nearness = {}
for right, left in PAIRS:
zr = [v[2] for v in pts[right]]
zl = [v[2] for v in pts[left]]
ref = (sum(zr) / len(zr) + sum(zl) / len(zl)) / 2
half = prof.get("shoulderHalf" if "arm" in right else "hipHalf", 10) * p["k"]
span = half * SHADE_SPAN_FRAC
for limb, zs in ((right, zr), (left, zl)):
nearness[limb] = [(0.5 + 0.5 * max(-1.0, min(1.0, (z - ref) / span)))
if span else 0.5 for z in zs]
# Shoulder girdle and pelvis, drawn with the spine so the limbs visibly hang
# from real width. Endpoints use each side's drawn attach (far offset included)
# so the bars meet the limbs exactly.
def attach(key, limb):
return scr(pts[key], off if shade[limb] == "far" else (0, 0))
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"]
mag = math.hypot(nd[0], nd[1])
if mag > 0.3:
ux, uy = nd[0] / mag, -nd[1] / mag
hx, hy = geo["head"]
r = prof["headR"]
geo["nose"] = ((hx + ux * r, hy + uy * r),
(hx + ux * (r + 7 * mag), hy + uy * (r + 7 * mag)))
geo["nearness"] = nearness
order = sorted(depths, key=lambda part: (_bucket(depths[part]),
FIXED_RANK[part])) + ["head"]
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
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
def timeline(norms, fps=20):
frames = []
for i, kf in enumerate(norms):
frames += [kf] * max(1, round(kf["hold"] * fps))
nxt = norms[(i + 1) % len(norms)]
steps = max(1, round(kf["tween"] * fps))
frames += [K.lerp_frames(kf, nxt, ease(s / steps)) for s in range(1, steps)]
return frames
# -------------------------------------------------------------------- props
# Equipment layer (see SYSTEM.md): `scene` shapes and `cable`s draw behind the
# figure in the recessive equipment gray; joint-attached items (`bar`,
# `dumbbell`, `pad`) draw over the limbs in the darker prop gray, following
# the resolved hand/foot positions frame by frame.
#
# Props have world-space 3D form: everything is authored (and resolved) in the
# authored view — scene points and cable anchors carry an optional depth `z`
# (+ toward the camera), a scene line an optional `depth` extrusion half-width
# that makes it a slab — then rotated about the world-vertical axis through
# the root anchor for the orbiting presentation, exactly like the figure and
# the mat. Positions that follow the figure (joint anchors) come from the
# *rotated* geometry; fixed points, directions, and offsets (scene shapes,
# cable anchors, bar angles, pad perpendiculars, roller offsets) resolve in
# the *authored* view and rotate — never recompute them in the rotated view.
def flip_props(props, width):
"""Mirror the props horizontally, matching the flipped camera (which views
from the opposite side, so authored depths flip sign too). Joint-attached
props follow the mirrored limbs automatically; only fixed coordinates,
depths, and world angles need mirroring."""
def fx(p):
return [width - p[0], p[1], -p[2]] if len(p) > 2 else [width - p[0], p[1]]
out = []
for prop in props:
p = json.loads(json.dumps(prop))
if p["type"] == "scene":
for s in p["shapes"]:
if "z" in s:
s["z"] = -s["z"]
if s["kind"] == "line":
s["pts"] = [fx(pt) for pt in s["pts"]]
elif s["kind"] == "circle":
s["c"] = fx(s["c"])
elif p["type"] == "cable":
p["from"] = fx(p["from"])
elif p["type"] in ("bar", "pad") and "angle" in p:
p["angle"] = 180 - p["angle"]
elif p["type"] == "roller":
p["side"] = -p.get("side", 1) # mirroring flips the perpendicular's handedness
out.append(p)
return out
def prop_rotation(pitch, yaw_offset):
"""View-space rotation carrying authored-view prop geometry to the camera
`yaw_offset` degrees past the authored yaw: a rotation about the world-
vertical axis through the root anchor, conjugated by the camera elevation.
The identity at offset 0, so the authored view stays bit-exact."""
if yaw_offset == 0:
return K.IDENTITY
return K.chain(K.rot_x(pitch), K.rot_y(-yaw_offset), K.rot_x(-pitch))
def joint_points(geo, ref):
"""Resolve a joint ref — `"hand_r"`, `"knee_l"`, or a midpoint list like
`["knee_r", "foot_r"]` — to (point, unit direction of the bone ending at
the first joint). None when the limb isn't drawn."""
names = ref if isinstance(ref, list) else [ref]
pts, direction = [], None
for name in names:
limb_name, idx = JOINT_LIMB[name]
limb = geo.get(limb_name)
if not limb:
return None, None
pts.append(limb[idx])
if direction is None:
a, b = limb[idx - 1], limb[idx]
d = math.hypot(b[0] - a[0], b[1] - a[1]) or 1.0
direction = ((b[0] - a[0]) / d, (b[1] - a[1]) / d)
return ((sum(p[0] for p in pts) / len(pts),
sum(p[1] for p in pts) / len(pts)), direction)
def resolve_props(props, geo, anchor, rot=K.IDENTITY, auth_geo=None, pitch=CAMERA_PITCH):
"""Props -> drawable primitives for one frame: (background, foreground).
`geo` is the frame's drawn (possibly orbit-rotated) geometry, `auth_geo`
the same frame at the authored camera (defaults to `geo`), `anchor` the
frame's root canvas anchor, and `rot` the `prop_rotation` between them.
Joint positions come from `geo`; everything authored — scene points,
cable anchors, bar angles, pad perpendiculars, roller offsets — resolves
against `auth_geo` and rotates through `rot`. `pitch` is the camera
elevation, needed by `axis` props whose world-space direction projects
through it (like the floor quad).
"""
auth = auth_geo if auth_geo is not None else geo
def place(pt, z=0.0):
"""Authored canvas point (+ depth toward the camera) -> drawn canvas."""
v = K.mvec(rot, (pt[0] - anchor[0], anchor[1] - pt[1], z))
return (anchor[0] + v[0], anchor[1] - v[1])
def swing(vec, z=0.0):
"""Authored canvas-space direction/offset -> drawn canvas (y-down)."""
v = K.mvec(rot, (vec[0], -vec[1], z))
return (v[0], -v[1])
bg, fg = [], []
for p in props or []:
t = p["type"]
if t == "scene":
for s in p["shapes"]:
color = s.get("color", "equipment")
z = s.get("z", 0.0)
if s["kind"] == "line":
depth = s.get("depth", 0.0)
pts = [(pt[0], pt[1], z + (pt[2] if len(pt) > 2 else 0.0))
for pt in s["pts"]]
if depth:
# An extruded slab: the polyline swept through +/-depth,
# filled and outlined so it degenerates to the plain
# line whenever the sweep collapses edge-on.
quad = ([place(pt, pt[2] + depth) for pt in pts]
+ [place(pt, pt[2] - depth) for pt in reversed(pts)])
bg.append({"kind": "poly", "pts": [list(q) for q in quad],
"w": s.get("w", 4), "color": color})
else:
bg.append({"kind": "line",
"pts": [list(place(pt, pt[2])) for pt in pts],
"w": s.get("w", 4), "color": color})
elif s["kind"] == "circle":
bg.append({"kind": "circle", "c": list(place(s["c"], z)),
"r": s["r"], "fill": s.get("fill", False),
"w": s.get("w", 3), "color": color})
elif t == "cable":
end, _ = joint_points(geo, p["to"])
if end:
start = place(p["from"], p["from"][2] if len(p["from"]) > 2 else 0.0)
bg.append({"kind": "line", "pts": [list(start), list(end)],
"w": p.get("w", 2), "color": "equipment"})
elif t == "roller":
# A machine roller pad seen end-on: a disc riding the limb's lower
# bone near the joint, on the `side` (+1/-1) of the bone it presses.
# The offset resolves along the authored-view bone, then rotates.
c, _ = joint_points(geo, p["at"])
_, d = joint_points(auth, p["at"])
if not c or not d:
continue
r = p.get("r", 5)
back = p.get("back", 0)
side = p.get("side", 1)
px, py = d[1] * side, -d[0] * side
off = swing((-d[0] * back + px * (r + 3), -d[1] * back + py * (r + 3)))
fg.append({"kind": "circle", "c": [c[0] + off[0], c[1] + off[1]], "r": r,
"fill": True, "color": "prop"})
elif t in ("bar", "dumbbell", "pad"):
c, _ = joint_points(geo, p["at"])
_, d = joint_points(auth, p["at"])
if not c or not d:
continue
if p.get("axis") == "z":
# A cross-body rod (barbell, pull-up bar): its true 3D axis is
# the world left-right axis, projected through the camera
# elevation like the floor quad — a small vertical sliver
# end-on in a profile view (the plates read nearly concentric,
# seen from slightly above), full span face-on, swinging with
# the figure in between, symmetric at 0 and 180.
pr = math.radians(pitch)
ux, uy = swing((0.0, math.sin(pr)), math.cos(pr))
elif t == "bar" or "angle" in p:
axis = dirv(p.get("angle", 0)) # fixed authored-view angle
ux, uy = swing(axis) # foreshortens under orbit
else:
axis = (-d[1], d[0]) # perpendicular to the lower bone
ux, uy = swing(axis)
h = p.get("halfLen", {"bar": 24, "dumbbell": 7, "pad": 8}[t])
a = (c[0] - ux * h, c[1] - uy * h)
b = (c[0] + ux * h, c[1] + uy * h)
fg.append({"kind": "line", "pts": [list(a), list(b)],
"w": p.get("w", {"bar": 4, "dumbbell": 3, "pad": 7}[t]),
"color": "prop"})
plate = p.get("plateR", 4.5 if t == "dumbbell" else 0)
if plate:
for e in (a, b):
fg.append({"kind": "circle", "c": list(e), "r": plate,
"fill": True, "color": "prop"})
return bg, fg
def svg_prims(prims, colors):
lines = []
for p in prims:
color = colors[p["color"]]
if p["kind"] == "line":
d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in p["pts"])
lines.append(f' ')
elif p["kind"] == "poly":
d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in p["pts"]) + " Z"
lines.append(f' ')
elif p["kind"] == "circle":
cx, cy = p["c"]
if p.get("fill"):
lines.append(f' ')
else:
lines.append(f' ')
return lines
def draw_prims(d, prims, colors, scale):
for p in prims:
color = colors[p["color"]]
if p["kind"] in ("line", "poly"):
pts = [(x * scale, y * scale) for x, y in p["pts"]]
if p["kind"] == "poly":
d.polygon(pts, fill=color)
pts = pts + pts[:1]
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)
elif p["kind"] == "circle":
cx, cy = p["c"][0] * scale, p["c"][1] * scale
r = p["r"] * scale
if p.get("fill"):
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=round(p.get("w", 3) * scale))
# ------------------------------------------------------------------- drawing
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' ')
def _lerp_hex(a, b, t):
"""Blend two "#rrggbb" colors; t=0 -> a, t=1 -> b."""
a, b = a.lstrip("#"), b.lstrip("#")
ar, ag, ab = int(a[0:2], 16), int(a[2:4], 16), int(a[4:6], 16)
br, bg, bb = int(b[0:2], 16), int(b[2:4], 16), int(b[4:6], 16)
return "#%02x%02x%02x" % (round(ar + (br - ar) * t),
round(ag + (bg - ag) * t),
round(ab + (bb - ab) * t))
def shade_ink(part, working, colors, t):
"""The ink at nearness `t` (1 = near/dark, 0 = far/light): the plain near/far
grays, or the accent teals when `part` is a working part."""
far, near = ("left_working", "right_working") if part in working else ("left", "right")
return _lerp_hex(colors[far], colors[near], t)
def shade_width(t):
"""Stroke width tracking nearness (far 5 -> near 6)."""
return WIDTHS["far"] + (WIDTHS["near"] - WIDTHS["far"]) * t
def part_style(part, working, colors, nearness):
"""Single-tone style for the spine and its bars — the near ink (or working
teal), at the spine width. (Limbs shade per vertex; see `svg_for_frame`.)"""
t = nearness.get(part, 1.0)
if isinstance(t, list):
t = sum(t) / len(t) if t else 1.0
return shade_ink(part, working, colors, t), WIDTHS["spine"]
def quad_points(p0, ctrl, p2, n=24):
pts = []
for i in range(n + 1):
t = i / n
pts.append(((1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * ctrl[0] + t ** 2 * p2[0],
(1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * ctrl[1] + t ** 2 * p2[1]))
return pts
def svg_for_frame(name, geo, order, shade, working, colors, prims=((), ())):
bg, fg = prims
w, h = CANVAS
zf = geo.get("zoom", 1.0)
defs, parts = [], [floor_svg(geo, colors)]
parts += svg_prims(bg, colors)
for part in order:
if part == "head":
parts += svg_prims(fg, colors)
hx, hy = geo["head"]
parts.append(f' ')
if "nose" in geo:
(sx, sy), (ex, ey) = geo["nose"]
parts.append(f' ')
continue
if part not in geo:
continue
if part == "spine":
color, _ = part_style(part, working, colors, geo["nearness"])
for bar in ("girdle", "pelvisBar"):
bd = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in geo[bar])
parts.append(f' ')
(ax, ay), (cx, cy), (bx, by) = geo["spine"]
parts.append(f' ')
continue
# A limb shades per vertex: each bone segment is its own round-capped path,
# stroked with a linear gradient between its two joints' depth tones so the
# ink flows smoothly along the limb. Adjacent segments share a joint tone,
# so the round caps meet seamlessly.
chain = geo[part]
tones = geo["nearness"].get(part) or [1.0] * len(chain)
parts.append(f' ')
for i in range(len(chain) - 1):
(x0, y0), (x1, y1) = chain[i], chain[i + 1]
c0 = shade_ink(part, working, colors, tones[i])
c1 = shade_ink(part, working, colors, tones[i + 1])
sw = shade_width((tones[i] + tones[i + 1]) / 2) * zf
if c0 == c1 or (abs(x1 - x0) < 0.01 and abs(y1 - y0) < 0.01):
stroke = c0
else:
gid = f"grad-{part}-{i}"
defs.append(f' '
f''
f'')
stroke = f"url(#{gid})"
parts.append(f' ')
parts.append(' ')
lx = w - 96
parts.append(f' ')
parts.append(f' ')
parts.append(f' near')
parts.append(f' ')
parts.append(f' far')
parts.append(' ')
parts.append('')
header = [f'