@woosh/meep-engine 2.140.0 → 2.141.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/quaternion/quat3_multiply.d.ts +21 -0
  3. package/src/core/geom/3d/quaternion/quat3_multiply.d.ts.map +1 -0
  4. package/src/core/geom/3d/quaternion/quat3_multiply.js +25 -0
  5. package/src/engine/physics/PLAN.md +152 -35
  6. package/src/engine/physics/REVIEW_002.md +151 -0
  7. package/src/engine/physics/constraint/DofMode.d.ts +28 -0
  8. package/src/engine/physics/constraint/DofMode.d.ts.map +1 -0
  9. package/src/engine/physics/constraint/DofMode.js +35 -0
  10. package/src/engine/physics/constraint/solve_constraints.d.ts +16 -0
  11. package/src/engine/physics/constraint/solve_constraints.d.ts.map +1 -0
  12. package/src/engine/physics/constraint/solve_constraints.js +436 -0
  13. package/src/engine/physics/ecs/Joint.d.ts +179 -0
  14. package/src/engine/physics/ecs/Joint.d.ts.map +1 -0
  15. package/src/engine/physics/ecs/Joint.js +234 -0
  16. package/src/engine/physics/ecs/PhysicsSystem.d.ts +52 -0
  17. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  18. package/src/engine/physics/ecs/PhysicsSystem.js +126 -4
  19. package/src/engine/physics/fluid/FluidField.d.ts +14 -10
  20. package/src/engine/physics/fluid/FluidField.d.ts.map +1 -1
  21. package/src/engine/physics/fluid/FluidField.js +14 -10
  22. package/src/engine/physics/fluid/FluidSimulator.d.ts.map +1 -1
  23. package/src/engine/physics/fluid/FluidSimulator.js +0 -1
  24. package/src/engine/physics/fluid/solver/v3_grid_compute_solid_neighbour_mask.d.ts +17 -10
  25. package/src/engine/physics/fluid/solver/v3_grid_compute_solid_neighbour_mask.d.ts.map +1 -1
  26. package/src/engine/physics/fluid/solver/v3_grid_compute_solid_neighbour_mask.js +18 -11
  27. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure.d.ts +13 -10
  28. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure.d.ts.map +1 -1
  29. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure.js +18 -13
  30. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_pcg.d.ts +4 -3
  31. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_pcg.d.ts.map +1 -1
  32. package/src/engine/physics/fluid/solver/v3_grid_solve_pressure_pcg.js +15 -11
  33. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.d.ts +24 -22
  34. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.d.ts.map +1 -1
  35. package/src/engine/physics/fluid/solver/v3_grid_subtract_pressure_gradient.js +26 -22
  36. package/src/engine/physics/island/IslandBuilder.d.ts +4 -1
  37. package/src/engine/physics/island/IslandBuilder.d.ts.map +1 -1
  38. package/src/engine/physics/island/IslandBuilder.js +33 -16
  39. package/src/engine/physics/narrowphase/box_box_manifold.d.ts.map +1 -1
  40. package/src/engine/physics/narrowphase/box_box_manifold.js +27 -1
  41. package/src/engine/physics/narrowphase/narrowphase_step.d.ts +33 -0
  42. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  43. package/src/engine/physics/narrowphase/narrowphase_step.js +75 -0
  44. package/src/engine/physics/solver/solve_contacts.d.ts +28 -0
  45. package/src/engine/physics/solver/solve_contacts.d.ts.map +1 -1
  46. package/src/engine/physics/solver/solve_contacts.js +169 -1
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "description": "Pure JavaScript game engine. Fully featured and production ready.",
7
7
  "type": "module",
8
8
  "author": "Alexander Goldring",
9
- "version": "2.140.0",
9
+ "version": "2.141.0",
10
10
  "main": "build/meep.module.js",
11
11
  "module": "build/meep.module.js",
