# REVIEW_002 — TGS solver, contact robustness, and constraints

Retrospective on the "competent → great" push: promoting the solver from
single-step PGS to substepped TGS, fixing the contact-robustness issues that
surfaced, and standing up the 6-DOF constraint subsystem. Companion to the
engine-comparison docs (`CANNON_REVIEW`, `RAPIER_REVIEW`, `JOLT_REVIEW`,
`BULLET_REVIEW`); this one is a build retrospective, not a comparison.

Scope: the work tracked in `PLAN.md` under "Solver quality" + "Constraints /
joints". What landed, the hard-won lessons, where the implementation
deviated from the original plan and why, and what's deferred.

---

## What landed

**TGS (Temporal Gauss-Seidel), Phases 1–3.** The solver is now a staged
pipeline driven by a substep loop:
`prepare → per substep [redetect-concave / refresh-convex → warm-start →
solve-velocity → solve-position] → restitution`. Defaults: 4 substeps, 4
velocity + 1 position iteration per substep. Split-impulse position
correction (pseudo-velocity folded into the pose, never into real velocity);
one-shot restitution; SPOOK compliance as the soft-constraint dial.

**Contact robustness.**
- Box-box SAT reference tie-break deadband → aligned cube stacks 4–10 high
  settle to zero velocity and sleep.
- Per-substep concave re-detection → a dynamic concave mesh body (torus knot)
  settles instead of rocking.

**Constraints — 6-DOF configurable joint** (PhysX D6 / Jolt SixDOF model):
one constraint type, each of 3 linear + 3 angular DOFs independently
locked/free/limited/spring/motor. Landed modes: LOCKED linear (ball-socket)
and LOCKED angular (hinge, weld). Solved as a parallel row set inside the
same substep loop; island-integrated; generation-checked against stale body
references.

Coverage went ~698 → 720+ physics tests, all green. Falling-tower bench
unchanged (~48 ms / 1000 active bodies).

---

## Hard-won lessons

These cost real debugging and are the reason the code looks the way it does.

1. **Per-substep warm-start is mandatory under substepping.** The first
   substep attempt warm-started once per outer step and applied gravity per
   substep. That mismatch — replaying a *full-frame* impulse against a
   *single substep* of gravity — over-pushes resting contacts and **explodes**
   deep stacks (a 10-cube stack hit 9 m of drift / 13 m/s). The fix: gravity
   per substep **and** warm-start per substep, so each substep's gravity and
   each substep's replayed impulse cancel exactly at rest. A resting body
   holds at `v = 0`, `j_n ≈ m·g·h`.

2. **Restitution must gate on the running-max normal impulse, not the
   end-of-loop value.** With per-substep warm-start, a transient collision's
   accumulated `j_n` relaxes back to ~0 by the end of the loop (no sustained
   load to hold it), so gating restitution on the final `j_n` silently killed
   every bounce. Tracking `maxNormalImpulse` over the whole step (Box2D-v3's
   trick) is what makes restitution fire.

3. **Analytic separation re-derivation is great for convex, wrong for
   dynamic concave.** Re-deriving each substep's penetration from frozen
   witness anchors + current pose (no narrowphase re-run) is cheap and exact
   *while the contact feature is stable* — true for a convex primitive under
   the small per-step motion. For a concave body rocking on a mesh, the
   supporting triangle (and its normal) genuinely changes mid-step, so
   freezing it pumps energy in. Resolution: convex pairs use the cheap
   analytic refresh; concave-involved pairs re-run narrowphase geometry each
   substep (`redetect_concave_contacts`). Hybrid, gated by collider convexity.

4. **The aligned-stack instability was not the solver.** Cube stacks crept
   and toppled; the instinct was "solver convergence". Dumping
   `box_box_manifold` across a settling pair showed the normal and contact
   *count* were stable, but the *reference face* flip-flopped between A and B
   each frame (their SAT overlaps are exactly equal for aligned boxes, so
   float noise from a sub-degree wobble decided the winner). That reordered
   the contacts → flipped the Gauss-Seidel sweep order → alternating bias →
   creep. A 6-line relative+absolute tie-break deadband (bias ties toward the
   earlier-tested axis) fixed stacks 4–10. **Lesson: diagnose the manifold
   before blaming the solver.**

5. **Granularity is everything for dynamic concave.** The first instinct
   ("decompose the mesh into the exact Delaunay tets we already have") is
   wrong: a Suzanne is ~8000 tets → ~8000 colliders → ~8000 dynamic-BVH
   leaves refit every frame. Tet meshing is a *volumetric* (FEM/soft-body)
   tool. Rigid collision wants the *fewest* convex pieces — a single convex
   hull for most dynamic objects, or a few-hull (V-HACD-style) decomposition.
   Recorded as the long-term path; the per-substep re-detection is the
   interim.

6. **One configurable 6-DOF constraint beats N joint types.** The row math is
   contact-shaped (Jacobian + effective mass + bias + bounds), so it slots
   into the existing solver. Concrete joints become *config*: ball-socket =
   lock 3 linear; hinge = lock 3 linear + 2 angular; weld = lock 6. This
   minimises distinct code paths — the whole point.

7. **Sign conventions: linear A−B, angular B−A.** The linear rows use
   "A minus B" (impulse +to A); the angular rows use "B minus A" (impulse
   +to B). Each is internally consistent — the body-order difference just
   folds the sign in. Derived carefully against the SPOOK Baumgarte target
   (`dC/dt = vrel`); both worked first try once written down.

---

## Deviations from the original plan (and why)

- **Joints run as a parallel row set, not by porting contacts onto a shared
  constraint base.** The plan floated either. Porting the working,
  well-tuned contact path carried regression risk for no immediate benefit;
  running joints as a second row family in the same substep loop gets the
  coupling (shared warm-start / islands / substep cadence) at far lower risk.
  Unifying remains optional.

- **Convex contacts use analytic refresh, not per-substep match-and-merge.**
  The plan said "substeps refresh contacts via match-and-merge". Analytic
  re-derivation is cheaper and avoids manifold-lifecycle churn for the convex
  majority; match-and-merge-style re-detection is reserved for the concave
  minority (lesson 3).

- **Dynamic concave: per-substep re-detection now, convex proxies later** —
  not the tet-compound idea first sketched (lesson 5).

---

## Deferred (with rationale)

- **Constraints**: prismatic (needs frame-basis linear rows — today's linear
  locks use world axes, exact only when all 3 are locked), limits, motors,
  springs (SPOOK soft), swing-twist (ragdoll cone), the raycast-vehicle
  layer, and extras (pulley/gear/conveyor/breakable). Sequenced in `PLAN.md`.
- **Trajectory accuracy**: gravity is substepped, so ballistic integration is
  at the substep rate — good. No higher-order integrator pursued.
- **Per-island parallel solve / CCD shape-cast / closed-form
  triangle-vs-primitive remainder**: unchanged backlog items.
- **Convex hull builder + few-hull decomposition**: the production answer for
  dynamic concave (lesson 5), not yet built.

---

## Bottom line

The engine moved from "competent PGS" to a substepped TGS solver with stable
stacks, accurate restitution, robust mass ratios, settling dynamic concave
bodies, and a working 6-DOF constraint (ball-socket / hinge / weld) — i.e.
chains, ropes, and the structural joints for ragdolls and mechanisms. The
costliest mistakes were all about *substepping invariants* (warm-start and
restitution-gating cadence) and *not trusting assumptions* (the stack bug was
narrowphase, not the solver). The remaining constraint work is incremental
on a validated framework.
