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:
+173
-18
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user