Fourteen Planet Fitness machine exercises join the rig library, each with authored motion, info page, and schematic equipment via the new props layer (scene shapes, cables, bars, pads) rendered in lockstep by render.py and the in-app figure renderer. Abductor and Adductor are authored face-on with the real seated machine motion — knees-bent legs swinging apart/together against knee pads — replacing the earlier middle-split depiction. The watch target now bundles the figure renderer and motion rigs too. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
598 lines
24 KiB
Python
598 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""Render Exercise Library visuals from an articulated 2D rig.
|
|
|
|
A body profile (body.json: bone lengths) plus a per-exercise motion script
|
|
(motion.json: key frames of absolute joint angles, root position, optional
|
|
IK pins, timing) resolve through forward kinematics into stick-figure frames.
|
|
See SYSTEM.md for the format and the visual language.
|
|
|
|
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 (figure
|
|
profile, flip, theme). `--export` instead copies body.json and each entry's
|
|
motion.json (as `<Name>.motion.json`) into Workouts/Resources/ExerciseMotions
|
|
for the in-app SwiftUI renderer. SVGs need no dependencies; GIFs/sheets need
|
|
Pillow.
|
|
|
|
Angles are absolute world angles in degrees, y-up: 0 = toward +x (right),
|
|
90 = straight up, 180 = toward -x (left), -90 = straight down.
|
|
"""
|
|
|
|
import json
|
|
import math
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
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 = {"right": 6, "left": 5, "spine": 6, "head": 6, "nose": 4}
|
|
|
|
# Draw order: left limbs behind, spine, right limbs, then the head on top —
|
|
# the head is filled opaque so limbs passing it (arms overhead beside the
|
|
# ears) are occluded instead of crossing through the face.
|
|
PART_ORDER = ["arm_l", "leg_l", "spine", "arm_r", "leg_r", "head"]
|
|
|
|
LIMBS = { # limb -> (attach joint, [bone length keys], pin key)
|
|
"arm_r": ("shoulder_r", ["upperArm", "foreArm"], "hand_r"),
|
|
"arm_l": ("shoulder_l", ["upperArm", "foreArm"], "hand_l"),
|
|
"leg_r": ("hip_r", ["thigh", "shin"], "foot_r"),
|
|
"leg_l": ("hip_l", ["thigh", "shin"], "foot_l"),
|
|
}
|
|
|
|
ANGLE_KEYS = ["spine", "neck", "gaze", "arm_r", "arm_l", "leg_r", "leg_l"]
|
|
|
|
JOINT_LIMB = {"hand_r": "arm_r", "hand_l": "arm_l", "foot_r": "leg_r", "foot_l": "leg_l"}
|
|
|
|
|
|
def bodies():
|
|
return json.loads((LIB / "body.json").read_text())
|
|
|
|
|
|
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 walk(start, angles, lengths):
|
|
"""Chain FK: [start, joint1, joint2, ...] for per-bone absolute angles."""
|
|
pts = [start]
|
|
x, y = start
|
|
for a, ln in zip(angles, lengths):
|
|
dx, dy = dirv(a)
|
|
x, y = x + dx * ln, y + dy * ln
|
|
pts.append((x, y))
|
|
return pts
|
|
|
|
|
|
def angle_of(a, b):
|
|
"""Y-up world angle of the segment a→b."""
|
|
return math.degrees(math.atan2(-(b[1] - a[1]), b[0] - a[0]))
|
|
|
|
|
|
def ik2(start, target, a, b, guess_angles):
|
|
"""Analytic 2-bone IK: angles [upper, lower] reaching from start toward
|
|
target, choosing the elbow solution nearest the authored guess."""
|
|
dx, dy_up = target[0] - start[0], -(target[1] - start[1])
|
|
d = max(abs(a - b) + 0.5, min(a + b - 0.01, math.hypot(dx, dy_up)))
|
|
base = math.degrees(math.atan2(dy_up, dx))
|
|
alpha = math.degrees(math.acos((a * a + d * d - b * b) / (2 * a * d)))
|
|
guess_elbow = walk(start, guess_angles, [a, b])[1]
|
|
best = None
|
|
for sign in (1, -1):
|
|
upper = base + sign * alpha
|
|
elbow = walk(start, [upper], [a])[1]
|
|
dist = math.dist(elbow, guess_elbow)
|
|
if best is None or dist < best[0]:
|
|
best = (dist, [upper, angle_of(elbow, target)])
|
|
return best[1]
|
|
|
|
|
|
# ------------------------------------------------------------------- solver
|
|
|
|
def flip_frame(kf, width):
|
|
"""Mirror a key frame horizontally: x -> width - x, angle -> 180 - angle."""
|
|
out = dict(kf)
|
|
out["root"] = [width - kf["root"][0], kf["root"][1]]
|
|
for key in ANGLE_KEYS:
|
|
if key not in kf:
|
|
continue
|
|
v = kf[key]
|
|
out[key] = [180 - a for a in v] if isinstance(v, list) else 180 - v
|
|
if "pins" in kf:
|
|
out["pins"] = {k: [width - x, y] for k, (x, y) in kf["pins"].items()}
|
|
return out
|
|
|
|
|
|
def normalize(kf, body, flip=False):
|
|
"""Resolve a key frame to pure angles: apply the mirror, then replace each
|
|
pinned limb's authored angles with its IK solution. Normalized frames
|
|
interpolate cleanly (angle space), and geometry() re-pins tween frames."""
|
|
if flip:
|
|
kf = flip_frame(kf, CANVAS[0])
|
|
out = {"root": list(kf["root"]), "pins": dict(kf.get("pins", {})),
|
|
"hold": kf.get("hold", 0.5), "tween": kf.get("tween", 0.6)}
|
|
for key in ANGLE_KEYS:
|
|
if key in kf:
|
|
out[key] = list(kf[key]) if isinstance(kf[key], list) else kf[key]
|
|
attach = attach_points(out, body, flip)
|
|
for limb, (joint, bone_keys, pin) in LIMBS.items():
|
|
if limb in out and pin in out["pins"]:
|
|
lengths = [body[k] for k in bone_keys]
|
|
out[limb] = ik2(attach[joint], out["pins"][pin], *lengths, out[limb])
|
|
return out
|
|
|
|
|
|
def attach_points(frame, body, flip=False):
|
|
"""FK for the trunk: pelvis, spine mid, neck joint, and limb attachments."""
|
|
pelvis = tuple(frame["root"])
|
|
mid = walk(pelvis, [frame["spine"][0]], [body["spine1"]])[1]
|
|
neck = walk(mid, [frame["spine"][1]], [body["spine2"]])[1]
|
|
ox, oy = body.get("leftOffset", [6, 2])
|
|
if flip:
|
|
ox = -ox
|
|
return {"pelvis": pelvis, "mid": mid, "neck": neck,
|
|
"shoulder_r": neck, "shoulder_l": (neck[0] + ox, neck[1] + oy),
|
|
"hip_r": pelvis, "hip_l": (pelvis[0] + ox, pelvis[1] + oy)}
|
|
|
|
|
|
def geometry(frame, body, hide=(), flip=False):
|
|
"""Normalized frame -> drawable points. Limbs with an active pin are
|
|
re-solved so planted hands/feet hold exactly through tweens."""
|
|
at = attach_points(frame, body, flip)
|
|
head = walk(at["neck"], [frame["neck"]], [body["neck"]])[1]
|
|
geo = {"head": head, "headR": body["headR"],
|
|
"spine": [at["pelvis"],
|
|
(2 * at["mid"][0] - (at["pelvis"][0] + at["neck"][0]) / 2,
|
|
2 * at["mid"][1] - (at["pelvis"][1] + at["neck"][1]) / 2),
|
|
at["neck"]]}
|
|
# `gaze` is optional: a frame without it faces the viewer, so no nose tick.
|
|
if "gaze" in frame:
|
|
geo["nose"] = walk(head, [frame["gaze"]], [body["headR"] + 7])[1]
|
|
for limb, (joint, bone_keys, pin) in LIMBS.items():
|
|
if limb not in frame or limb in hide:
|
|
continue
|
|
lengths = [body[k] for k in bone_keys]
|
|
angles = frame[limb]
|
|
if pin in frame.get("pins", {}):
|
|
angles = ik2(at[joint], frame["pins"][pin], *lengths, angles)
|
|
geo[limb] = walk(at[joint], angles, lengths)
|
|
return geo
|
|
|
|
|
|
# ------------------------------------------------------------------ tweening
|
|
|
|
def ease(t):
|
|
return 3 * t * t - 2 * t * t * t
|
|
|
|
|
|
def lerp_angle(a, b, t):
|
|
return a + ((b - a + 180) % 360 - 180) * t
|
|
|
|
|
|
def lerp_norm(a, b, t):
|
|
out = {"root": [a["root"][0] + (b["root"][0] - a["root"][0]) * t,
|
|
a["root"][1] + (b["root"][1] - a["root"][1]) * t]}
|
|
for key in ANGLE_KEYS:
|
|
if key not in a or key not in b:
|
|
continue
|
|
va, vb = a[key], b[key]
|
|
out[key] = ([lerp_angle(x, y, t) for x, y in zip(va, vb)]
|
|
if isinstance(va, list) else lerp_angle(va, vb, t))
|
|
# A pin survives a tween only if planted in both neighboring key frames.
|
|
out["pins"] = {k: [a["pins"][k][0] + (b["pins"][k][0] - a["pins"][k][0]) * t,
|
|
a["pins"][k][1] + (b["pins"][k][1] - a["pins"][k][1]) * t]
|
|
for k in a["pins"] if k in b["pins"]}
|
|
return out
|
|
|
|
|
|
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 += [lerp_norm(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 flip_frame. 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"` or a `["hand_r", "hand_l"]` midpoint —
|
|
to (point, lower-bone unit direction). None when the limb isn't drawn."""
|
|
names = ref if isinstance(ref, list) else [ref]
|
|
pts, direction = [], None
|
|
for name in names:
|
|
limb = geo.get(JOINT_LIMB[name])
|
|
if not limb:
|
|
return None, None
|
|
pts.append(limb[-1])
|
|
if direction is None:
|
|
a, b = limb[-2], limb[-1]
|
|
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):
|
|
side = "left" if part.endswith("_l") else "right"
|
|
color = colors[f"{side}_working"] if part in working else colors[side]
|
|
width = WIDTHS["spine"] if part == "spine" else WIDTHS[side]
|
|
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 nose_segment(geo):
|
|
hx, hy = geo["head"]
|
|
nx, ny = geo["nose"]
|
|
d = math.hypot(nx - hx, ny - hy) or 1.0
|
|
ux, uy = (nx - hx) / d, (ny - hy) / d
|
|
r = geo["headR"]
|
|
return (hx + ux * r, hy + uy * r), (hx + ux * (r + 7), hy + uy * (r + 7))
|
|
|
|
|
|
def svg_for_frame(name, geo, 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 PART_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) = nose_segment(geo)
|
|
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)
|
|
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 - 78
|
|
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 + 16}" y2="16" stroke="{colors["right"]}" stroke-width="4" stroke-linecap="round"/>')
|
|
parts.append(f' <text x="{lx + 22}" y="20">R</text>')
|
|
parts.append(f' <line x1="{lx + 40}" y1="16" x2="{lx + 56}" y2="16" stroke="{colors["left"]}" stroke-width="4" stroke-linecap="round"/>')
|
|
parts.append(f' <text x="{lx + 62}" y="20">L</text>')
|
|
parts.append(' </g>')
|
|
parts.append('</svg>')
|
|
return "\n".join(parts) + "\n"
|
|
|
|
|
|
def draw_geo(geo, 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 PART_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(nose_segment(geo)), colors["right"], WIDTHS["nose"])
|
|
continue
|
|
if part not in geo:
|
|
continue
|
|
color, width = part_style(part, working, colors)
|
|
pts = quad_points(*geo["spine"]) if part == "spine" else geo[part]
|
|
line(pts, color, width)
|
|
|
|
lx = CANVAS[0] - 78
|
|
line([(lx, 16), (lx + 16, 16)], colors["right"], 4)
|
|
line([(lx + 40, 16), (lx + 56, 16)], colors["left"], 4)
|
|
if font:
|
|
d.text((lx * scale + 22 * scale, 16 * scale - 11 * scale / 2 - 2), "R",
|
|
fill=colors["legend_text"], font=font)
|
|
d.text((lx * scale + 62 * scale, 16 * scale - 11 * scale / 2 - 2), "L",
|
|
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 export_app_resources(folders):
|
|
"""Write the app's bundled copies: body.json plus one `<Name>.motion.json`
|
|
and one `<Name>.info.md` per library entry. Unique basenames — Xcode copies
|
|
resources flat, so the per-entry motion.json/info.md files can't ship under
|
|
their own folder names."""
|
|
out = LIB.parent / "Workouts" / "Resources" / "ExerciseMotions"
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
(out / "body.json").write_text((LIB / "body.json").read_text())
|
|
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 body.json -> {out}")
|
|
|
|
|
|
def render_exercise(folder, figure="neutral", flip=False):
|
|
motion = load_motion(folder)
|
|
body = bodies()[figure]
|
|
working = set(motion.get("working", []))
|
|
hide = set(motion.get("hide", []))
|
|
props = motion.get("props", [])
|
|
if flip:
|
|
props = flip_props(props, CANVAS[0])
|
|
norms = [normalize(kf, body, flip) for kf in motion["frames"]]
|
|
geos = [geometry(n, body, hide, flip) for n in norms]
|
|
|
|
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"], g, working, colors, props) for g in 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 = [draw_geo(geometry(n, body, hide, flip), working, colors, font=font, props=props)
|
|
for n in timeline(norms)]
|
|
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 contact_sheet(folders, figure="neutral", out=None):
|
|
from PIL import Image, ImageDraw
|
|
|
|
font = legend_font()
|
|
body = bodies()[figure]
|
|
cells = []
|
|
for folder in folders:
|
|
motion = load_motion(folder)
|
|
working, hide = set(motion.get("working", [])), set(motion.get("hide", []))
|
|
props = motion.get("props", [])
|
|
for i, kf in enumerate(motion["frames"], start=1):
|
|
geo = geometry(normalize(kf, body), body, hide)
|
|
cells.append((f"{motion['name']} {i}/{len(motion['frames'])}",
|
|
draw_geo(geo, 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 four ways — the doors the rig
|
|
opens: neutral, female profile, flipped, alternate theme."""
|
|
motion = load_motion(folder)
|
|
working, hide = set(motion.get("working", [])), set(motion.get("hide", []))
|
|
kf = motion["frames"][motion.get("primary", 1) - 1]
|
|
font = legend_font()
|
|
variants = [("neutral", "neutral", False, "default"),
|
|
("female profile", "female", False, "default"),
|
|
("flipped", "neutral", True, "default"),
|
|
("themed (indigo)", "neutral", False, "indigo")]
|
|
cells = []
|
|
for label, figure, flip, palette in variants:
|
|
body = bodies()[figure]
|
|
props = flip_props(motion.get("props", []), CANVAS[0]) if flip else motion.get("props", [])
|
|
geo = geometry(normalize(kf, body, flip), body, hide, flip)
|
|
cells.append((f"{motion['name']} — {label}",
|
|
draw_geo(geo, working, PALETTES[palette], font=font, props=props)))
|
|
save_sheet(cells, LIB / "demo-sheet.png", cols=2)
|
|
|
|
|
|
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)")
|
|
|
|
|
|
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
|
|
for folder in folders:
|
|
render_exercise(folder, figure=figure, flip="--flip" 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()
|