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
722 lines
30 KiB
Python
722 lines
30 KiB
Python
#!/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). `--export`
|
|
instead bakes each motion down to the legacy planar schema consumed by the
|
|
in-app SwiftUI renderer. 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}
|
|
|
|
# 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, order_parts = {}, []
|
|
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])
|
|
|
|
# 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)))
|
|
|
|
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
|
|
|
|
|
|
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.
|
|
|
|
def flip_props(props, width):
|
|
"""Mirror the props horizontally, matching the flipped camera. Joint-
|
|
attached props follow the mirrored limbs automatically; only fixed
|
|
coordinates and world angles need mirroring."""
|
|
def fx(p):
|
|
return [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 s["kind"] == "line":
|
|
s["pts"] = [fx(pt) for pt in s["pts"]]
|
|
elif s["kind"] == "circle":
|
|
s["c"] = fx(s["c"])
|
|
elif s["kind"] == "rect":
|
|
s["x"] = width - s["x"] - s["w"]
|
|
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 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):
|
|
"""Props -> drawable primitives for one frame: (background, foreground)."""
|
|
bg, fg = [], []
|
|
for p in props or []:
|
|
t = p["type"]
|
|
if t == "scene":
|
|
for s in p["shapes"]:
|
|
bg.append(dict(s, color=s.get("color", "equipment")))
|
|
elif t == "cable":
|
|
end, _ = joint_points(geo, p["to"])
|
|
if end:
|
|
bg.append({"kind": "line", "pts": [list(p["from"]), 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.
|
|
c, d = joint_points(geo, p["at"])
|
|
if not c:
|
|
continue
|
|
r = p.get("r", 5)
|
|
back = p.get("back", 0)
|
|
side = p.get("side", 1)
|
|
px, py = d[1] * side, -d[0] * side
|
|
center = (c[0] - d[0] * back + px * (r + 3),
|
|
c[1] - d[1] * back + py * (r + 3))
|
|
fg.append({"kind": "circle", "c": list(center), "r": r,
|
|
"fill": True, "color": "prop"})
|
|
elif t in ("bar", "dumbbell", "pad"):
|
|
c, d = joint_points(geo, p["at"])
|
|
if not c:
|
|
continue
|
|
if t == "bar" or "angle" in p:
|
|
ux, uy = dirv(p.get("angle", 0)) # fixed world angle
|
|
else:
|
|
ux, uy = -d[1], d[0] # perpendicular to the lower bone
|
|
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": [a, 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' <path d="{d}" stroke="{color}" stroke-width="{p.get("w", 4)}"'
|
|
f' stroke-linecap="round" stroke-linejoin="round"/>')
|
|
elif p["kind"] == "circle":
|
|
cx, cy = p["c"]
|
|
if p.get("fill"):
|
|
lines.append(f' <circle cx="{cx:.1f}" cy="{cy:.1f}" r="{p["r"]}" fill="{color}"/>')
|
|
else:
|
|
lines.append(f' <circle cx="{cx:.1f}" cy="{cy:.1f}" r="{p["r"]}"'
|
|
f' stroke="{color}" stroke-width="{p.get("w", 3)}"/>')
|
|
elif p["kind"] == "rect":
|
|
lines.append(f' <rect x="{p["x"]}" y="{p["y"]}" width="{p["w"]}" height="{p["h"]}"'
|
|
f' rx="{p.get("r", 2)}" fill="{colors[p.get("color", "equipment")]}"/>')
|
|
return lines
|
|
|
|
|
|
def draw_prims(d, prims, colors, scale):
|
|
for p in prims:
|
|
color = colors[p["color"]]
|
|
if p["kind"] == "line":
|
|
pts = [(x * scale, y * scale) for x, y in p["pts"]]
|
|
w = 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=p.get("w", 3) * scale)
|
|
elif p["kind"] == "rect":
|
|
x, y = p["x"] * scale, p["y"] * scale
|
|
d.rounded_rectangle([x, y, x + p["w"] * scale, y + p["h"] * scale],
|
|
radius=p.get("r", 2) * scale, fill=color)
|
|
|
|
|
|
# ------------------------------------------------------------------- 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' <path d="{d}" fill="none" stroke="{colors["ground"]}"'
|
|
f' stroke-width="3" stroke-linejoin="round"/>')
|
|
|
|
|
|
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."""
|
|
tone = shade.get(part, "near")
|
|
key = "right" if tone == "near" else "left"
|
|
color = colors[f"{key}_working"] if part in working else colors[key]
|
|
width = WIDTHS["spine"] if part == "spine" else WIDTHS[tone]
|
|
return color, width
|
|
|
|
|
|
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, props=None):
|
|
bg, fg = resolve_props(props, geo)
|
|
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_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' <circle id="head" cx="{hx:.1f}" cy="{hy:.1f}" r="{geo["headR"]}"'
|
|
f' fill="{colors["head_fill"]}" stroke="{colors["right"]}" stroke-width="{WIDTHS["head"]}"/>')
|
|
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"/>')
|
|
continue
|
|
if part not in geo:
|
|
continue
|
|
color, width = part_style(part, working, colors, shade)
|
|
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"'
|
|
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}"'
|
|
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"]}">')
|
|
parts.append(f' <line x1="{lx}" y1="16" x2="{lx + 14}" y2="16" stroke="{colors["right"]}" stroke-width="4" stroke-linecap="round"/>')
|
|
parts.append(f' <text x="{lx + 19}" y="20">near</text>')
|
|
parts.append(f' <line x1="{lx + 49}" y1="16" x2="{lx + 63}" y2="16" stroke="{colors["left"]}" stroke-width="4" stroke-linecap="round"/>')
|
|
parts.append(f' <text x="{lx + 68}" y="20">far</text>')
|
|
parts.append(' </g>')
|
|
parts.append('</svg>')
|
|
return "\n".join(parts) + "\n"
|
|
|
|
|
|
def draw_geo(geo, order, shade, working, colors, scale=2, font=None, props=None):
|
|
from PIL import Image, ImageDraw
|
|
|
|
bg, fg = resolve_props(props, geo)
|
|
w, h = CANVAS[0] * scale, CANVAS[1] * scale
|
|
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")
|
|
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)
|
|
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
|
|
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"])
|
|
continue
|
|
if part not in geo:
|
|
continue
|
|
color, width = part_style(part, working, colors, shade)
|
|
if part == "spine":
|
|
line(geo["girdle"], color, 5)
|
|
line(geo["pelvisBar"], color, 5)
|
|
pts = quad_points(*geo["spine"]) if part == "spine" else geo[part]
|
|
line(pts, color, width)
|
|
|
|
lx = CANVAS[0] - 96
|
|
line([(lx, 16), (lx + 14, 16)], colors["right"], 4)
|
|
line([(lx + 49, 16), (lx + 63, 16)], colors["left"], 4)
|
|
if font:
|
|
d.text((lx * scale + 19 * scale, 16 * scale - 11 * scale / 2 - 2), "near",
|
|
fill=colors["legend_text"], font=font)
|
|
d.text((lx * scale + 68 * scale, 16 * scale - 11 * scale / 2 - 2), "far",
|
|
fill=colors["legend_text"], font=font)
|
|
return img.resize(CANVAS, Image.LANCZOS)
|
|
|
|
|
|
def legend_font(scale=2):
|
|
from PIL import ImageFont
|
|
try:
|
|
return ImageFont.load_default(size=11 * scale)
|
|
except TypeError:
|
|
return ImageFont.load_default()
|
|
|
|
|
|
# --------------------------------------------------------------------- main
|
|
|
|
def load_motion(folder):
|
|
return json.loads((folder / "motion.json").read_text())
|
|
|
|
|
|
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."""
|
|
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):
|
|
issues += K.validate_rom(nf, skel["joints"], f"frame {i}: ")
|
|
if issues and strict:
|
|
print(f" {motion['name']}: ROM violations:")
|
|
for msg in issues:
|
|
print(f" {msg}")
|
|
sys.exit(1)
|
|
if issues:
|
|
print(f" {motion['name']}: {len(issues)} ROM warning(s) — run --strict to list")
|
|
props = motion.get("props", [])
|
|
if flip:
|
|
norms = [mirror_frame(nf, CANVAS[0]) for nf in norms]
|
|
props = flip_props(props, CANVAS[0])
|
|
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, 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, mat)
|
|
for limb in hide:
|
|
geo.pop(limb, None)
|
|
return geo, order, shade
|
|
|
|
resolved = []
|
|
key_geos = []
|
|
for nf in norms:
|
|
out, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch, mat)
|
|
for limb in hide:
|
|
geo.pop(limb, None)
|
|
resolved.append(out)
|
|
key_geos.append((geo, order, shade))
|
|
|
|
frames_dir = folder / "frames"
|
|
frames_dir.mkdir(exist_ok=True)
|
|
for old in frames_dir.glob("frame-*.svg"):
|
|
old.unlink()
|
|
colors = PALETTES["default"]
|
|
svgs = [svg_for_frame(motion["name"], geo, order, shade, working, colors, props)
|
|
for geo, order, shade in key_geos]
|
|
for i, svg in enumerate(svgs, start=1):
|
|
(frames_dir / f"frame-{i}.svg").write_text(svg)
|
|
(folder / "visual.svg").write_text(svgs[motion.get("primary", 1) - 1])
|
|
|
|
try:
|
|
font = legend_font()
|
|
imgs = []
|
|
for nf in timeline(resolved):
|
|
geo, order, shade = geometry(nf)
|
|
imgs.append(draw_geo(geo, order, shade, working, colors, font=font, props=props))
|
|
imgs[0].save(folder / "preview.gif", save_all=True, append_images=imgs[1:],
|
|
duration=50, loop=0)
|
|
print(f" {motion['name']}: {len(svgs)} frames, preview.gif")
|
|
except ImportError:
|
|
print(f" {motion['name']}: Pillow missing — SVGs written, preview.gif skipped")
|
|
|
|
|
|
def render_orbit(folder, figure="neutral"):
|
|
"""A full-turn demo: the camera sweeps 360 degrees while the motion loops.
|
|
Scene props are view-locked billboards, so orbit shines on prop-free
|
|
motions (bodyweight exercises)."""
|
|
motion = load_motion(folder)
|
|
working = set(motion.get("working", []))
|
|
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)
|
|
imgs = []
|
|
for i, nf in enumerate(ticks):
|
|
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, pitch=pitch)
|
|
posed["pins"] = {}
|
|
_, 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))
|
|
imgs[0].save(folder / "orbit.gif", save_all=True, append_images=imgs[1:],
|
|
duration=50, loop=0)
|
|
print(f" {motion['name']}: orbit.gif ({len(imgs)} frames)")
|
|
|
|
|
|
def contact_sheet(folders, figure="neutral", out=None):
|
|
font = legend_font()
|
|
cells = []
|
|
for folder in folders:
|
|
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, mat=mat)
|
|
for limb in hide:
|
|
geo.pop(limb, None)
|
|
cells.append((f"{motion['name']} {i}/{len(norms)}",
|
|
draw_geo(geo, order, shade, working, PALETTES["default"],
|
|
font=font, props=props)))
|
|
save_sheet(cells, Path(out) if out else LIB / "contact-sheet.png")
|
|
|
|
|
|
def demo_sheet(folder):
|
|
"""One exercise's primary frame rendered five ways — the doors the rig
|
|
opens: neutral / female / male profiles (same motion script, different
|
|
proportions), the flipped camera, an alternate theme."""
|
|
motion = load_motion(folder)
|
|
working, hide = set(motion.get("working", [])), set(motion.get("hide", []))
|
|
idx = motion.get("primary", 1) - 1
|
|
font = legend_font()
|
|
variants = [("neutral", "neutral", False, "default"),
|
|
("female profile", "female", False, "default"),
|
|
("male profile", "male", False, "default"),
|
|
("flipped camera", "neutral", True, "default"),
|
|
("themed (indigo)", "neutral", False, "indigo")]
|
|
cells = []
|
|
for label, figure, flip, palette in variants:
|
|
norms, prof, cam, pitch, props = prepare(motion, figure, flip)
|
|
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}",
|
|
draw_geo(geo, order, shade, working, PALETTES[palette],
|
|
font=font, props=props)))
|
|
save_sheet(cells, LIB / "demo-sheet.png", cols=3)
|
|
|
|
|
|
def save_sheet(cells, path, cols=4):
|
|
from PIL import Image, ImageDraw
|
|
|
|
rows = (len(cells) + cols - 1) // cols
|
|
cw, ch, cap = CANVAS[0], CANVAS[1], 22
|
|
sheet = Image.new("RGB", (cols * (cw + 8) + 8, rows * (ch + cap + 8) + 8), "white")
|
|
d = ImageDraw.Draw(sheet)
|
|
for i, (label, img) in enumerate(cells):
|
|
x, y = 8 + (i % cols) * (cw + 8), 8 + (i // cols) * (ch + cap + 8)
|
|
sheet.paste(img, (x, y))
|
|
d.text((x + 6, y + ch + 3), label, fill=PALETTES["default"]["right"])
|
|
sheet.save(path)
|
|
print(f" {path.name} ({len(cells)} cells)")
|
|
|
|
|
|
# ------------------------------------------------------------------- export
|
|
|
|
def export_app_resources(folders):
|
|
"""Write the app's bundled copies: skeleton.json plus one
|
|
`<Name>.motion.json` and one `<Name>.info.md` per library entry (unique
|
|
basenames — Xcode copies resources flat). The in-app solver is a port of
|
|
kinematics.py and consumes the same files; figure-fixtures.json in
|
|
WorkoutsTests holds it to this pipeline's geometry."""
|
|
out = LIB.parent / "Workouts" / "Resources" / "ExerciseMotions"
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
(out / "skeleton.json").write_text((LIB / "skeleton.json").read_text())
|
|
(out / "body.json").unlink(missing_ok=True) # the legacy profile file
|
|
for folder in folders:
|
|
motion = load_motion(folder)
|
|
(out / f"{motion['name']}.motion.json").write_text(
|
|
(folder / "motion.json").read_text())
|
|
print(f" exported {motion['name']}.motion.json")
|
|
info = folder / "info.md"
|
|
if info.exists():
|
|
(out / f"{motion['name']}.info.md").write_text(info.read_text())
|
|
print(f" exported {motion['name']}.info.md")
|
|
print(f" exported skeleton.json -> {out}")
|
|
|
|
|
|
def main():
|
|
flags = [a for a in sys.argv[1:] if a.startswith("--")]
|
|
names = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
figure = "neutral"
|
|
sheet = None # False = off, None+flag = default path, str = custom path
|
|
for f in flags:
|
|
if f.startswith("--figure="):
|
|
figure = f.split("=", 1)[1]
|
|
elif f.startswith("--sheet"):
|
|
sheet = f.split("=", 1)[1] if "=" in f else True
|
|
folders = ([LIB / n for n in names] if names
|
|
else sorted(p.parent for p in LIB.glob("*/motion.json")))
|
|
if "--export" in flags:
|
|
export_app_resources(folders)
|
|
return
|
|
if "--orbit" in flags:
|
|
for folder in folders:
|
|
render_orbit(folder, figure=figure)
|
|
return
|
|
for folder in folders:
|
|
render_exercise(folder, figure=figure, flip="--flip" in flags,
|
|
strict="--strict" in flags)
|
|
if sheet:
|
|
contact_sheet(folders, figure=figure, out=None if sheet is True else sheet)
|
|
if "--demo" in flags:
|
|
demo_sheet(folders[0] if names else LIB / "Bird Dog")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|