Rebuild the exercise figure on an anatomical 3D skeleton
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
This commit is contained in:
+251
-226
@@ -1,28 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render Exercise Library visuals from an articulated 2D rig.
|
||||
"""Render Exercise Library visuals from an anatomical 3D 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.
|
||||
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 (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.
|
||||
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
|
||||
@@ -41,21 +44,15 @@ PALETTES = {
|
||||
"equipment": "#c5cad4", "prop": "#6b7180",
|
||||
},
|
||||
}
|
||||
WIDTHS = {"right": 6, "left": 5, "spine": 6, "head": 6, "nose": 4}
|
||||
WIDTHS = {"near": 6, "far": 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"]
|
||||
# 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
|
||||
|
||||
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"]
|
||||
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.
|
||||
@@ -67,8 +64,8 @@ JOINT_LIMB = {
|
||||
}
|
||||
|
||||
|
||||
def bodies():
|
||||
return json.loads((LIB / "body.json").read_text())
|
||||
def profiles():
|
||||
return K.load_skeleton()["profiles"]
|
||||
|
||||
|
||||
def dirv(deg):
|
||||
@@ -77,145 +74,108 @@ def dirv(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."""
|
||||
"""Y-up world angle of the canvas 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()}
|
||||
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 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 _chain_depth(pts):
|
||||
return sum(p[2] for p in pts) / len(pts)
|
||||
|
||||
|
||||
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 _bucket(depth):
|
||||
return round(depth / DEPTH_BUCKET)
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------ 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)]
|
||||
frames += [K.lerp_frames(kf, nxt, ease(s / steps)) for s in range(1, steps)]
|
||||
return frames
|
||||
|
||||
|
||||
@@ -226,9 +186,9 @@ def timeline(norms, fps=20):
|
||||
# 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."""
|
||||
"""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 = []
|
||||
@@ -351,10 +311,13 @@ def draw_prims(d, prims, colors, scale):
|
||||
|
||||
# ------------------------------------------------------------------- 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]
|
||||
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
|
||||
|
||||
|
||||
@@ -367,16 +330,7 @@ def quad_points(p0, ctrl, p2, n=24):
|
||||
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):
|
||||
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">',
|
||||
@@ -384,20 +338,20 @@ def svg_for_frame(name, geo, working, colors, props=None):
|
||||
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:
|
||||
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) = nose_segment(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)
|
||||
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}"
|
||||
@@ -405,18 +359,18 @@ def svg_for_frame(name, geo, working, colors, props=None):
|
||||
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
|
||||
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 + 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(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, working, colors, scale=2, font=None, props=None):
|
||||
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)
|
||||
@@ -433,7 +387,7 @@ def draw_geo(geo, working, colors, scale=2, font=None, props=None):
|
||||
|
||||
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:
|
||||
for part in order:
|
||||
if part == "head":
|
||||
draw_prims(d, fg, colors, scale)
|
||||
hx, hy = geo["head"]
|
||||
@@ -441,21 +395,21 @@ def draw_geo(geo, working, colors, scale=2, font=None, props=None):
|
||||
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"])
|
||||
line(list(geo["nose"]), colors["right"], WIDTHS["nose"])
|
||||
continue
|
||||
if part not in geo:
|
||||
continue
|
||||
color, width = part_style(part, working, colors)
|
||||
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] - 78
|
||||
line([(lx, 16), (lx + 16, 16)], colors["right"], 4)
|
||||
line([(lx + 40, 16), (lx + 56, 16)], colors["left"], 4)
|
||||
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 + 22 * scale, 16 * scale - 11 * scale / 2 - 2), "R",
|
||||
d.text((lx * scale + 19 * scale, 16 * scale - 11 * scale / 2 - 2), "near",
|
||||
fill=colors["legend_text"], font=font)
|
||||
d.text((lx * scale + 62 * scale, 16 * scale - 11 * scale / 2 - 2), "L",
|
||||
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)
|
||||
|
||||
@@ -474,53 +428,68 @@ 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)
|
||||
# A motion may pin its own profile (e.g. "frontal": foreshortened legs for
|
||||
# face-on seated machines); it wins over the CLI default.
|
||||
body = bodies()[motion.get("figure", figure)]
|
||||
working = set(motion.get("working", []))
|
||||
hide = set(motion.get("hide", []))
|
||||
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])
|
||||
norms = [normalize(kf, body, flip) for kf in motion["frames"]]
|
||||
geos = [geometry(n, body, hide, flip) for n in norms]
|
||||
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"], g, working, colors, props) for g in geos]
|
||||
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 = [draw_geo(geometry(n, body, hide, flip), working, colors, font=font, props=props)
|
||||
for n in timeline(norms)]
|
||||
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")
|
||||
@@ -528,43 +497,70 @@ def render_exercise(folder, figure="neutral", flip=False):
|
||||
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):
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
font = legend_font()
|
||||
profiles = bodies()
|
||||
cells = []
|
||||
for folder in folders:
|
||||
motion = load_motion(folder)
|
||||
body = profiles[motion.get("figure", figure)]
|
||||
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)))
|
||||
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 four ways — the doors the rig
|
||||
opens: neutral, female profile, flipped, alternate theme."""
|
||||
"""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", []))
|
||||
kf = motion["frames"][motion.get("primary", 1) - 1]
|
||||
idx = motion.get("primary", 1) - 1
|
||||
font = legend_font()
|
||||
variants = [("neutral", "neutral", False, "default"),
|
||||
("female profile", "female", False, "default"),
|
||||
("flipped", "neutral", True, "default"),
|
||||
("male profile", "male", False, "default"),
|
||||
("flipped camera", "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)
|
||||
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, working, PALETTES[palette], font=font, props=props)))
|
||||
save_sheet(cells, LIB / "demo-sheet.png", cols=2)
|
||||
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):
|
||||
@@ -582,6 +578,30 @@ def save_sheet(cells, path, cols=4):
|
||||
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("--")]
|
||||
@@ -597,8 +617,13 @@ def main():
|
||||
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)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user