Fix figure IK snapping and gate the library on a fail-hard motion checker

Three solver defects made limbs teleport, twist, or windmill: write-back
angles wrapped at ±180 and lerped the long way around; branch flips landed
on configurations the anatomical write-back cannot represent, silently
pulling pinned extremities off their pins; and the degenerate straight-limb
bend plane fell back to the camera axis instead of the anatomical anterior.
solve_limb now verifies each branch reproduces the solved end before
accepting it, resolve unwraps written-back angles toward the pose they
replace, and the degenerate plane comes from the parent's anterior axis.

render.py --check replays every exercise's full tween loop and fails hard
on six invariants (pin fidelity, continuity, wraps, authored-vs-resolved
drift, ground penetration, resolved ROM); --export refuses to ship a
failing exercise. All 66 motions re-authored or retouched to pass: honest
authored angles where pins used to override them silently, grounded feet
on the seated machines, a vertical bench-press bar path, straight-armed
child's pose, a butterfly stretch seated on the mat, and FK arms where
pins forced impossible reaches. MotionSolver.swift mirrors the solver
changes line for line, held by regenerated fixtures.

Claude-Session: https://claude.ai/code/session_01PKptrgbx74peTwHGRxBojv
This commit is contained in:
2026-07-12 00:37:23 -04:00
parent 400601283e
commit 79e75a9127
303 changed files with 3114 additions and 2272 deletions
+268 -1
View File
@@ -16,7 +16,11 @@ 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;
the in-app Swift solver to this pipeline. `--check` is compute-only: it
simulates the full tween loop per exercise and fails hard on solver
inconsistencies (pin drift, teleports, angle wraps, authored-vs-resolved
pose contradictions, sunk geometry, resolved ROM) — `--export` always runs
it first and refuses to ship a failing exercise. SVGs need no dependencies;
GIFs/sheets need Pillow.
"""
@@ -970,6 +974,264 @@ def write_fixtures(folders):
print(f" {out_path.name} ({len(exercises)} exercises)")
def _dof_values(frame, joint_map):
return {f"{name}.{dof}": frame[name][dof]
for name, jt in joint_map.items() for dof in K.JOINT_DOFS[jt]}
def _resolved_rom_issues(nf, joints, slack):
"""`K.validate_rom`'s DoF walk, re-implemented here with a slack margin
(kinematics.py itself stays untouched). Returns (name, dof, angle, lo,
hi, over) tuples, `over` the degrees past the slack-widened range."""
issues = []
def check(joint_type, name, value):
for dof, angle in value.items():
lo_hi = joints.get(joint_type, {}).get(dof)
if not lo_hi:
continue
lo, hi = lo_hi
if angle < lo - slack:
issues.append((name, dof, angle, lo, hi, lo - slack - angle))
elif angle > hi + slack:
issues.append((name, dof, angle, lo, hi, angle - hi - slack))
for i, seg in enumerate(nf["spine"], start=1):
check("spine", f"spine{i}", seg)
for key, jt in K.FRAME_JOINTS.items():
check(jt, key, nf[key])
return issues
# ---------------------------------------------------------------------- check
# `--check` fail-fast validation: simulate the exact per-tick pipeline
# render_exercise drives (prepare -> frame_geometry per key frame -> timeline
# -> frame_geometry per tick) at pitch 0 with no mat — the camera elevation
# and the exercise mat are presentation-only, while pins and the ground line
# are authored flat — and check invariants the solver has historically
# swallowed silently. Compute-only: never touches frames/, preview.gif, or
# any other output file.
PIN_TO_LIMB = {pin: limb for limb, (_attach, _sigma, pin) in K.LIMBS.items()}
LIMB_JOINT_NAMES = {
"arm_r": ("shoulder", "elbow", "hand"), "arm_l": ("shoulder", "elbow", "hand"),
"leg_r": ("hip", "knee", "ankle", "toe"), "leg_l": ("hip", "knee", "ankle", "toe"),
}
SPINE_POINT_NAMES = ("pelvis", "ctrl", "neck")
# Joints whose DoFs a lerp actually sweeps between adjacent key frames.
WRAP_JOINTS = {
"shoulder_r": "shoulder", "shoulder_l": "shoulder", "hip_r": "hip", "hip_l": "hip",
"elbow_r": "elbow", "elbow_l": "elbow", "knee_r": "knee", "knee_l": "knee",
"ankle_r": "ankle", "ankle_l": "ankle", "neck": "neck",
}
# Joints IK can actually rewrite (the pinned-limb pairs) — the ones worth
# comparing authored vs resolved for silent pin-vs-pose contradictions.
DRIFT_JOINTS = {
"shoulder_r": "shoulder", "shoulder_l": "shoulder", "hip_r": "hip", "hip_l": "hip",
"elbow_r": "elbow", "elbow_l": "elbow", "knee_r": "knee", "knee_l": "knee",
}
PIN_TOL = 3.0 # canvas units: drawn extremity vs its authored pin.
# Sized just above the solver's own rotation-gating
# residual band (~2.8px worst-case for a correctly
# authored on-pin branch; see BRANCH_REPRO_TOL in
# kinematics.py) so the gate fails authoring errors,
# not solver-inherent precision.
PIN_UNREACHABLE_TOL = 4.0 # canvas units: drawn extremity vs the reach-clamped target
CONTINUITY_TOL = 16.0 # canvas units: any drawn joint, tick to tick
WRAP_TOL = 180.0 # degrees: any angle DoF, adjacent resolved key frames
DRIFT_TOL = 45.0 # degrees: resolved vs authored, on pinned-limb DoFs
GROUND_TOL = 2.0 # canvas units of slack below GROUND_Y
ROM_SLACK = 5.0 # degrees of slack for the resolved-ROM re-check
def _dist(a, b):
return math.hypot(a[0] - b[0], a[1] - b[1])
def _pt(p):
return f"({p[0]:.0f},{p[1]:.0f})"
def _joint_points(geo):
"""Every drawn joint worth a continuity check: head, spine, and every
point of every limb chain (attach through extremity, incl. toes)."""
pts = {"head": geo["head"]}
for nm, p in zip(SPINE_POINT_NAMES, geo["spine"]):
pts[f"spine.{nm}"] = p
for limb, names in LIMB_JOINT_NAMES.items():
for nm, p in zip(names, geo[limb]):
pts[f"{limb}.{nm}"] = p
return pts
def _ground_points(geo):
"""`_joint_points` plus the shoulder-girdle and pelvis bar endpoints."""
pts = dict(_joint_points(geo))
pts["girdle.l"], pts["girdle.r"] = geo["girdle"][0], geo["girdle"][2]
pts["pelvisBar.l"], pts["pelvisBar.r"] = geo["pelvisBar"][0], geo["pelvisBar"][2]
return pts
def _limb_reach(prof, limb):
a, b = ((prof["upperArm"], prof["foreArm"]) if limb.startswith("arm")
else (prof["thigh"], prof["shin"]))
return a + b - 0.01 # mirrors solve_limb's own reach clamp
def _add(violations, cls, joint, where, mag, unit, noun, detail):
violations.append({"cls": cls, "joint": joint, "where": where, "mag": mag,
"unit": unit, "noun": noun, "detail": detail})
def check_exercise(folder, figure="neutral"):
"""Simulate the complete render loop for one exercise — resolve every key
frame, build the tween timeline, re-resolve every tick — at pitch 0 with
no mat, collecting violations of the six invariants (pin fidelity,
pin-unreachable, continuity, wrap, authored-vs-resolved drift, resolved
ROM). Returns (name, [violation, ...]); an empty list means it passed.
"""
name = folder.name
violations = []
try:
motion = load_motion(folder)
name = motion["name"]
skel = K.load_skeleton()
norms, prof, cam, _pitch, _props, _zoom = prepare(motion, figure)
resolved = [frame_geometry(nf, prof, cam, pitch=0.0)[0] for nf in norms]
n = len(resolved)
# 6. RESOLVED ROM — the authored-frame ROM gate, re-run post-IK.
for i, rf in enumerate(resolved, start=1):
for jname, dof, angle, lo, hi, over in _resolved_rom_issues(rf, skel["joints"], ROM_SLACK):
joint, where = f"{jname}.{dof}", f"keyframe {i}"
_add(violations, "ROM", joint, where, over, "deg", "keyframes",
f"ROM {joint}: {where} resolves to {angle:.1f}, outside "
f"[{lo}, {hi}] (+/-{ROM_SLACK:g} slack) by {over:.1f}deg")
# 4. AUTHORED-VS-RESOLVED DRIFT — pins silently overriding the pose.
for i in range(n):
for jkey, jt in DRIFT_JOINTS.items():
for dof in K.JOINT_DOFS[jt]:
a, b = norms[i][jkey][dof], resolved[i][jkey][dof]
diff = abs(a - b)
if diff > DRIFT_TOL:
joint, where = f"{jkey}.{dof}", f"keyframe {i + 1}"
_add(violations, "DRIFT", joint, where, diff, "deg", "keyframes",
f"DRIFT {joint}: {where} authored {a:.1f} vs resolved "
f"{b:.1f}{diff:.1f}deg")
# 3. WRAP — adjacent resolved key frames, including the loop closure.
if n > 1:
for i in range(n):
j = (i + 1) % n
da = _dof_values(resolved[i], WRAP_JOINTS)
db = _dof_values(resolved[j], WRAP_JOINTS)
where = f"keyframe {i + 1}->{j + 1}"
for key in da:
diff = abs(da[key] - db[key])
if diff > WRAP_TOL:
_add(violations, "WRAP", key, where, diff, "deg", "keyframe pairs",
f"WRAP {key}: {where} {da[key]:.1f} -> {db[key]:.1f}"
f"{diff:.1f}deg")
# 1/2/5. Per-tick pass: PIN / PIN-UNREACHABLE, CONTINUITY, GROUND.
ticks = timeline(resolved)
total = len(ticks)
prev = None
for t, nf in enumerate(ticks):
_work, geo, _order, _shade = frame_geometry(nf, prof, cam, pitch=0.0)
where = f"tick {t + 1}/{total}"
for pin_name, pin_xy in nf["pins"].items():
limb = PIN_TO_LIMB.get(pin_name)
if limb is None:
continue
attach, drawn = geo[limb][0], geo[limb][2]
d_attach_pin = _dist(attach, pin_xy)
reach = _limb_reach(prof, limb)
if d_attach_pin > reach:
over = d_attach_pin - reach
# Sub-PIN_TOL overreach parks the extremity invisibly short
# of its pin (authors place pins at full extension); only a
# visible shortfall is an authoring error.
if over > PIN_TOL:
_add(violations, "PIN-UNREACHABLE", limb, where, over, "px", "ticks",
f"PIN-UNREACHABLE {limb}: {where} pin {_pt(pin_xy)} is "
f"{d_attach_pin:.1f}px from attach, reach {reach:.1f}"
f"{over:.1f}px over")
scale = reach / d_attach_pin
target = (attach[0] + (pin_xy[0] - attach[0]) * scale,
attach[1] + (pin_xy[1] - attach[1]) * scale)
tol = PIN_UNREACHABLE_TOL
else:
target, tol = pin_xy, PIN_TOL
d = _dist(drawn, target)
if d > tol:
_add(violations, "PIN", limb, where, d, "px", "ticks",
f"PIN {limb}: {where} drawn {_pt(drawn)} vs pin "
f"{_pt(target)}{d:.1f}px")
jp = _joint_points(geo)
gp = _ground_points(geo)
for joint, p in gp.items():
if p[1] > GROUND_Y + GROUND_TOL:
depth = p[1] - (GROUND_Y + GROUND_TOL)
_add(violations, "GROUND", joint, where, depth, "px", "ticks",
f"GROUND {joint}: {where} at y={p[1]:.1f}{depth:.1f}px "
f"below ground")
if prev is not None:
trans = f"tick {t}->{t + 1}/{total}"
for joint, p in jp.items():
if joint not in prev:
continue
d = _dist(prev[joint], p)
if d > CONTINUITY_TOL:
_add(violations, "CONTINUITY", joint, trans, d, "px", "ticks",
f"CONTINUITY {joint}: {trans} moved {d:.1f}px")
prev = jp
except Exception as e: # a solver bug must not crash the whole check run
_add(violations, "ERROR", "-", "-", float("inf"), "", "",
f"ERROR: {type(e).__name__}: {e}")
return name, violations
def _report_exercise(name, violations):
print(f" {name}: FAIL ({len(violations)} violation(s))")
groups = {}
for v in violations:
groups.setdefault((v["cls"], v["joint"]), []).append(v)
for (cls, joint), vs in sorted(groups.items()):
vs.sort(key=lambda v: -v["mag"])
worst = vs[0]
if len(vs) == 1:
print(f" {worst['detail']}")
else:
print(f" {cls} {joint}: {len(vs)} {worst['noun']}, "
f"max {worst['mag']:.1f}{worst['unit']} (worst {worst['where']})")
def run_check(folders, figure="neutral"):
"""`--check`: simulate every render tick for each exercise and fail hard
on the six invariant classes. Prints a compact per-exercise violation
block (silence = pass) and a final tally; returns True iff every
exercise passed clean."""
total_violations = 0
failed = 0
for folder in folders:
name, violations = check_exercise(folder, figure)
total_violations += len(violations)
if violations:
failed += 1
_report_exercise(name, violations)
print(f"{len(folders)} exercises checked, {failed} failed, "
f"{total_violations} violations")
return failed == 0
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("--")]
@@ -982,7 +1244,12 @@ def main():
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 "--check" in flags:
sys.exit(0 if run_check(folders, figure=figure) else 1)
if "--export" in flags:
if not run_check(folders, figure=figure):
print(" --export aborted: fix the --check violations above first.")
sys.exit(1)
export_app_resources(folders)
return
if "--fixtures" in flags: