Scene shapes, cable anchors, bar angles, pad perpendiculars, and roller offsets all resolve in the authored view exactly as before, then rotate about the world-vertical axis through the root anchor - the same resolve-then-rotate pattern as the figure's pins and the mat - so at the authored yaw every exercise renders bit-identically to today, and under an orbiting camera the equipment turns with the figure while staying welded to its hands and feet. Scene lines gain an optional depth plane (z) and slab extrusion (depth) so seats, backrests, and platforms keep form edge-on; the rect shape is retired (re-authored as slab lines). All 14 machines' props re-authored with depths and verified at eight orbit angles. The fixture snapshots move into the pipeline as render.py --fixtures and now cover orbit-presentation samples with resolved prop primitives for a spread of prop flavors; the in-app renderer resolves props in MotionSolver (lockstep with resolve_props) and the view just draws primitives. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
858 lines
38 KiB
Python
858 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
"""Render Exercise Library visuals from an anatomical 3D rig.
|
|
|
|
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 (body
|
|
profiles, flipped camera, theme); `--orbit` writes orbit.gif per named
|
|
exercise (the camera sweeps 360 degrees while the motion loops — props
|
|
rotate with the figure). `--export` copies the app's bundled resources
|
|
verbatim; `--fixtures` regenerates the projected-geometry snapshots pinning
|
|
the in-app Swift solver to this pipeline. 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
|
|
# The default viewpoint is slightly elevated: the camera pitches down a
|
|
# touch, so the floor reads as a plane (drawn as a rectangle) instead of a
|
|
# line. Motions can override via "camera": {"pitch": ...}.
|
|
CAMERA_PITCH = 10.0
|
|
FLOOR_HALF_DEPTH = 30.0
|
|
|
|
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 = {"near": 6, "far": 5, "spine": 6, "head": 6, "nose": 4}
|
|
|
|
# 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
|
|
|
|
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.
|
|
JOINT_LIMB = {
|
|
"hand_r": ("arm_r", 2), "elbow_r": ("arm_r", 1),
|
|
"hand_l": ("arm_l", 2), "elbow_l": ("arm_l", 1),
|
|
"foot_r": ("leg_r", 2), "knee_r": ("leg_r", 1),
|
|
"foot_l": ("leg_l", 2), "knee_l": ("leg_l", 1),
|
|
}
|
|
|
|
|
|
def profiles():
|
|
return K.load_skeleton()["profiles"]
|
|
|
|
|
|
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 angle_of(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]))
|
|
|
|
|
|
# ------------------------------------------------------------------- solver
|
|
|
|
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 _chain_depth(pts):
|
|
return sum(p[2] for p in pts) / len(pts)
|
|
|
|
|
|
def _bucket(depth):
|
|
return round(depth / DEPTH_BUCKET)
|
|
|
|
|
|
def frame_geometry(nf, prof, cam, flipped=False, pitch=CAMERA_PITCH, mat=None):
|
|
"""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, pitch)
|
|
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]]
|
|
# Pins are canvas targets in the authored, unpitched view: solve IK flat,
|
|
# then tilt the *posed* body - the camera elevation is pure presentation,
|
|
# so contacts straddle the floor plane instead of pins going out of reach.
|
|
work, _ = K.resolve(work, prof, cam, 0.0)
|
|
work["pins"] = dict(nf["pins"]) # keep authored pins; only angles resolved
|
|
p = K.pose(work, prof, cam, pitch)
|
|
|
|
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])
|
|
|
|
# Shoulder girdle and pelvis, drawn with the spine so the limbs visibly hang
|
|
# from real width. Endpoints use each side's drawn attach (far offset included)
|
|
# so the bars meet the limbs exactly.
|
|
def attach(key, limb):
|
|
return scr(pts[key], off if shade[limb] == "far" else (0, 0))
|
|
|
|
geo["girdle"] = [attach("shoulder_l", "arm_l"), neck_b, attach("shoulder_r", "arm_r")]
|
|
geo["pelvisBar"] = [attach("hip_l", "leg_l"), pelvis, attach("hip_r", "leg_r")]
|
|
|
|
# The exercise mat: a world-space quad on the ground plane, rotating with
|
|
# the camera about the figure's vertical axis (`mat` = screen-x bounds of
|
|
# the motion's footprint in the authored view).
|
|
if mat is not None:
|
|
yg = -(GROUND_Y + 4 - anchor[1])
|
|
rc = K.mmul(K.rot_x(pitch), K.rot_y(-cam))
|
|
geo["floor"] = [scr(K.mvec(rc, (dx, yg, dz))[:2] + (0,))
|
|
for dx, dz in ((mat[0] - anchor[0], FLOOR_HALF_DEPTH),
|
|
(mat[1] - anchor[0], FLOOR_HALF_DEPTH),
|
|
(mat[1] - anchor[0], -FLOOR_HALF_DEPTH),
|
|
(mat[0] - anchor[0], -FLOOR_HALF_DEPTH))]
|
|
|
|
# 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
|
|
|
|
|
|
def mat_bounds(norms, prof, cam, pitch=CAMERA_PITCH):
|
|
"""The motion's footprint in the authored view: min/max screen x of every
|
|
figure point across the key frames, padded - the exercise mat spans it."""
|
|
lo, hi = float("inf"), float("-inf")
|
|
for nf in norms:
|
|
_, geo, _, _ = frame_geometry(nf, prof, cam, pitch=pitch)
|
|
xs = [geo["head"][0] - geo["headR"], geo["head"][0] + geo["headR"]]
|
|
for part in ("arm_r", "arm_l", "leg_r", "leg_l", "spine", "girdle", "pelvisBar"):
|
|
xs += [pt[0] for pt in geo.get(part, [])]
|
|
lo, hi = min(lo, min(xs)), max(hi, max(xs))
|
|
return lo - 12, hi + 12
|
|
|
|
|
|
def ease(t):
|
|
return 3 * t * t - 2 * t * t * t
|
|
|
|
|
|
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 += [K.lerp_frames(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.
|
|
#
|
|
# Props have world-space 3D form: everything is authored (and resolved) in the
|
|
# authored view — scene points and cable anchors carry an optional depth `z`
|
|
# (+ toward the camera), a scene line an optional `depth` extrusion half-width
|
|
# that makes it a slab — then rotated about the world-vertical axis through
|
|
# the root anchor for the orbiting presentation, exactly like the figure and
|
|
# the mat. Positions that follow the figure (joint anchors) come from the
|
|
# *rotated* geometry; fixed points, directions, and offsets (scene shapes,
|
|
# cable anchors, bar angles, pad perpendiculars, roller offsets) resolve in
|
|
# the *authored* view and rotate — never recompute them in the rotated view.
|
|
|
|
def flip_props(props, width):
|
|
"""Mirror the props horizontally, matching the flipped camera (which views
|
|
from the opposite side, so authored depths flip sign too). Joint-attached
|
|
props follow the mirrored limbs automatically; only fixed coordinates,
|
|
depths, and world angles need mirroring."""
|
|
def fx(p):
|
|
return [width - p[0], p[1], -p[2]] if len(p) > 2 else [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 "z" in s:
|
|
s["z"] = -s["z"]
|
|
if s["kind"] == "line":
|
|
s["pts"] = [fx(pt) for pt in s["pts"]]
|
|
elif s["kind"] == "circle":
|
|
s["c"] = fx(s["c"])
|
|
elif p["type"] == "cable":
|
|
p["from"] = fx(p["from"])
|
|
elif p["type"] in ("bar", "pad") and "angle" in p:
|
|
p["angle"] = 180 - p["angle"]
|
|
elif p["type"] == "roller":
|
|
p["side"] = -p.get("side", 1) # mirroring flips the perpendicular's handedness
|
|
out.append(p)
|
|
return out
|
|
|
|
|
|
def prop_rotation(pitch, yaw_offset):
|
|
"""View-space rotation carrying authored-view prop geometry to the camera
|
|
`yaw_offset` degrees past the authored yaw: a rotation about the world-
|
|
vertical axis through the root anchor, conjugated by the camera elevation.
|
|
The identity at offset 0, so the authored view stays bit-exact."""
|
|
if yaw_offset == 0:
|
|
return K.IDENTITY
|
|
return K.chain(K.rot_x(pitch), K.rot_y(-yaw_offset), K.rot_x(-pitch))
|
|
|
|
|
|
def joint_points(geo, ref):
|
|
"""Resolve a joint ref — `"hand_r"`, `"knee_l"`, or a midpoint list like
|
|
`["knee_r", "foot_r"]` — to (point, unit direction of the bone ending at
|
|
the first joint). None when the limb isn't drawn."""
|
|
names = ref if isinstance(ref, list) else [ref]
|
|
pts, direction = [], None
|
|
for name in names:
|
|
limb_name, idx = JOINT_LIMB[name]
|
|
limb = geo.get(limb_name)
|
|
if not limb:
|
|
return None, None
|
|
pts.append(limb[idx])
|
|
if direction is None:
|
|
a, b = limb[idx - 1], limb[idx]
|
|
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, anchor, rot=K.IDENTITY, auth_geo=None):
|
|
"""Props -> drawable primitives for one frame: (background, foreground).
|
|
|
|
`geo` is the frame's drawn (possibly orbit-rotated) geometry, `auth_geo`
|
|
the same frame at the authored camera (defaults to `geo`), `anchor` the
|
|
frame's root canvas anchor, and `rot` the `prop_rotation` between them.
|
|
Joint positions come from `geo`; everything authored — scene points,
|
|
cable anchors, bar angles, pad perpendiculars, roller offsets — resolves
|
|
against `auth_geo` and rotates through `rot`.
|
|
"""
|
|
auth = auth_geo if auth_geo is not None else geo
|
|
|
|
def place(pt, z=0.0):
|
|
"""Authored canvas point (+ depth toward the camera) -> drawn canvas."""
|
|
v = K.mvec(rot, (pt[0] - anchor[0], anchor[1] - pt[1], z))
|
|
return (anchor[0] + v[0], anchor[1] - v[1])
|
|
|
|
def swing(vec, z=0.0):
|
|
"""Authored canvas-space direction/offset -> drawn canvas (y-down)."""
|
|
v = K.mvec(rot, (vec[0], -vec[1], z))
|
|
return (v[0], -v[1])
|
|
|
|
bg, fg = [], []
|
|
for p in props or []:
|
|
t = p["type"]
|
|
if t == "scene":
|
|
for s in p["shapes"]:
|
|
color = s.get("color", "equipment")
|
|
z = s.get("z", 0.0)
|
|
if s["kind"] == "line":
|
|
depth = s.get("depth", 0.0)
|
|
pts = [(pt[0], pt[1], z + (pt[2] if len(pt) > 2 else 0.0))
|
|
for pt in s["pts"]]
|
|
if depth:
|
|
# An extruded slab: the polyline swept through +/-depth,
|
|
# filled and outlined so it degenerates to the plain
|
|
# line whenever the sweep collapses edge-on.
|
|
quad = ([place(pt, pt[2] + depth) for pt in pts]
|
|
+ [place(pt, pt[2] - depth) for pt in reversed(pts)])
|
|
bg.append({"kind": "poly", "pts": [list(q) for q in quad],
|
|
"w": s.get("w", 4), "color": color})
|
|
else:
|
|
bg.append({"kind": "line",
|
|
"pts": [list(place(pt, pt[2])) for pt in pts],
|
|
"w": s.get("w", 4), "color": color})
|
|
elif s["kind"] == "circle":
|
|
bg.append({"kind": "circle", "c": list(place(s["c"], z)),
|
|
"r": s["r"], "fill": s.get("fill", False),
|
|
"w": s.get("w", 3), "color": color})
|
|
elif t == "cable":
|
|
end, _ = joint_points(geo, p["to"])
|
|
if end:
|
|
start = place(p["from"], p["from"][2] if len(p["from"]) > 2 else 0.0)
|
|
bg.append({"kind": "line", "pts": [list(start), list(end)],
|
|
"w": p.get("w", 2), "color": "equipment"})
|
|
elif t == "roller":
|
|
# A machine roller pad seen end-on: a disc riding the limb's lower
|
|
# bone near the joint, on the `side` (+1/-1) of the bone it presses.
|
|
# The offset resolves along the authored-view bone, then rotates.
|
|
c, _ = joint_points(geo, p["at"])
|
|
_, d = joint_points(auth, p["at"])
|
|
if not c or not d:
|
|
continue
|
|
r = p.get("r", 5)
|
|
back = p.get("back", 0)
|
|
side = p.get("side", 1)
|
|
px, py = d[1] * side, -d[0] * side
|
|
off = swing((-d[0] * back + px * (r + 3), -d[1] * back + py * (r + 3)))
|
|
fg.append({"kind": "circle", "c": [c[0] + off[0], c[1] + off[1]], "r": r,
|
|
"fill": True, "color": "prop"})
|
|
elif t in ("bar", "dumbbell", "pad"):
|
|
c, _ = joint_points(geo, p["at"])
|
|
_, d = joint_points(auth, p["at"])
|
|
if not c or not d:
|
|
continue
|
|
if t == "bar" or "angle" in p:
|
|
axis = dirv(p.get("angle", 0)) # fixed authored-view angle
|
|
else:
|
|
axis = (-d[1], d[0]) # perpendicular to the lower bone
|
|
ux, uy = swing(axis) # foreshortens under orbit
|
|
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": [list(a), list(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"] == "poly":
|
|
d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in p["pts"]) + " Z"
|
|
lines.append(f' <path d="{d}" fill="{color}" stroke="{color}"'
|
|
f' 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)}"/>')
|
|
return lines
|
|
|
|
|
|
def draw_prims(d, prims, colors, scale):
|
|
for p in prims:
|
|
color = colors[p["color"]]
|
|
if p["kind"] in ("line", "poly"):
|
|
pts = [(x * scale, y * scale) for x, y in p["pts"]]
|
|
if p["kind"] == "poly":
|
|
d.polygon(pts, fill=color)
|
|
pts = pts + pts[:1]
|
|
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)
|
|
|
|
|
|
# ------------------------------------------------------------------- drawing
|
|
|
|
def floor_svg(geo, colors):
|
|
"""The exercise mat: a world-space quad on the ground plane, sized to the
|
|
motion's footprint and rotating with the camera."""
|
|
quad = geo.get("floor")
|
|
if not quad:
|
|
return ""
|
|
d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in quad) + " Z"
|
|
return (f' <path d="{d}" fill="none" stroke="{colors["ground"]}"'
|
|
f' stroke-width="3" stroke-linejoin="round"/>')
|
|
|
|
|
|
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
|
|
|
|
|
|
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 svg_for_frame(name, geo, order, shade, working, colors, prims=((), ())):
|
|
bg, fg = prims
|
|
w, h = CANVAS
|
|
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" fill="none">',
|
|
f' <title>{name}</title>',
|
|
floor_svg(geo, colors)]
|
|
parts += svg_prims(bg, colors)
|
|
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) = 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, shade)
|
|
if part == "spine":
|
|
for bar in ("girdle", "pelvisBar"):
|
|
bd = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in geo[bar])
|
|
parts.append(f' <path id="{bar}" d="{bd}" stroke="{color}" stroke-width="5"'
|
|
f' stroke-linecap="round" stroke-linejoin="round"/>')
|
|
(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 - 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 + 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, order, shade, working, colors, scale=2, font=None, prims=((), ())):
|
|
from PIL import Image, ImageDraw
|
|
|
|
bg, fg = prims
|
|
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)
|
|
|
|
if geo.get("floor"):
|
|
line(geo["floor"] + geo["floor"][:1], colors["ground"], 3)
|
|
draw_prims(d, bg, colors, scale)
|
|
for part in 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(geo["nose"]), colors["right"], WIDTHS["nose"])
|
|
continue
|
|
if part not in geo:
|
|
continue
|
|
color, width = part_style(part, working, colors, shade)
|
|
if part == "spine":
|
|
line(geo["girdle"], color, 5)
|
|
line(geo["pelvisBar"], color, 5)
|
|
pts = quad_points(*geo["spine"]) if part == "spine" else geo[part]
|
|
line(pts, color, width)
|
|
|
|
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 + 19 * scale, 16 * scale - 11 * scale / 2 - 2), "near",
|
|
fill=colors["legend_text"], font=font)
|
|
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)
|
|
|
|
|
|
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 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)
|
|
pitch = float(motion.get("camera", {}).get("pitch", CAMERA_PITCH))
|
|
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])
|
|
return norms, prof, cam, pitch, 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, pitch, props = prepare(motion, figure, flip, strict)
|
|
|
|
mat = mat_bounds(norms, prof, cam, pitch)
|
|
|
|
def geometry(nf):
|
|
_, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch, mat)
|
|
for limb in hide:
|
|
geo.pop(limb, None)
|
|
return geo, order, shade
|
|
|
|
resolved = []
|
|
key_cells = []
|
|
for nf in norms:
|
|
out, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch, mat)
|
|
for limb in hide:
|
|
geo.pop(limb, None)
|
|
resolved.append(out)
|
|
key_cells.append((geo, order, shade, resolve_props(props, geo, nf["root"]["pos"])))
|
|
|
|
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"], geo, order, shade, working, colors, prims)
|
|
for geo, order, shade, prims in key_cells]
|
|
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 = []
|
|
for nf in timeline(resolved):
|
|
geo, order, shade = geometry(nf)
|
|
prims = resolve_props(props, geo, nf["root"]["pos"])
|
|
imgs.append(draw_geo(geo, order, shade, working, colors, font=font, prims=prims))
|
|
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 render_orbit(folder, figure="neutral"):
|
|
"""An orbiting demo of what the app shows: the camera sweeps 360 degrees
|
|
while the motion loops (or swings a `camera.sweep` pendulum when the
|
|
motion authors one). Props rotate with the figure about the root anchor."""
|
|
motion = load_motion(folder)
|
|
working = set(motion.get("working", []))
|
|
hide = set(motion.get("hide", []))
|
|
norms, prof, cam, pitch, props = prepare(motion, figure)
|
|
resolved = [frame_geometry(nf, prof, cam, pitch=pitch)[0] for nf in norms]
|
|
mat = mat_bounds(norms, prof, cam, pitch)
|
|
sweep = motion.get("camera", {}).get("sweep")
|
|
font = legend_font()
|
|
colors = PALETTES["default"]
|
|
ticks = timeline(resolved)
|
|
imgs = []
|
|
for i, nf in enumerate(ticks):
|
|
off = (sweep * math.sin(2 * math.pi * i / len(ticks)) if sweep
|
|
else 360.0 * i / len(ticks))
|
|
# Pins are canvas targets in the AUTHORED view: resolve the pose there,
|
|
# then rotate the posed body - never re-pin in the rotated view. The
|
|
# authored-view geometry also feeds the props' directions and offsets.
|
|
posed, auth_geo, _, _ = frame_geometry(nf, prof, cam, pitch=pitch)
|
|
posed["pins"] = {}
|
|
_, geo, order, shade = frame_geometry(posed, prof, cam + off, pitch=pitch, mat=mat)
|
|
for limb in hide:
|
|
geo.pop(limb, None)
|
|
prims = resolve_props(props, geo, nf["root"]["pos"],
|
|
prop_rotation(pitch, off), auth_geo)
|
|
imgs.append(draw_geo(geo, order, shade, working, colors, font=font, prims=prims))
|
|
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):
|
|
font = legend_font()
|
|
cells = []
|
|
for folder in folders:
|
|
motion = load_motion(folder)
|
|
working, hide = set(motion.get("working", [])), set(motion.get("hide", []))
|
|
norms, prof, cam, pitch, props = prepare(motion, figure)
|
|
mat = mat_bounds(norms, prof, cam, pitch)
|
|
for i, nf in enumerate(norms, start=1):
|
|
_, geo, order, shade = frame_geometry(nf, prof, cam, pitch=pitch, mat=mat)
|
|
for limb in hide:
|
|
geo.pop(limb, None)
|
|
prims = resolve_props(props, geo, nf["root"]["pos"])
|
|
cells.append((f"{motion['name']} {i}/{len(norms)}",
|
|
draw_geo(geo, order, shade, working, PALETTES["default"],
|
|
font=font, prims=prims)))
|
|
save_sheet(cells, Path(out) if out else LIB / "contact-sheet.png")
|
|
|
|
|
|
def demo_sheet(folder):
|
|
"""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", []))
|
|
idx = motion.get("primary", 1) - 1
|
|
font = legend_font()
|
|
variants = [("neutral", "neutral", False, "default"),
|
|
("female profile", "female", False, "default"),
|
|
("male profile", "male", False, "default"),
|
|
("flipped camera", "neutral", True, "default"),
|
|
("themed (indigo)", "neutral", False, "indigo")]
|
|
cells = []
|
|
for label, figure, flip, palette in variants:
|
|
norms, prof, cam, pitch, props = prepare(motion, figure, flip)
|
|
mat = mat_bounds(norms, prof, cam, pitch)
|
|
_, geo, order, shade = frame_geometry(norms[idx], prof, cam, flip, pitch, mat)
|
|
for limb in hide:
|
|
geo.pop(limb, None)
|
|
prims = resolve_props(props, geo, norms[idx]["root"]["pos"])
|
|
cells.append((f"{motion['name']} — {label}",
|
|
draw_geo(geo, order, shade, working, PALETTES[palette],
|
|
font=font, prims=prims)))
|
|
save_sheet(cells, LIB / "demo-sheet.png", cols=3)
|
|
|
|
|
|
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)")
|
|
|
|
|
|
# ------------------------------------------------------------------- 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 write_fixtures(folders):
|
|
"""Regenerate `WorkoutsTests/Fixtures/figure-fixtures.json` — the projected
|
|
geometry snapshots that pin the Swift solver to this pipeline: per exercise
|
|
and key frame the draw order, shading, spine, head, nose, and limb chains,
|
|
plus a mid-tween sample and (for a spread of prop flavors) orbit samples
|
|
taken through the presentation path — resolve at the authored camera, clear
|
|
the pins, rotate the posed body — with the resolved prop primitives."""
|
|
ORBIT_SAMPLED = {"Bird Dog", "Lat Pull Down", "Leg Extension", "Abductor"}
|
|
OFFSETS = (37.0, 180.0)
|
|
T = 0.5
|
|
|
|
def sample(geo, order, shade, prims=None):
|
|
out = {"order": order, "shade": shade,
|
|
"spine": [list(p) for p in geo["spine"]],
|
|
"head": list(geo["head"])}
|
|
if "nose" in geo:
|
|
out["nose"] = [list(geo["nose"][0]), list(geo["nose"][1])]
|
|
for limb in K.LIMBS:
|
|
out[limb] = [list(p) for p in geo[limb]]
|
|
if prims is not None:
|
|
out["props"] = {"bg": prims[0], "fg": prims[1]}
|
|
return out
|
|
|
|
exercises = []
|
|
for folder in folders:
|
|
motion = load_motion(folder)
|
|
norms, prof, cam, pitch, props = prepare(motion, "neutral")
|
|
resolved, frames = [], []
|
|
for nf in norms:
|
|
out, geo, order, shade = frame_geometry(nf, prof, cam)
|
|
resolved.append(out)
|
|
frames.append(sample(geo, order, shade))
|
|
mid = K.lerp_frames(resolved[0], resolved[1], ease(T))
|
|
_, geo, order, shade = frame_geometry(mid, prof, cam)
|
|
entry = {"name": motion["name"], "camera": cam, "frames": frames,
|
|
"tween": {"t": T, "sample": sample(geo, order, shade)}}
|
|
if motion["name"] in ORBIT_SAMPLED:
|
|
anchor = norms[0]["root"]["pos"]
|
|
posed, auth_geo, _, _ = frame_geometry(norms[0], prof, cam)
|
|
posed["pins"] = {}
|
|
orbit = []
|
|
for off in OFFSETS:
|
|
_, geo, order, shade = frame_geometry(posed, prof, cam + off)
|
|
prims = resolve_props(props, geo, anchor,
|
|
prop_rotation(CAMERA_PITCH, off), auth_geo)
|
|
orbit.append({"yaw": cam + off,
|
|
"sample": sample(geo, order, shade, prims)})
|
|
entry["orbit"] = orbit
|
|
exercises.append(entry)
|
|
|
|
out_path = LIB.parent / "WorkoutsTests" / "Fixtures" / "figure-fixtures.json"
|
|
out_path.write_text(json.dumps(
|
|
{"profile": "neutral", "pitch": CAMERA_PITCH, "exercises": exercises}))
|
|
print(f" {out_path.name} ({len(exercises)} exercises)")
|
|
|
|
|
|
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
|
|
if "--fixtures" in flags:
|
|
write_fixtures(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,
|
|
strict="--strict" 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()
|