Common Problems We Solved
Twenty-five real bugs from a production ASL animation system: hands through the chest, fingers hyperextending, a limit cycle in the collision solver, a measurement that lied, with the root cause and fix for each.

Twenty-five bugs, grouped by the part of the system they lived in. Each one has the symptom, the root cause, the fix, and where it is interesting, how the wrong diagnosis nearly won.
The theme running through all of them: the obvious cause was usually wrong. Four of five plausible fixes in this project measured worse than the bug they were meant to fix.
Rendering and export
1. Fingers detached from the hands
Symptom: the GLB loaded without error. The character rendered. The fingers floated near the hands but not attached to them.
Root cause: Rigify parents deform bones to non-deform bones.
{
"DEF-f_index.01.L": "ORG-palm.01.L",
"DEF-f_index.02.L": "DEF-f_index.01.L"
}A deform-bones-only glTF export excludes ORG-palm.01.L. Its child is orphaned. No error is raised, since glTF is happy to export a forest instead of a tree.
Fix: rebuild a clean single-root deform hierarchy before exporting, walking up through the Rigify twins:
def nearest_def_parent(arm, name, defset):
"""Closest deform ancestor, following the Rigify ORG-/MCH- twins."""
b = arm.bones[name].parent
while b is not None:
if b.name in defset and b.name != name:
return b.name
for pref in ("ORG-", "MCH-"):
if b.name.startswith(pref):
twin = "DEF-" + b.name[len(pref):]
if twin in defset and twin != name:
return twin
b = b.parent
return NoneHow it was found: by querying the live Blender session for bone parents before writing export code. This is the strongest argument in the project for inspection-first.
2. Morph targets deforming the eyeballs
Symptom: facial expressions squashed the eyes into eggs.
Root cause: morphs were applied to every face mesh uniformly. The eyeballs are rigid spheres, so they should only rotate.
Fix: role-scoped morphs.
_ROLE_ALLOW = {
"brows": {"browRaise", "browFurrow", "browRaiseInner"},
"lashes": {"blinkL", "blinkR", "squint", "eyeWide"},
"eyeballs": {"gazeL", "gazeR", "gazeUp", "gazeDown"},
}The consequence this introduced, and it caused bug #21 below, is that meshes which should move together can now drift apart.
Next.js and three.js
3. The build failed on window is not defined
Symptom: npm run build failed. Dev mode worked.
Root cause: three.js touches window and WebGL at module scope. Next.js pre-renders pages in Node during the build, where neither exists.
Fix: one line.
const SignStage = dynamic(() => import('@/components/SignStage'), { ssr: false });4. The dev server started 404-ing on chunks
Symptom: after running a production build, the running dev server broke, since chunk requests returned 404.
Root cause: npm run build overwrites .next, which next dev is actively serving from.
Fix: a process, not code.
# kill dev, then:
rm -rf .next
npm run build
rm -rf .next
npm run dev5. Coordinate system conversion
Symptom: hands in plausible but wrong places.
Root cause: Blender is Z-up, glTF is Y-up:
gltf.x = blender.x
gltf.y = blender.z
gltf.z = -blender.yEvery anchor, collision band and measured constant crosses that boundary.
Why it is dangerous: getting it wrong does not crash. It produces poses that look almost right, which is much harder to notice than a failure.
Fix: convert once, at the export boundary, and record the convention in the manifest:
"space": "gltf-y-up (x right, y up, z toward viewer)"Finger rotations
6. Fingers hyperextending instead of curling
Symptom: closing a fist bent the fingers backwards.
Root cause: the curl axis was computed as cross(palmNormal, dir), the correct axis negated.
Fix: swap the operands, and write down why:
// Axis whose positive rotation sweeps the fingertip toward the palm:
// closing a fist moves the tips along +palmNormal (out of the palm
// plane, ending against the palm), and (d x n) x d = +n.
const axisWorld = new Vector3().crossVectors(b.dir, palmNormal).normalize();7. Open handshapes came out half-closed
Symptom: the 5 handshape (all fingers extended and spread) looked like a relaxed hand.
Root cause: the character's bind pose has slightly curled fingers, which is correct modelling for a hand at rest. The code treated flexion 0 as apply no rotation, so flexion 0 left the fingers curled.
Fix: measure how bent each joint already is, and make flexion 0 actively straighten:
/**
* How far each joint is already flexed in the bind pose. The character is
* modelled with relaxed, slightly curled fingers, so a flexion of 0 has to
* *straighten* the finger rather than leave it alone.
*/
restFlex: number[];const prevDir = i === 0 ? base.dir : bones[i - 1].dir;
restFlex.push(signedAngle(prevDir, b.dir, axisWorld));8. Spread worked backwards
Symptom: spread = 1 (fully splayed) brought the fingers closer together than spread = 0.
Root cause: the per-finger lateral bias signs were inverted, so positive rotation about the palm normal moved fingers the wrong way.
Fix: negate the bias table.
/**
* Lateral spread bias per finger: the fan opens toward the radial side for the
* index and the ulnar side for the pinky. Signs are set so that a positive
* rotation about the measured palm normal moves a finger toward the thumb.
*/
const SPREAD_BIAS: Record<FingerName, number> = {
index: -1.0, middle: -0.28, ring: 0.45, pinky: 1.15,
};Bone constraints
9. The wrist limiter amplified rotation
Symptom: entering the ME sign, the hand appeared to spin rather than travel, at 2,492 deg/s where the animation asked for 590.
Root cause: the limiter clamped the angle by scaling limited/angle. That bounds the angle but not its rate of change: the derivative grows without bound as the requested angle does. While engaged, the limiter amplified whatever rotation the animation asked for.
Fix: soft saturation instead of a hard clip.
export function limitWristDirection(fingerDir: Vector3, foreDir: Vector3): Vector3 {
const d = Math.min(1, Math.max(-1, fingerDir.dot(foreDir)));
const angle = Math.acos(d);
if (angle <= WRIST_KNEE || angle < 1e-6) return fingerDir;
// tanh saturates smoothly above a knee: identity below it, asymptotic to the
// limit above it, and derivative <= 1 everywhere, so the limiter can only ever
// slow the hand down.
const span = WRIST_LIMITS.swing - WRIST_KNEE;
const limited = WRIST_KNEE + span * Math.tanh((angle - WRIST_KNEE) / span);
return slerpDirection(foreDir, fingerDir, limited / angle);
}The key property: derivative less than or equal to 1 everywhere. A limiter should never be able to make something move faster than it was asked to.
10. The NAME hand flip: 18,000 deg/s
Symptom: during NAME, the left hand flipped into a wrong orientation and returned. Reported by a human watching, then confirmed by the detector at 18,000 deg/s.
How it was diagnosed: bisection using the debug toggles. Disabling interpolation smoothing changed nothing. Disabling the wrist limiter removed it.
Root cause: the limiter used a swing-twist quaternion decomposition, which has a singularity. Near it, the decomposition produces a wildly different result for a tiny input change.
Fix: replace the decomposition with the geometric formulation above, working directly with directions and angles, no decomposition, no singularity.
11. A second singularity, in the fix
Symptom: after fixing #10, a different 10,490 deg/s spike appeared.
Root cause: the limiter rotates fingerDir. The palm direction was then re-orthogonalised against the new finger direction, and when the two were nearly parallel, that collapsed.
Fix: carry the palm through the same rotation rather than recomputing it:
const roll = new Quaternion().setFromUnitVectors(fDir, limited);
pDir.applyQuaternion(roll).normalize();
fDir = limited;12. The detector disagreed with the limiter
Symptom: 408 frames flagged as wristOverBend that were sitting exactly at the allowed limit.
Root cause: the limiter and the detector each held the same threshold as a separate literal. One was changed. They drifted.
Fix: derive one from the other.
/**
* Radians from the forearm axis. Derived from the limiter's own value rather
* than written out again: when the two drifted apart the detector flagged 408
* frames that were sitting exactly on the allowed limit.
*/
wristSwing: WRIST_LIMITS.swing + 0.04,Animation clipping and hand intersections
13. Hands pushed out through the character's back
Symptom: hands escaping a body collision emerged behind the character.
Root cause: the escape direction was the radial normal from the body's centre line. For a hand near the chest, the shortest way out can point backwards: geometrically correct, anatomically impossible.
Fix: never allow a backward escape; choose between forward and the nearer side.
The test suite now asserts it, over 901 interior points:
ok escape direction never points backward (901 interior points)14. Hands buzzing when close together
Symptom: in two-handed signs, the hands vibrated at frame rate.
Root cause: the separation resolver fixed only the single closest finger pair. Separating pair A promotes pair B to closest, whose push points elsewhere, and the following frame the ranking swaps back.
Fix: aggregate every violating pair into one push, weighted by overlap depth:
// Resolving only the single closest pair is what made the hands buzz:
// separating pair A promotes pair B to closest, whose push points
// somewhere else, and the following frame the ranking swaps back. The
// hand then oscillates between two contact normals at frame rate. This
// resolver alone accounted for 31 of 63 flicker events; summing the
// contributions gives a direction that changes smoothly as the hands move.
push3.addScaledVector(dir.normalize(), deficit * deficit);
weight += deficit * deficit;15. The resolver fought signs that are supposed to touch
Symptom: SCHOOL, a palm-on-palm clap, stuttered badly. The wrist alternated 0.3 mm and 17 mm per frame.
Root cause: the separation resolver enforced a travelling gap unconditionally. The animation pulled the hands together; the resolver shoved them apart; repeat every frame.
The data was already there and was not being read. ASL-LEX records contact = 1 and MinorLocation = Palm for SCHOOL.
Fix: propagate intent from the phonology into the resolver.
/**
* True when the sign *intends* the two hands to touch - SCHOOL claps one palm
* onto the other, NAME taps hand on hand. Without this the separation
* resolver fights the animation: it shoves the hands apart, the sign pulls
* them back, and the wrist stutters between 0.3 mm and 17 mm per frame.
*/
handsTouch?: boolean;Measured: SCHOOL's stutter ratio went from 319 to 4.3.
Interpolation and timing
16. The interpolation was wrong in three compounding ways
Symptom: stutter during repeated signs.
Root causes, all three at once:
- 1
The uniform Catmull-Rom formula applied to knots that are not uniformly spaced in time. A repeated sign puts four knots 0.145 s apart between much longer transitions, so the tangents were wrong at every knot.
- 2
It was fed the eased parameter, shaping velocity a second time.
- 3
Smoothing was disabled next to hold keyframes, so a spline span met an eased-lerp span with mismatched velocity at the join.
Fix: time-aware cubic Hermite, evaluated on raw time, applied to every span, with Fritsch-Carlson monotone tangent limiting:
const limit = (a: number, b: number) => {
if (a * b <= 0) return 0; // turning point: flat
const m = (a + b) * 0.5;
const cap = 3 * Math.min(Math.abs(a), Math.abs(b));
return Math.sign(m) * Math.min(Math.abs(m), cap);
};17. 417 signs lasted 0.08 seconds
Symptom: LOVE and 416 others flashed and vanished.
Root cause: those ASL-LEX entries have movement: None, since they are held signs. The synthesiser emitted a single keyframe, and a single keyframe has no duration by definition.
Fix:
path.length ? [start, ...path] : [start, { ...start }]18. Doubled letters read as one
Symptom: MUHAMMAD fingerspelled with a single visible M.
Root cause: two identical handshapes in sequence interpolate to no motion at all. The hand just sits there.
Fix: a lateral bounce plus an intermediate re-articulation keyframe.
const doubled = i > 0 && letters[i - 1] === ch;
const lateral = doubled ? -s * 0.16 : 0;
if (doubled) {
// small re-articulation so the two letters read as two
keys.push({ t: t - ctx.rate * 0.35, ease: 'easeOut', pose: ... });
}Incorrect signs
19. Thank you was fingerspelled T-H-A-N-K
Symptom: a common sign came out spelled.
Root cause: a namespace collision. The proper-noun guard tested lex.index[mapped] (an index keyed by English words) against a value that had already been mapped to a sign ID. LEXICAL_MAP sends thank to thank_you, and no English word contains an underscore. The lookup failed, the word looked unknown, and a capitalised sentence-initial word got fingerspelled.
Fix: resolve the sign first, then decide about fingerspelling.
20. ME requested a 156 degree wrist
Symptom: the ME sign (pointing at your own chest) put the hand in an impossible orientation and triggered the wrist limiter, which then produced the spin in bug #9.
Root cause: the override's palmDir faced up rather than across the body.
Fix: correct the orientation data, and additionally score elbow swivel on wrist strain, not just body penetration:
const score = (e: Vector3) => this.armPenetration(S, e, W) * 10 + wristStrain(e);Facial rig
21. An extra set of eyelids
Symptom: during YESTERDAY the brows raise, and a second set of eyelids appears to move up behind the real eyes.
Diagnosis: profile the morph's displacement by height relative to the eye centre.
Height 0.00 (pupil): browRaise displacement 0.0724.
Height +0.05 to +0.15 (eye aperture): browRaise displacement 0.076, full amplitude.
Height +0.25 (the actual eyebrow): browRaise displacement 0.0651.
Root cause: the field was centred on the eye, not the brow. _fall(abs(p.z - BROW_Z), 0.055, 0.30) has no lower bound: from BROW_Z at 10.262 it reaches 9.962, past the bottom of the eye opening at 10.045.
Because the lash mesh does not carry brow morphs (see bug #2), the lid skin slid up away from the lashes and eyeballs. Two lid lines, and exposed sclera.
Fix: a floor mask on all three brow fields.
def _above_eye(p):
"""1.0 above the lid crease, 0.0 at and below the top of the eye opening."""
return _fall(EYE_TOP_Z + BROW_FLOOR_TAPER - p.z, 0.0, BROW_FLOOR_TAPER)Result: displacement at pupil level 0.0724 to 0.014; the brow itself unchanged.
22. The face inflated on Thank you very much
Symptom: the midface ballooned during MUCH.
Root cause: two defects.
for ch in (CHEEK_L, CHEEK_R):
w = _fall((p - ch).length, 0.14, 0.44) * _front(p, 0.10, 0.55)
out += Vector((copysign(0.085, ch.x) * w, -0.055 * w, -0.010 * w))- 1
The two lobes were summed. Midline vertices (nose, philtrum, upper lip) sat inside both falloffs. The sideways components cancelled; the forward component doubled to -0.11 and pushed the centre of the face out.
- 2
Radius 0.44 from a cheek at z 9.78 reaches 10.22, above the eye centre.
Fix: nearest lobe only, tighter radius, capped below the eye, and amplitude reduced from 0.1017 to 0.074. It had been the largest expression morph in the rig by 40 percent.
23. A morpheme that silently did nothing
Symptom: MANY, MUCH and FAT produced no mouth movement.
Root cause: the grammar layer emitted puff; the synthesiser's morpheme table had no puff entry. The lookup returned undefined and the code moved on.
Fix: add the entry, and add a test at the seam:
ok all 8 mouth morphemes resolve to a face shape
ok every mouth morpheme drives a morph at mid-signThe two hardest ones
24. A limit cycle in the collision solver
Symptom: persistent low-amplitude flicker that survived every obvious fix.
Diagnosis: a purpose-built probe recording the posed wrist path, not the keyframe target:
t=0.783 z=633.5
t=0.792 z=635.8
t=0.800 z=628.0 <- down 7.8
t=0.808 z=630.1 <- up 2.1
t=0.817 z=622.2 <- down 7.9
t=0.825 z=624.3 <- up 2.1X and Y perfectly smooth. Z alone sawtoothing on alternate frames: a period-2 limit cycle riding on smooth motion.
Root cause: the release logic decayed corrections toward zero while the resolvers pushed toward the constraint surface. Two targets that never coincide, so the hand crossed the surface every other frame, indefinitely.
Fix: make the release speculative. Propose the decay, re-solve, keep it only if the pose is still collision-free, and do it at end-of-frame, after both arms are posed, because a per-hand release cannot see hand-vs-hand state.
Result: total 138 to 118, hand collisions 11 to 6, hand-in-forearm 8 to 2, body penetration 4 to 0.
25. The measurement that lied
Symptom: a change produced the best score of the entire project: total 76, every metric down.
It was wrong. The clearance predicate returned max(0, penetration), so it could never go negative. Release only if there is clearance to spare was therefore unsatisfiable, and the release silently never fired. Corrections became permanent. The hands stopped oscillating because they stopped returning to where they belonged.
How it was caught: a parameter sweep that produced identical results at every value.
margin 0.003 -> 76
margin 0.006 -> 76
margin 0.010 -> 76
margin 0.022 -> 76
margin 0.035 -> 76A parameter that changes nothing is not well-tuned. It is not being read.
Fix: rewrite the predicate as a proper signed clearance test, and instrument it:
/**
* Diagnostics: how often the end-of-frame release let a correction go, and how
* often it was refused. A release count stuck at zero means the corrections
* are permanent rather than settling - which a badly-signed clearance test
* once made happen silently.
*/
releaseStats = { released: 0, refused: 0 };The honest score was 118, not 76. And the better policy was then measurable as what it really was: hands left permanently displaced by up to 0.56 units, about 6.6 hand-gaps, putting signs in the wrong place.
Three rules from this:
- 1
A suspiciously good result deserves more suspicion than a bad one.
- 2
A flat parameter sweep means the parameter is not wired up.
- 3
Count how often conditional logic fires. A gate that never fires and a gate that always passes look identical from outside.
How the AI actually helped, and where it did not
Where it was genuinely strong:
Breadth of candidate causes: Given the hand flips during NAME, it enumerated eight plausible mechanisms across quaternion math, IK, retargeting and blending. That framing is most of the work.
Building instruments on demand: The 15-artifact detector, the posed-wrist probe, the filmstrip renderer, the pixel-space projection: each written in minutes when the question demanded it. This is where the leverage really was.
Mechanical bisection: Running the corpus across seven resolver configurations and tabulating results is exactly the kind of tedious work that gets skipped by humans.
Holding context: Remembering that a low-pass filter was already tried and measured 230 to 3286.
Where it needed a human:
Noticing it looked wrong: The detector said SCHOOL was fine. A person watching said still flickering. The person was right; the metric was in the wrong units.
Judging tradeoffs: Never release corrections scores better on every artifact metric. Deciding that permanently displaced hands are worse than measured flicker is a judgement about what the system is for.
Knowing that contact is phonemic in ASL: No amount of geometric reasoning gets you to hands must not intersect, except when the sign says they must.
Distrusting a good result: The 76 was accepted for several minutes. What broke it was a human instinct that the number was too clean.
The honest summary: the AI was fastest at generating hypotheses and building the tools to test them. The human contributions were noticing that a metric did not match perception, and deciding what better means.
Key Takeaways
- 1
Inspect before you build: One query showing DEF-f_index.01.L parented to a non-deform bone saved a day of debugging floating fingers.
- 2
Silent failures are the expensive ones: Detached fingers, a missing morpheme, and a namespace collision all produced plausible output with no error.
- 3
State sign conventions in comments at the point of definition: Three consecutive bugs were inverted cross products and biases.
- 4
Clamps bound values, not derivatives: A hard clip on wrist angle amplified rotation to 2,492 deg/s; tanh saturation has derivative less than or equal to 1 everywhere.
- 5
Avoid decompositions with singularities when inputs are unbounded: Swing-twist caused an 18,000 deg/s flip.
- 6
Rotate whole frames, not single axes: Re-orthogonalising the palm against a rotated finger direction reintroduced the degeneracy.
- 7
Derive shared constants: A drifted threshold produced 408 false positives and cost trust in every other number.
- 8
Aggregate over all violations, not the worst one: Ranking-based solvers oscillate when the ranking is unstable.
- 9
Constraint systems need to know intent: Hands must not intersect is wrong when contact is phonemic.
- 10
Duration lives between keyframes: A static sign needs two.
- 11
Overlapping radial fields that sum will double somewhere: Take the max.
- 12
Distrust suspiciously good results: Sweep every new parameter as a wiring test, and instrument your instruments.
Resources
Fritsch, F. N., and Carlson, R. E. (1980): Monotone Piecewise Cubic Interpolation. SIAM J. Numer. Anal. 17(2).
glTF 2.0 specification: Khronos Group. Skins, morph targets, coordinate conventions.
Rigify documentation: Blender manual, for the DEF-, ORG-, MCH- convention behind bug #1.
Next.js dynamic imports: nextjs.org/docs (https://nextjs.org/docs) on ssr: false.
ASL-LEX 2.0: Sevcikova Sehyr, Caselli, Cohen-Goldberg and Emmorey (2021), CC BY-NC 4.0. The contact field behind bug #15.
The rest of the series
This is Part 9 of a 10-part series. The other parts:
- 1
How I Built an AI-Powered ASL Sign Character with Claude Code
- 2
How Blender MCP Works with Claude Code
- 3
Building a Production Ready Sign Language Character
- 4
Designing the Goal Prompt
- 5
Loop Engineering Explained
- 6
Building Natural ASL Animation
- 7
Project Folder Architecture
- 8
Every Prompt Used During Development
- 9
Lessons Learned
Frequently Asked Questions
Why do fingers detach when exporting a Rigify character to glTF?
Rigify parents deform bones to ORG- and MCH- bones. A deform-bones-only export excludes those parents and orphans the children, with no error, since glTF will happily export a forest instead of a tree. Rebuild a single-root deform hierarchy first, reparenting each deform bone to its nearest deform ancestor via the ORG- to DEF- twin naming.
Why does my three.js component break the Next.js build?
three.js touches window and WebGL at module scope, and Next.js pre-renders in Node during the build. Import the component with dynamic(() => import('...'), { ssr: false }) rather than scattering typeof window guards through it.
How do you stop hands passing through the body?
A cleanup pipeline at synthesis time: isolate shared targets, lift out of the body, open crowded hands, enforce a speed limit, route around the body, re-enforce the speed limit, plus runtime resolvers. Order matters: excessive speed is what drives hands through the chest, so slow down before routing. And escape directions must never point backward, which is geometrically valid and anatomically impossible.
Why do my fingers bend the wrong way?
Almost certainly an inverted cross product in the curl axis. cross(dir, palmNormal) and cross(palmNormal, dir) differ by a sign, and both look reasonable in code. Write the geometric argument in a comment so the next person can verify it without re-deriving it.
Why does an open handshape look half-closed?
Because the bind pose probably has slightly curled fingers, and your code treats flexion 0 as apply no rotation. Measure how bent each joint already is at rest and make flexion 0 actively straighten. This also makes the code survive a character re-export.
What causes hands to vibrate when they are close together?
A separation resolver that fixes only the single worst violation. Separating the closest pair promotes a different pair to closest, whose push points elsewhere, and the ranking flips back next frame. Aggregate all violating pairs into one weighted push so the direction changes continuously.
Why does a collision solver oscillate forever?
Two competing set-points with no dead zone. In this project the release logic decayed corrections toward zero while the resolvers pushed toward the constraint surface: targets that never coincide, so the hand crossed the surface on alternate frames. It showed as a clean period-2 sawtooth in one axis with the other two perfectly smooth.
How do I know my metrics are not lying to me?
Sweep every new parameter across a wide range: identical results at every value means it is not being read. Count how often conditional logic fires, since a gate that never fires looks exactly like one that always passes. And treat a suspiciously good result with more suspicion than a bad one; bad results get investigated automatically, good ones get accepted.
If you are shipping software where bugs like these can hide (visual quality, real-time constraints, systems that look right but are not) that is exactly the kind of AI product engineering we ship at ETechViral. The final part of the series draws out the transferable lessons.
- Debugging
- Three.js
- Next.js
- Blender
- Inverse Kinematics
- Collision Detection
- Animation
- glTF
Related articles

Loop Engineering Explained
A six-step framework for running AI agents in iterative loops: trigger, goal, context, tools, verification, stop condition. Built from a real animation project where four of five plausible fixes measured worse than the bug.
14 min read
Building Natural ASL Animation
How English text becomes an ASL performance: parsing, grammar reordering, phonological synthesis, fingerspelling, non-manual markers, gaze, interpolation and the cleanup passes that keep hands out of the chest.
18 min read
Building a Production Ready Sign Language Character
What a 3D character actually needs to sign ASL: finger topology, deform hierarchy, facial openings, shape keys, and how to export a 400-bone Rigify rig to a web-sized GLB without breaking the hands.
15 min read