12
12
  "exports": {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Hamilton product of two unit quaternions: `out = a ⊗ b`.
3
+ *
4
+ * Composition order is the usual rotation convention: the product `a ⊗ b`
5
+ * applies `b` first, then `a` (i.e. `(a⊗b)·v·(a⊗b)* == a·(b·v·b*)·a*`). So to
6
+ * rotate a vector by `q1` and then by `q2`, compose `q2 ⊗ q1`.
7
+ *
8
+ * Sign convention: `(x, y, z, w)` — `w` last, matching {@link Quaternion} and
9
+ * the rest of the `core/geom/3d/quaternion/` family.
10
+ *
11
+ * `out` may alias neither `a` nor `b` component-wise (the result is computed
12
+ * from all eight inputs before any write, so passing the same backing array
13
+ * via different offsets is safe only if the ranges don't overlap).
14
+ *
15
+ * @param {number[]|Float32Array|Float64Array} out
16
+ * @param {number} out_offset offset into `out`; receives 4 floats (x, y, z, w)
17
+ * @param {number} ax @param {number} ay @param {number} az @param {number} aw
18
+ * @param {number} bx @param {number} by @param {number} bz @param {number} bw
19
+ */
20
+ export function quat3_multiply(out: number[] | Float32Array | Float64Array, out_offset: number, ax: number, ay: number, az: number, aw: number, bx: number, by: number, bz: number, bw: number): void;
21
+ //# sourceMappingURL=quat3_multiply.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quat3_multiply.d.ts","sourceRoot":"","sources":["../../../../../../src/core/geom/3d/quaternion/quat3_multiply.js"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,oCALW,MAAM,EAAE,GAAC,YAAY,GAAC,YAAY,cAClC,MAAM,MACN,MAAM,MAAa,MAAM,MAAa,MAAM,MAAa,MAAM,MAC/D,MAAM,MAAa,MAAM,MAAa,MAAM,MAAa,MAAM,QAOzE"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Hamilton product of two unit quaternions: `out = a ⊗ b`.
3
+ *
4
+ * Composition order is the usual rotation convention: the product `a ⊗ b`
5
+ * applies `b` first, then `a` (i.e. `(a⊗b)·v·(a⊗b)* == a·(b·v·b*)·a*`). So to
6
+ * rotate a vector by `q1` and then by `q2`, compose `q2 ⊗ q1`.
7
+ *
8
+ * Sign convention: `(x, y, z, w)` — `w` last, matching {@link Quaternion} and
9
+ * the rest of the `core/geom/3d/quaternion/` family.
10
+ *
11
+ * `out` may alias neither `a` nor `b` component-wise (the result is computed
12
+ * from all eight inputs before any write, so passing the same backing array
13
+ * via different offsets is safe only if the ranges don't overlap).
14
+ *
15
+ * @param {number[]|Float32Array|Float64Array} out
16
+ * @param {number} out_offset offset into `out`; receives 4 floats (x, y, z, w)
17
+ * @param {number} ax @param {number} ay @param {number} az @param {number} aw
18
+ * @param {number} bx @param {number} by @param {number} bz @param {number} bw
19
+ */
20
+ export function quat3_multiply(out, out_offset, ax, ay, az, aw, bx, by, bz, bw) {
21
+ out[out_offset] = aw * bx + ax * bw + ay * bz - az * by;
22
+ out[out_offset + 1] = aw * by - ax * bz + ay * bw + az * bx;
23
+ out[out_offset + 2] = aw * bz + ax * by - ay * bx + az * bw;
24
+ out[out_offset + 3] = aw * bw - ax * bx - ay * by - az * bz;
25
+ }
@@ -257,21 +257,30 @@ Architectural references for design choices:
257
257
  the knot already uses the exact closed-form box-triangle solver (P1.1b);
258
258
  the problem is purely that TGS freezes *which* feature is in contact across
259
259
  substeps. The common concave case — a convex dynamic body on static concave
260
- terrain — is unaffected (the convex side's feature is stable). The fix is
261
- per-substep contact re-detection for pairs involving a concave body
262
- (re-running narrowphase, or at least re-selecting the deepest triangle,
263
- inside the substep loop) while convex pairs keep the cheap analytic
264
- refresh a hybrid the substep architecture already accommodates. Medium+
265
- *perfectly* axis-aligned cube stacks can fail to fully settle (and so never
266
- sleep), but *erratically* with height and exact placement — a 5-stack may
267
- jitter while a 7-stack sleeps, and a sub-mm gap flips the outcome. That
268
- chaotic, configuration-sensitive signature is box-box contact-point jitter
269
- (Sutherland-Hodgman clipping selecting different points frame to frame),
270
- NOT the solver confirmed by running the same stacks through both the
271
- single-step and TGS paths. It's the separate box-box-manifold robustness /
272
- stable-feature-ID backlog item, independent of TGS. Realistic (slightly
273
- perturbed, mixed-shape) stacks are unaffected; mass ratios up to ~100:1 and
274
- 4-/7-/8-cube aligned stacks settle and sleep cleanly under TGS.
260
+ terrain — is unaffected (the convex side's feature is stable), and that is
261
+ the only concave case the engine targets.
262
+
263
+ **Interim fix (implemented): per-substep concave re-detection.** For
264
+ contact pairs involving a concave body, the substep loop re-runs the
265
+ concave narrowphase geometry at the current substep pose (instead of the
266
+ analytic refresh that freezes the feature) and re-prepares those contacts
267
+ from the fresh witness/normal/depth so the contact normal tracks the
268
+ rocking body and no energy is pumped in. Convex pairs keep the cheap
269
+ analytic refresh. This is ~Nx narrowphase cost on concave-involved pairs
270
+ (acceptable they're rare), gated by collider convexity. Un-skips the
271
+ torus-knot dynamic-settle test.
272
+
273
+ **Better long-term fix: convex collision proxies (not raw concave).** Every
274
+ major engine (Box2D, Jolt, PhysX, Rapier) requires dynamic bodies to be
275
+ convex or convex-decomposed; raw concave meshes are static-only. The right
276
+ granularity is a *few* convex pieces — NOT the thousands of tets a
277
+ volumetric mesher produces (tet count ≈ collider/BVH-leaf count, which
278
+ explodes the broadphase for an awake body; tet meshing is for a future
279
+ FEM/soft-body subsystem, not rigid collision). See the "Convex collision
280
+ proxies for dynamic concave bodies" backlog item — a 3D convex hull builder
281
+ (single-hull proxy covers most dynamic objects) plus an optional
282
+ few-hull (V-HACD-style) decomposition. Those supersede the interim
283
+ per-substep re-detection once built.
275
284
 
276
285
  ---
277
286
 
@@ -331,31 +340,121 @@ scaffolding is in place.
331
340
  for convex shapes (cheaper, no manifold-lifecycle churn) but is
332
341
  only as good as the frozen normal — see the concave caveat below.
333
342
 
334
- Remaining (Phases 4–6, backlog):
335
- - More regression coverage: heavy-on-light *pyramid*, a
336
- ragdoll-stub once joints exist.
337
- - **Per-substep contact re-detection for concave pairs** to lift the
338
- dynamic-concave-body limitation (see Limitations) and un-skip the
339
- torus-knot dynamic-settle test. The analytic refresh freezes *which*
340
- triangle is the contact feature across substeps, which is wrong for
341
- a body rocking on a mesh; convex pairs keep the cheap analytic path.
342
- - REVIEW_002 retrospective.
343
+ Follow-ups since the core landed:
344
+ - [x] **Box-box SAT reference tie-break deadband** — aligned cube
345
+ stacks (4–10 high) now settle to zero velocity and sleep; the
346
+ reference-face flip-flop that creeped/toppled them is gone.
347
+ - [x] **Per-substep contact re-detection for concave pairs** — lifts
348
+ the dynamic-concave-body limitation; the torus-knot dynamic-settle
349
+ test is un-skipped. Concave pairs re-run narrowphase geometry each
350
+ substep (`redetect_concave_contacts`); convex pairs keep the cheap
351
+ analytic refresh.
352
+
353
+ Remaining (Phases 4–6) — now complete:
354
+ - [x] Regression coverage: heavy-on-light pyramid (10× capstone on two
355
+ light cubes settles + sleeps) and a ragdoll-stub (shoulder
356
+ ball-socket + elbow hinge arm hangs, stays articulated, settles).
357
+ - [x] REVIEW_002 retrospective — `engine/physics/REVIEW_002.md`.
343
358
 
344
359
  References: Catto 2018 ("Soft Constraints" GDC talk + the TGS
345
360
  follow-up); Box2D v3 source (`b2ApplyRestitution`, the substep solver
346
361
  stages); Rapier as the closest architectural sibling.
347
362
 
348
- - [ ] **Joints** (distance, hinge, ball-socket, prismatic and beyond).
349
- *To be refined when we get to it.* Joints want the TGS substep
350
- iteration model in place first joint-chain convergence is a TGS
351
- sweet spot and a PGS pain point, and any constraint structures
352
- written against PGS today become migration cost once TGS lands.
353
- The solver loop is already set up to iterate
354
- `contacts joints` and the manifold-style impulse persistence is
355
- there; what's missing is the constraint structures themselves,
356
- joint-limit handling, the motor / soft-constraint surface, and
357
- the authoring API. Plan the phased breakdown once TGS lands —
358
- until then this stays as a visible dependency placeholder.
363
+ - [ ] **Constraints / joints the next major work.** Now unblocked: TGS is
364
+ in (joint-chain convergence is a TGS sweet spot), warm-start +
365
+ per-substep + island machinery is reusable, and the SPOOK compliance
366
+ dial already in the contact solver gives soft/spring constraints
367
+ essentially for free. Target use cases: chains/ropes, ragdolls,
368
+ vehicles (incl. suspension), plus the common mechanical set (doors,
369
+ pistons, welds, grab/drag, winches, drivetrains).
370
+
371
+ **Foundational work (do first): generalise the solver to constraint
372
+ rows.** Today `solver/solve_contacts.js` is hard-coded to the
373
+ contact-shape constraint (normal + 2 friction tangents, ≥0 clamp,
374
+ restitution, penetration bias). Joints are equality / inequality
375
+ constraints on relative velocity at anchors, generally bilateral
376
+ (impulse may be ±) with optional limits and motors. The clean shape —
377
+ and what Jolt / Box2D-v3 do — is a **generic constraint row**: a
378
+ Jacobian (linear + angular parts per body), an effective mass, a bias
379
+ (position error × SPOOK gain, or motor target), and impulse bounds
380
+ `[lo, hi]` (`[0,∞)` for a contact/limit, `(−∞,∞)` for an equality,
381
+ `[−maxForce·h, +maxForce·h]` for a motor). Each joint type just fills
382
+ in its rows; the existing per-body impulse-apply primitive
383
+ (`apply_impulse_to_body` + `world_inverse_inertia_apply`), the
384
+ per-substep warm-start, the islands, and the split-impulse / SPOOK
385
+ position handling are all reused. Contacts become *one* constraint
386
+ type among several rather than the hard-coded path.
387
+
388
+ The specific constraint set, its use-case mapping, and per-type
389
+ architecture-fit assessment are under review (see the constraints
390
+ sketch). High level: ball-socket / distance / spring / weld and the
391
+ grab constraint are near drop-ins on the row machinery; hinge /
392
+ prismatic / cone-twist / motors / limits add angular-row + bounded-row
393
+ mechanics (still within the impulse framework); raycast vehicles,
394
+ conveyor surface-velocity, and gear/pulley coupling are higher-level
395
+ systems or contact modifiers that sit *on top of* the primitives
396
+ rather than being generic rows.
397
+
398
+ **Decision: build ONE configurable 6-DOF constraint** (PhysX D6 / Jolt
399
+ SixDOF), implemented mode-by-mode. The `Joint` ECS component carries
400
+ `dofMode[6]` (3 linear, 3 angular) each `{locked|free|limited|spring|
401
+ motor}` + per-DOF limit/spring/motor config + warm-start accumulators.
402
+ Concrete joints are configs, not new code (ball-socket = lock 3 linear;
403
+ hinge = lock 3 linear + 2 angular; weld = lock 6; cone-twist = lock 3
404
+ linear + limit 3 angular; suspension = spring 1 linear + lock rest).
405
+
406
+ Phasing:
407
+ 1. [x] Constraint-row solver as a **parallel row set** in the TGS
408
+ substep loop (contacts left untouched, not ported — lower risk).
409
+ `constraint/solve_constraints.js` reuses `world_inverse_inertia`,
410
+ per-substep warm-start, and the SPOOK position bias; `Joint`
411
+ component + `link_joint`/`unlink_joint` in PhysicsSystem;
412
+ `jointIterations` knob. Bodies need no collider.
413
+ 2. [x] **LOCKED linear DOFs → ball-socket.** Pendulum (anchor pinned
414
+ to a world pivot, body swings) and a 2-link chain (body↔body,
415
+ joints stay connected, chain hangs) pass. → **chains, ropes,
416
+ pendulums working.**
417
+ 3. [x] LOCKED angular + linear DOFs in the frame basis — **weld,
418
+ hinge, prismatic done**. Joint frame bases
419
+ (`localBasisA`/`localBasisB`); BOTH linear and angular rows now
420
+ resolve in frame A's axes (cleared the world-axis linear debt — the
421
+ solver is fully frame-relative). Angular: relative rotation
422
+ `qD = conj(qA)·qB` → small-angle error, ωB−ωA rows + SPOOK bias.
423
+ Linear: `C·axis` error, vA−vB rows. `asWeld()` / `asHinge(axis)` /
424
+ `asPrismatic(axis)` presets. Verified: weld holds pose + orientation
425
+ against an off-centre torque; hinge swings about its free axis only
426
+ (locked axes < 0.02); prismatic slides along its one free axis,
427
+ locked on the others; all LOCKED-mode tests still green after the
428
+ frame-basis rewrite.
429
+ 4. [ ] LIMITED + MOTOR (bounded rows) → doors, pistons, wheel
430
+ spin/drive, joint ROM.
431
+ 5. [ ] SPRING (SPOOK soft) → suspension, bungees, soft ragdolls.
432
+ 6. [ ] Cone-twist / swing-twist angular limits → ragdolls.
433
+ 7. [ ] Vehicle layer — recommend a **raycast-vehicle controller**
434
+ (raycast + suspension force + tire friction; what most games ship)
435
+ on top of the queries, with simulated wheels via the 6-DOF as an
436
+ option. → vehicles.
437
+ 8. [ ] Extras: pulley, gear, conveyor (contact surface-velocity),
438
+ breakable-joint flag.
439
+
440
+ Foundation gaps — both now closed:
441
+ - [x] **Island integration.** Jointed dynamic-dynamic bodies are
442
+ unioned into one island (`IslandBuilder` Pass 1b), so a chain /
443
+ ragdoll sleeps and wakes as a unit; `__wake_joints` propagates wake
444
+ across a joint when one side is awake and the other asleep
445
+ (e.g. a kinematic/motor driver pulling a sleeping chain). Verified:
446
+ a damped chain settles and both links sleep in one sleep group.
447
+ - [x] **Generation-checked body references.** `solve_joints`,
448
+ `IslandBuilder` Pass 1b and `__wake_joints` all gate on
449
+ `storage.is_valid(packedId)`, so a joint to an unlinked / slot-reused
450
+ body goes inert instead of attaching to the wrong body or crashing.
451
+ Verified: unlinking a jointed body leaves the joint inert and the
452
+ survivor free.
453
+
454
+ References: Catto / Box2D-v3 joint solvers; Jolt's `Constraint` base
455
+ (`SetupVelocityConstraint` / `WarmStartVelocityConstraint` /
456
+ `SolveVelocityConstraint` / `SolvePositionConstraint`); PhysX D6 /
457
+ ODE joint taxonomy.
359
458
 
360
459
  ### Stability
361
460
  - [ ] **Closed-form triangle-vs-primitive solvers**
@@ -391,6 +490,24 @@ scaffolding is in place.
391
490
  Out-of-scope unless / until SAB is universally usable.
392
491
 
393
492
  ### Features
493
+ - [ ] **Convex collision proxies for dynamic concave bodies.** The long-term
494
+ replacement for the interim per-substep concave re-detection (see
495
+ Limitations) — and how every major engine handles dynamic non-convex
496
+ shapes: collide a *few* convex pieces, never the raw concave mesh.
497
+ 1. **3D convex hull builder** (meep has only 2D hulls today —
498
+ `core/geom/2d/convex-hull/`). A single hull of a mesh is one
499
+ collider / one broadphase leaf and covers the overwhelming majority
500
+ of dynamic objects (thrown props, debris). Pairs with the existing
501
+ "Convex hull shape + eigen-inertia" item below.
502
+ 2. **Few-hull (V-HACD-style) approximate convex decomposition** for
503
+ shapes whose concavity matters (a cup, a chair): ~8–64 fat convex
504
+ hulls = 8–64 colliders, two orders of magnitude below a tet mesh.
505
+ Each hull is convex → stable contact feature → the TGS analytic refresh
506
+ is exact → no per-substep re-detection, no rocking. Granularity is the
507
+ whole point: collider/BVH-leaf count must stay small for an *awake*
508
+ dynamic body (the volumetric tet-mesher under `core/geom/3d/tetrahedra/`
509
+ is the wrong tool here — thousands of pieces — and belongs to a future
510
+ FEM/soft-body subsystem, not rigid collision).
394
511
  - [ ] **Convex hull shape** with eigen-based principal-axes inertia
395
512
  derivation. Hooks `matrix_eigenvalues_in_place` from the existing
396
513
  linalg layer.
@@ -0,0 +1,151 @@
1
+ # REVIEW_002 — TGS solver, contact robustness, and constraints
2
+
3
+ Retrospective on the "competent → great" push: promoting the solver from
4
+ single-step PGS to substepped TGS, fixing the contact-robustness issues that
5
+ surfaced, and standing up the 6-DOF constraint subsystem. Companion to the
6
+ engine-comparison docs (`CANNON_REVIEW`, `RAPIER_REVIEW`, `JOLT_REVIEW`,
7
+ `BULLET_REVIEW`); this one is a build retrospective, not a comparison.
8
+
9
+ Scope: the work tracked in `PLAN.md` under "Solver quality" + "Constraints /
10
+ joints". What landed, the hard-won lessons, where the implementation
11
+ deviated from the original plan and why, and what's deferred.
12
+
13
+ ---
14
+
15
+ ## What landed
16
+
17
+ **TGS (Temporal Gauss-Seidel), Phases 1–3.** The solver is now a staged
18
+ pipeline driven by a substep loop:
19
+ `prepare → per substep [redetect-concave / refresh-convex → warm-start →
20
+ solve-velocity → solve-position] → restitution`. Defaults: 4 substeps, 4
21
+ velocity + 1 position iteration per substep. Split-impulse position
22
+ correction (pseudo-velocity folded into the pose, never into real velocity);
23
+ one-shot restitution; SPOOK compliance as the soft-constraint dial.
24
+
25
+ **Contact robustness.**
26
+ - Box-box SAT reference tie-break deadband → aligned cube stacks 4–10 high
27
+ settle to zero velocity and sleep.
28
+ - Per-substep concave re-detection → a dynamic concave mesh body (torus knot)
29
+ settles instead of rocking.
30
+
31
+ **Constraints — 6-DOF configurable joint** (PhysX D6 / Jolt SixDOF model):
32
+ one constraint type, each of 3 linear + 3 angular DOFs independently
33
+ locked/free/limited/spring/motor. Landed modes: LOCKED linear (ball-socket)
34
+ and LOCKED angular (hinge, weld). Solved as a parallel row set inside the
35
+ same substep loop; island-integrated; generation-checked against stale body
36
+ references.
37
+
38
+ Coverage went ~698 → 720+ physics tests, all green. Falling-tower bench
39
+ unchanged (~48 ms / 1000 active bodies).
40
+
41
+ ---
42
+
43
+ ## Hard-won lessons
44
+
45
+ These cost real debugging and are the reason the code looks the way it does.
46
+
47
+ 1. **Per-substep warm-start is mandatory under substepping.** The first
48
+ substep attempt warm-started once per outer step and applied gravity per
49
+ substep. That mismatch — replaying a *full-frame* impulse against a
50
+ *single substep* of gravity — over-pushes resting contacts and **explodes**
51
+ deep stacks (a 10-cube stack hit 9 m of drift / 13 m/s). The fix: gravity
52
+ per substep **and** warm-start per substep, so each substep's gravity and
53
+ each substep's replayed impulse cancel exactly at rest. A resting body
54
+ holds at `v = 0`, `j_n ≈ m·g·h`.
55
+
56
+ 2. **Restitution must gate on the running-max normal impulse, not the
57
+ end-of-loop value.** With per-substep warm-start, a transient collision's
58
+ accumulated `j_n` relaxes back to ~0 by the end of the loop (no sustained
59
+ load to hold it), so gating restitution on the final `j_n` silently killed
60
+ every bounce. Tracking `maxNormalImpulse` over the whole step (Box2D-v3's
61
+ trick) is what makes restitution fire.
62
+
63
+ 3. **Analytic separation re-derivation is great for convex, wrong for
64
+ dynamic concave.** Re-deriving each substep's penetration from frozen
65
+ witness anchors + current pose (no narrowphase re-run) is cheap and exact
66
+ *while the contact feature is stable* — true for a convex primitive under
67
+ the small per-step motion. For a concave body rocking on a mesh, the
68
+ supporting triangle (and its normal) genuinely changes mid-step, so
69
+ freezing it pumps energy in. Resolution: convex pairs use the cheap
70
+ analytic refresh; concave-involved pairs re-run narrowphase geometry each
71
+ substep (`redetect_concave_contacts`). Hybrid, gated by collider convexity.
72
+
73
+ 4. **The aligned-stack instability was not the solver.** Cube stacks crept
74
+ and toppled; the instinct was "solver convergence". Dumping
75
+ `box_box_manifold` across a settling pair showed the normal and contact
76
+ *count* were stable, but the *reference face* flip-flopped between A and B
77
+ each frame (their SAT overlaps are exactly equal for aligned boxes, so
78
+ float noise from a sub-degree wobble decided the winner). That reordered
79
+ the contacts → flipped the Gauss-Seidel sweep order → alternating bias →
80
+ creep. A 6-line relative+absolute tie-break deadband (bias ties toward the
81
+ earlier-tested axis) fixed stacks 4–10. **Lesson: diagnose the manifold
82
+ before blaming the solver.**
83
+
84
+ 5. **Granularity is everything for dynamic concave.** The first instinct
85
+ ("decompose the mesh into the exact Delaunay tets we already have") is
86
+ wrong: a Suzanne is ~8000 tets → ~8000 colliders → ~8000 dynamic-BVH
87
+ leaves refit every frame. Tet meshing is a *volumetric* (FEM/soft-body)
88
+ tool. Rigid collision wants the *fewest* convex pieces — a single convex
89
+ hull for most dynamic objects, or a few-hull (V-HACD-style) decomposition.
90
+ Recorded as the long-term path; the per-substep re-detection is the
91
+ interim.
92
+
93
+ 6. **One configurable 6-DOF constraint beats N joint types.** The row math is
94
+ contact-shaped (Jacobian + effective mass + bias + bounds), so it slots
95
+ into the existing solver. Concrete joints become *config*: ball-socket =
96
+ lock 3 linear; hinge = lock 3 linear + 2 angular; weld = lock 6. This
97
+ minimises distinct code paths — the whole point.
98
+
99
+ 7. **Sign conventions: linear A−B, angular B−A.** The linear rows use
100
+ "A minus B" (impulse +to A); the angular rows use "B minus A" (impulse
101
+ +to B). Each is internally consistent — the body-order difference just
102
+ folds the sign in. Derived carefully against the SPOOK Baumgarte target
103
+ (`dC/dt = vrel`); both worked first try once written down.
104
+
105
+ ---
106
+
107
+ ## Deviations from the original plan (and why)
108
+
109
+ - **Joints run as a parallel row set, not by porting contacts onto a shared
110
+ constraint base.** The plan floated either. Porting the working,
111
+ well-tuned contact path carried regression risk for no immediate benefit;
112
+ running joints as a second row family in the same substep loop gets the
113
+ coupling (shared warm-start / islands / substep cadence) at far lower risk.
114
+ Unifying remains optional.
115
+
116
+ - **Convex contacts use analytic refresh, not per-substep match-and-merge.**
117
+ The plan said "substeps refresh contacts via match-and-merge". Analytic
118
+ re-derivation is cheaper and avoids manifold-lifecycle churn for the convex
119
+ majority; match-and-merge-style re-detection is reserved for the concave
120
+ minority (lesson 3).
121
+
122
+ - **Dynamic concave: per-substep re-detection now, convex proxies later** —
123
+ not the tet-compound idea first sketched (lesson 5).
124
+
125
+ ---
126
+
127
+ ## Deferred (with rationale)
128
+
129
+ - **Constraints**: prismatic (needs frame-basis linear rows — today's linear
130
+ locks use world axes, exact only when all 3 are locked), limits, motors,
131
+ springs (SPOOK soft), swing-twist (ragdoll cone), the raycast-vehicle
132
+ layer, and extras (pulley/gear/conveyor/breakable). Sequenced in `PLAN.md`.
133
+ - **Trajectory accuracy**: gravity is substepped, so ballistic integration is
134
+ at the substep rate — good. No higher-order integrator pursued.
135
+ - **Per-island parallel solve / CCD shape-cast / closed-form
136
+ triangle-vs-primitive remainder**: unchanged backlog items.
137
+ - **Convex hull builder + few-hull decomposition**: the production answer for
138
+ dynamic concave (lesson 5), not yet built.
139
+
140
+ ---
141
+
142
+ ## Bottom line
143
+
144
+ The engine moved from "competent PGS" to a substepped TGS solver with stable
145
+ stacks, accurate restitution, robust mass ratios, settling dynamic concave
146
+ bodies, and a working 6-DOF constraint (ball-socket / hinge / weld) — i.e.
147
+ chains, ropes, and the structural joints for ragdolls and mechanisms. The
148
+ costliest mistakes were all about *substepping invariants* (warm-start and
149
+ restitution-gating cadence) and *not trusting assumptions* (the stack bug was
150
+ narrowphase, not the solver). The remaining constraint work is incremental
151
+ on a validated framework.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Per-degree-of-freedom mode for a 6-DOF constraint ({@link Joint }).
3
+ *
4
+ * A 6-DOF constraint has 3 linear DOFs (translation of body B's anchor frame
5
+ * relative to body A's, along A's frame axes) and 3 angular DOFs (relative
6
+ * rotation, swing-twist decomposed). Each DOF independently takes one of these
7
+ * modes — which is how a single configurable constraint expresses the whole
8
+ * joint taxonomy (PhysX D6 / Jolt SixDOF / Bullet Generic6DOF):
9
+ *
10
+ * - ball-socket = lock 3 linear
11
+ * - hinge = lock 3 linear + 2 angular
12
+ * - prismatic = lock 2 linear + 3 angular
13
+ * - weld = lock all 6
14
+ * - cone-twist = lock 3 linear + limit 3 angular
15
+ * - suspension = spring 1 linear + lock the rest
16
+ *
17
+ * Implementation lands mode-by-mode: LOCKED first (covers ball-socket / hinge
18
+ * / prismatic / weld), then LIMITED, SPRING, MOTOR.
19
+ */
20
+ export type DofMode = number;
21
+ export namespace DofMode {
22
+ let LOCKED: number;
23
+ let FREE: number;
24
+ let LIMITED: number;
25
+ let SPRING: number;
26
+ let MOTOR: number;
27
+ }
28
+ //# sourceMappingURL=DofMode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DofMode.d.ts","sourceRoot":"","sources":["../../../../../src/engine/physics/constraint/DofMode.js"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;sBAqBU,MAAM"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Per-degree-of-freedom mode for a 6-DOF constraint ({@link Joint}).
3
+ *
4
+ * A 6-DOF constraint has 3 linear DOFs (translation of body B's anchor frame
5
+ * relative to body A's, along A's frame axes) and 3 angular DOFs (relative
6
+ * rotation, swing-twist decomposed). Each DOF independently takes one of these
7
+ * modes — which is how a single configurable constraint expresses the whole
8
+ * joint taxonomy (PhysX D6 / Jolt SixDOF / Bullet Generic6DOF):
9
+ *
10
+ * - ball-socket = lock 3 linear
11
+ * - hinge = lock 3 linear + 2 angular
12
+ * - prismatic = lock 2 linear + 3 angular
13
+ * - weld = lock all 6
14
+ * - cone-twist = lock 3 linear + limit 3 angular
15
+ * - suspension = spring 1 linear + lock the rest
16
+ *
17
+ * Implementation lands mode-by-mode: LOCKED first (covers ball-socket / hinge
18
+ * / prismatic / weld), then LIMITED, SPRING, MOTOR.
19
+ *
20
+ * @author Alex Goldring
21
+ * @copyright Company Named Limited (c) 2026
22
+ * @enum {number}
23
+ */
24
+ export const DofMode = {
25
+ /** Constrained to zero (relative position/angle held at the rest value). */
26
+ LOCKED: 0,
27
+ /** Unconstrained — the DOF moves freely. */
28
+ FREE: 1,
29
+ /** Free within `[lower, upper]`, constrained (one-sided) at the limits. */
30
+ LIMITED: 2,
31
+ /** A soft spring (stiffness + damping) pulling toward a rest target. */
32
+ SPRING: 3,
33
+ /** Driven toward a target velocity, bounded by a maximum force. */
34
+ MOTOR: 4,
35
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Solve every joint for one substep: recompute geometry at the current poses,
3
+ * replay the per-substep warm-start, and run `iters` velocity iterations of
4
+ * the locked linear DOFs.
5
+ *
6
+ * Called once per substep from `PhysicsSystem.fixedUpdate`, after the contact
7
+ * solve so the two share the substep / warm-start cadence.
8
+ *
9
+ * @param {Joint[]} joints live joints (sparse array; holes skipped)
10
+ * @param {PhysicsSystem} system reads `__bodies` / `__transforms` / index map
11
+ * @param {number} dt_sub substep size in seconds (the SPOOK gain is derived
12
+ * from it, matching the contact solver's per-substep position stiffness)
13
+ * @param {number} iters velocity iterations
14
+ */
15
+ export function solve_joints(joints: Joint[], system: PhysicsSystem, dt_sub: number, iters: number): void;
16
+ //# sourceMappingURL=solve_constraints.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"solve_constraints.d.ts","sourceRoot":"","sources":["../../../../../src/engine/physics/constraint/solve_constraints.js"],"names":[],"mappings":"AAyJA;;;;;;;;;;;;;GAaG;AACH,qCANW,OAAO,iCAEP,MAAM,SAEN,MAAM,QA8QhB"}