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
+91 -37
View File
@@ -24,8 +24,12 @@ y up, z toward the camera); the renderer maps to y-down canvas points.
Pins are canvas-space IK targets for hands/feet (`hand_r`, `foot_l`, ...):
the two-bone chain is solved analytically in 3D, in the plane picked by the
authored (FK) elbow/knee, then converted back to anatomical angles so key
frames always interpolate in anatomical space.
authored (FK) elbow/knee (or the anatomical anterior axis when that guess is
degenerate), then converted back to anatomical angles. The solver keeps only
a bend branch whose recovered angles re-pose to the solved position, and
unwraps those angles toward the pose they replace, so key frames always
interpolate continuously in anatomical space and a pinned extremity can never
silently leave its pin.
skeleton.json carries the bone-length profiles (including shoulder/pelvis
half-widths and feet) and each joint type's degrees of freedom with their
@@ -123,12 +127,24 @@ def _clamp(x, lo=-1.0, hi=1.0):
# rotation is left at 0 (see invert_limb).
ROT_MIN_LATERAL = 0.08
# When a leg's authored knee sits within this fraction of the thigh length off the
# hip->ankle line (~sin 8.6 deg), the leg is treated as straight and its IK knee is
# bent anatomically forward rather than trusting the (unreliable) authored side
# (see solve_limb).
# When an authored mid joint (knee or elbow) sits within this fraction of the upper
# bone length off the attach->target line (~sin 8.6 deg), the FK guess is unreliable:
# the two bend solutions straddle the line and the near-parallel guess cannot pick a
# side or a plane. The bend plane is then taken from the anatomical anterior axis
# (knees forward, elbow flexion carries the forearm anterior) instead of the guess,
# for both arms and legs (see solve_limb).
KNEE_STRAIGHT_FRAC = 0.15
# A recovered IK branch is kept only if its re-posed extremity lands within this many
# canvas units of the solved point, measured in the screen plane where the pin lives
# (see solve_limb). A branch the anatomical write-back cannot represent - the acos
# bend-sign loss or a rotation gated at ROT_MIN_LATERAL - mirrors the lower bone and
# misses by a wide margin; the small residual a correctly-authored, on-pin branch
# leaves when its rotation grazes the gating boundary is only a couple of units, so
# this margin flips genuine mirrors while leaving well-authored branches on their
# FK-guess side (a tighter margin would flip them too, shifting sound geometry).
BRANCH_REPRO_TOL = 4.0
# ---------------------------------------------------------------- the frame
@@ -292,45 +308,77 @@ def pose(nf, prof, cam_yaw, cam_pitch=0.0):
def solve_limb(kind, attach, target, guess_mid, lengths, parent, sigma):
"""Analytic two-bone IK in 3D: reach from `attach` toward `target` in the
plane picked by the authored (FK) mid joint, then convert back to
anatomical angles. Returns (upper joint dict, lower joint dict)."""
anatomical angles. Returns (upper joint dict, lower joint dict).
The two bend solutions are tried in preference order (arm: nearest the FK
guess; leg: the authored/anterior side first). The write-back keeps only a
branch whose recovered angles forward-kinematic back to the solved end - a
branch the angle representation cannot express (acos loses the bend sign, a
near-sagittal limb loses its axial rotation) would silently move the pinned
extremity, so it is rejected in favor of the flip."""
a, b = lengths
to_t = vsub(target, attach)
d = _clamp(vlen(to_t), abs(a - b) + 0.5, a + b - 0.01)
dir_t = vnorm(to_t) if vlen(to_t) > 1e-9 else (0.0, -1.0, 0.0)
normal = vcross(dir_t, vsub(guess_mid, attach))
if vlen(normal) < 1e-6: # chain straight along the target: any plane works
normal = vcross(dir_t, (0, 0, 1))
if vlen(normal) < 1e-6:
normal = vcross(dir_t, (0, 1, 0))
# Guess reliability: how far the authored mid sits off the attach->target
# line. Below KNEE_STRAIGHT_FRAC the two bend solutions straddle the line and
# the guess (near-parallel) can pick neither a plane nor a side.
gm = vsub(guess_mid, attach)
gm_perp = vsub(gm, vscale(dir_t, vdot(gm, dir_t)))
reliable = vlen(gm_perp) >= KNEE_STRAIGHT_FRAC * a
if reliable:
normal = vcross(dir_t, gm)
else:
# Degenerate guess: bow the joint along the anatomical anterior axis.
anterior = mvec(parent, (1, 0, 0))
ap = vsub(anterior, vscale(dir_t, vdot(anterior, dir_t)))
normal = vcross(dir_t, ap)
if vlen(normal) < 1e-6: # target along the anterior axis: any plane works
normal = vcross(dir_t, (0, 0, 1))
if vlen(normal) < 1e-6:
normal = vcross(dir_t, (0, 1, 0))
normal = vnorm(normal)
perp = vcross(normal, dir_t)
along = (a * a + d * d - b * b) / (2 * d)
h = math.sqrt(max(a * a - along * along, 0.0))
base = vadd(attach, vscale(dir_t, along))
if kind == "leg":
# A knee bends one way only. Near full extension the two knee solutions
# straddle the hip->ankle line and the authored guess (also near that line)
# cannot reliably pick a side - the bend plane's normal is a cross of two
# near-parallel vectors, so its sign is noise and the knee can flip behind
# the leg, swinging the whole thigh backward. When the authored knee sits
# within KNEE_STRAIGHT_FRAC of the line, treat the leg as straight and bend
# the knee anatomically forward (anterior); otherwise honor the authored side.
gm = vsub(guess_mid, attach)
gm_perp = vsub(gm, vscale(dir_t, vdot(gm, dir_t)))
ref = mvec(parent, (1, 0, 0)) if vlen(gm_perp) < KNEE_STRAIGHT_FRAC * a else gm_perp
sign = 1.0 if vdot(perp, ref) >= 0 else -1.0
mid = vadd(attach, vadd(vscale(dir_t, along), vscale(perp, sign * h)))
# A knee bends one way only. Honor the authored side when it is reliable,
# else bend anatomically forward (anterior); the flip is the fallback.
ref = gm_perp if reliable else mvec(parent, (1, 0, 0))
first = 1.0 if vdot(perp, ref) >= 0 else -1.0
signs = (first, -first)
else:
best = None
for sign in (1.0, -1.0):
mid = vadd(attach, vadd(vscale(dir_t, along), vscale(perp, sign * h)))
dist = vlen(vsub(mid, guess_mid))
if best is None or dist < best[0]:
best = (dist, mid)
mid = best[1]
end = vadd(mid, vscale(vnorm(vsub(target, mid)), b))
return invert_limb(kind, attach, mid, end, lengths, parent, sigma)
# Prefer the mid nearest the FK guess; the flip is the fallback.
d1 = vlen(vsub(vadd(base, vscale(perp, h)), guess_mid))
d2 = vlen(vsub(vadd(base, vscale(perp, -h)), guess_mid))
signs = (1.0, -1.0) if d1 <= d2 else (-1.0, 1.0)
fallback = None
for sign in signs:
mid = vadd(base, vscale(perp, sign * h))
end = vadd(mid, vscale(vnorm(vsub(target, mid)), b))
upper, lower = invert_limb(kind, attach, mid, end, lengths, parent, sigma)
# Re-pose the recovered angles (fk_limb's math, upper->lower) and accept
# this branch only if the pinned end lands back on the solved point in
# the screen plane - that is where the pin lives. A branch the write-back
# mirrors leaves the pin in the drawing and is rejected; one off only in
# depth (rotation gated at ROT_MIN_LATERAL) still holds its pin. Solving
# is unpitched, so view-space (x, y) is the canvas projection (the anchor
# offset cancels in the difference).
fu = mmul(parent, _ball(upper, sigma))
fl = (mmul(fu, rot_z(lower["flexion"])) if kind == "arm"
else mmul(fu, rot_z(-lower["flexion"])))
re_mid = vadd(attach, mvec(fu, (0, -a, 0)))
re_end = vadd(re_mid, mvec(fl, (0, -b, 0)))
if fallback is None:
fallback = (upper, lower)
if math.hypot(re_end[0] - end[0], re_end[1] - end[1]) <= BRANCH_REPRO_TOL:
return upper, lower
return fallback
def invert_limb(kind, attach, mid, end, lengths, parent, sigma):
@@ -387,10 +435,16 @@ def resolve(nf, prof, cam_yaw, cam_pitch=0.0):
parent = p["f2"] if kind == "arm" else p["f_root"]
upper, lower = solve_limb(kind, attach, target, chain_pts[1],
lengths, parent, sigma)
if kind == "arm":
nf["shoulder_" + side], nf["elbow_" + side] = upper, lower
else:
nf["hip_" + side], nf["knee_" + side] = upper, lower
upper_key = ("shoulder_" if kind == "arm" else "hip_") + side
lower_key = ("elbow_" if kind == "arm" else "knee_") + side
# invert_limb returns principal-value angles (atan2/asin); unwrap the
# flexion/rotation toward the pose being replaced so key frames lerp the
# short way and tween ticks stay continuous. Abduction is asin-ranged and
# cannot wrap.
prev = nf[upper_key]
for dof in ("flexion", "rotation"):
upper[dof] += 360.0 * round((prev[dof] - upper[dof]) / 360.0)
nf[upper_key], nf[lower_key] = upper, lower
solved = True
if solved:
p = pose(nf, prof, cam_yaw, cam_pitch)