The library's planar world-angle rig becomes a genuine 3D anatomical model: skeleton.json holds bone-length profiles (real shoulder/pelvis widths, feet, neutral/female/male) and per-joint ROM; motions pose joints with anatomical angles (flexion/abduction/rotation from neutral standing) under a per-exercise orthographic camera, resolved by kinematics.py (3D FK, analytic two-bone IK with anatomical write-back) and validated against physiological ranges. All 20 sagittal motions were migrated by planar decomposition with 0.00 px golden parity against the old renderer — relabeled to true anatomy, since shading is now near-dark/far-light by camera depth rather than by limb suffix — and the face-on machines are re-authored honestly: Abductor/Adductor with real hip abduction (the foreshortened "frontal" profile is retired) and Rotary with genuine spine axial rotation. Figures gain articulated feet; profiles swap without touching a single motion script; --orbit sweeps the camera 360° while a motion loops. The in-app SwiftUI renderer (iOS + watch) is ported to the same model and consumes the exported motions verbatim; figure-fixtures.json pins its geometry to the Python pipeline within 0.5 px across every exercise, key frame, tween, and orbit sample. Also makes the watch bridge logger nonisolated for the newer SDK's stricter isolation checking. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
635 lines
26 KiB
Python
635 lines
26 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
|
|
|
|
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):
|
|
"""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)
|
|
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]]
|
|
work, p = K.resolve(work, prof, cam)
|
|
work["pins"] = dict(nf["pins"]) # keep authored pins; only angles resolved
|
|
|
|
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])
|
|
|
|
# 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 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"]
|
|
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 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 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>',
|
|
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"/>']
|
|
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":
|
|
(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)
|
|
|
|
line([(16, GROUND_Y + 4), (CANVAS[0] - 16, GROUND_Y + 4)], 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)
|
|
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)
|
|
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, 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)
|
|
|
|
def geometry(nf):
|
|
_, geo, order, shade = frame_geometry(nf, prof, cam, flip)
|
|
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)
|
|
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, props = prepare(motion, figure)
|
|
resolved = [frame_geometry(nf, prof, cam)[0] for nf in norms]
|
|
font = legend_font()
|
|
colors = PALETTES["default"]
|
|
ticks = timeline(resolved)
|
|
imgs = []
|
|
for i, nf in enumerate(ticks):
|
|
yaw = cam + 360.0 * i / len(ticks)
|
|
_, geo, order, shade = frame_geometry(nf, prof, yaw)
|
|
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, props = prepare(motion, figure)
|
|
for i, nf in enumerate(norms, start=1):
|
|
_, geo, order, shade = frame_geometry(nf, prof, cam)
|
|
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, props = prepare(motion, figure, flip)
|
|
_, geo, order, shade = frame_geometry(norms[idx], prof, cam, flip)
|
|
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()
|