Exercise Library/ holds per-exercise reference docs (setup, cues, mistakes, progressions) with SVG visuals and a Python-rendered motion pipeline; Workouts/ExerciseFigure renders the bundled *.motion.json rigs as animated stick figures on the exercise screen. Exercises gain a warm-up/main-circuit category, timed exercises display hold time via planSummary, and a completed exercise reopens to a check screen instead of its timers.
443 lines
18 KiB
Python
443 lines
18 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",
|
|
},
|
|
"indigo": {
|
|
"right": "#3a3f4b", "left": "#a9afba",
|
|
"right_working": "#4f46e5", "left_working": "#a5b4fc",
|
|
"ground": "#b9bec9", "legend_text": "#6b7180", "head_fill": "white",
|
|
},
|
|
}
|
|
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"]
|
|
|
|
|
|
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]
|
|
nose_tip = walk(head, [frame["gaze"]], [body["headR"] + 7])[1]
|
|
geo = {"head": head, "nose": nose_tip, "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"]]}
|
|
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
|
|
|
|
|
|
# ------------------------------------------------------------------- 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):
|
|
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"/>']
|
|
for part in PART_ORDER:
|
|
if part == "head":
|
|
hx, hy = geo["head"]
|
|
(sx, sy), (ex, ey) = nose_segment(geo)
|
|
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"]}"/>')
|
|
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):
|
|
from PIL import Image, ImageDraw
|
|
|
|
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)
|
|
for part in PART_ORDER:
|
|
if part == "head":
|
|
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)
|
|
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`
|
|
per library entry. Unique basenames — Xcode copies resources flat, so the
|
|
per-entry motion.json 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")
|
|
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", []))
|
|
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) 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)
|
|
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"):
|
|
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", []))
|
|
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)))
|
|
save_sheet(cells, 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]
|
|
geo = geometry(normalize(kf, body, flip), body, hide, flip)
|
|
cells.append((f"{motion['name']} — {label}",
|
|
draw_geo(geo, working, PALETTES[palette], font=font)))
|
|
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"
|
|
for f in flags:
|
|
if f.startswith("--figure="):
|
|
figure = f.split("=", 1)[1]
|
|
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" in flags:
|
|
contact_sheet(folders, figure=figure)
|
|
if "--demo" in flags:
|
|
demo_sheet(folders[0] if names else LIB / "Bird Dog")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|