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:
+268
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user