Author the machine exercise circuit with props and corrected hip machine motions

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
This commit is contained in:
2026-07-06 16:29:31 -04:00
parent 888852cc2e
commit fce8fa4c17
130 changed files with 4116 additions and 82 deletions
+173 -18
View File
@@ -32,11 +32,13 @@ PALETTES = {
"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}
@@ -55,6 +57,8 @@ LIMBS = { # limb -> (attach joint, [bone length keys], pin key)
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())
@@ -153,12 +157,14 @@ def geometry(frame, body, hide=(), flip=False):
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"],
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
@@ -206,6 +212,134 @@ def timeline(norms, fps=20):
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):
@@ -233,20 +367,24 @@ def nose_segment(geo):
return (hx + ux * r, hy + uy * r), (hx + ux * (r + 7), hy + uy * (r + 7))
def svg_for_frame(name, geo, working, colors):
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"]
(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"/>')
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
@@ -269,9 +407,10 @@ def svg_for_frame(name, geo, working, colors):
return "\n".join(parts) + "\n"
def draw_geo(geo, working, colors, scale=2, font=None):
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)
@@ -284,13 +423,16 @@ def draw_geo(geo, working, colors, scale=2, font=None):
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)
line(list(nose_segment(geo)), colors["right"], WIDTHS["nose"])
if "nose" in geo:
line(list(nose_segment(geo)), colors["right"], WIDTHS["nose"])
continue
if part not in geo:
continue
@@ -325,8 +467,9 @@ def load_motion(folder):
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."""
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())
@@ -335,6 +478,10 @@ def export_app_resources(folders):
(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}")
@@ -343,6 +490,9 @@ def render_exercise(folder, figure="neutral", flip=False):
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]
@@ -351,14 +501,14 @@ def render_exercise(folder, figure="neutral", flip=False):
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]
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)
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)
@@ -367,7 +517,7 @@ def render_exercise(folder, figure="neutral", flip=False):
print(f" {motion['name']}: Pillow missing — SVGs written, preview.gif skipped")
def contact_sheet(folders, figure="neutral"):
def contact_sheet(folders, figure="neutral", out=None):
from PIL import Image, ImageDraw
font = legend_font()
@@ -376,11 +526,12 @@ def contact_sheet(folders, figure="neutral"):
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)))
save_sheet(cells, LIB / "contact-sheet.png")
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):
@@ -397,9 +548,10 @@ def demo_sheet(folder):
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)))
draw_geo(geo, working, PALETTES[palette], font=font, props=props)))
save_sheet(cells, LIB / "demo-sheet.png", cols=2)
@@ -422,9 +574,12 @@ 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:
@@ -432,8 +587,8 @@ def main():
return
for folder in folders:
render_exercise(folder, figure=figure, flip="--flip" in flags)
if "--sheet" in flags:
contact_sheet(folders, figure=figure)
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")