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