@woosh/meep-engine 3.4.0 → 3.6.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 (66) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/3d/gjk/gjk_epa_penetration.d.ts +30 -3
  3. package/src/core/geom/3d/gjk/gjk_epa_penetration.d.ts.map +1 -1
  4. package/src/core/geom/3d/gjk/gjk_epa_penetration.js +83 -10
  5. package/src/core/geom/3d/gjk/mpr.d.ts +7 -1
  6. package/src/core/geom/3d/gjk/mpr.d.ts.map +1 -1
  7. package/src/core/geom/3d/gjk/mpr.js +76 -13
  8. package/src/engine/Engine.js +2 -2
  9. package/src/engine/graphics/CONTEXT_LOSS_RECOVERY_PLAN.md +1 -1
  10. package/src/engine/graphics3/GraphicsEngine3.d.ts +7 -0
  11. package/src/engine/graphics3/GraphicsEngine3.d.ts.map +1 -1
  12. package/src/engine/graphics3/GraphicsEngine3.js +9 -0
  13. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  14. package/src/engine/physics/narrowphase/narrowphase_step.d.ts.map +1 -1
  15. package/src/engine/physics/narrowphase/narrowphase_step.js +20 -2
  16. package/src/shade/playground/skinned_blas_refit/README.md +56 -33
  17. package/src/shade/playground/skinned_blas_refit/place_character.d.ts +2 -1
  18. package/src/shade/playground/skinned_blas_refit/place_character.d.ts.map +1 -1
  19. package/src/shade/playground/skinned_blas_refit/place_character.js +2 -1
  20. package/src/shade/renderer/scene/bvh/StaticSceneBVH.js +4 -4
  21. package/src/view/graphics/{WebGLContextFailureView.d.ts → GraphicsContextFailureView.d.ts} +2 -2
  22. package/src/view/graphics/GraphicsContextFailureView.d.ts.map +1 -0
  23. package/src/view/graphics/{WebGLContextFailureView.js → GraphicsContextFailureView.js} +1 -1
  24. package/src/core/geom/3d/aabb/aabb3_from_three_geometry.d.ts +0 -7
  25. package/src/core/geom/3d/aabb/aabb3_from_three_geometry.d.ts.map +0 -1
  26. package/src/core/geom/3d/aabb/aabb3_from_three_geometry.js +0 -18
  27. package/src/core/geom/3d/aabb/aabb3_from_threejs_geometry.d.ts +0 -7
  28. package/src/core/geom/3d/aabb/aabb3_from_threejs_geometry.d.ts.map +0 -1
  29. package/src/core/geom/3d/aabb/aabb3_from_threejs_geometry.js +0 -7
  30. package/src/engine/ecs/transform/copy_three_transform.d.ts +0 -7
  31. package/src/engine/ecs/transform/copy_three_transform.d.ts.map +0 -1
  32. package/src/engine/ecs/transform/copy_three_transform.js +0 -15
  33. package/src/engine/graphics/ecs/mesh-v2/DeferredBoundsQueue.d.ts +0 -48
  34. package/src/engine/graphics/ecs/mesh-v2/DeferredBoundsQueue.d.ts.map +0 -1
  35. package/src/engine/graphics/ecs/mesh-v2/DeferredBoundsQueue.js +0 -88
  36. package/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshSystem.d.ts +0 -63
  37. package/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshSystem.d.ts.map +0 -1
  38. package/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshSystem.js +0 -314
  39. package/src/engine/graphics/ecs/mesh-v2/render/adapters/AbstractRenderAdapter.d.ts +0 -67
  40. package/src/engine/graphics/ecs/mesh-v2/render/adapters/AbstractRenderAdapter.d.ts.map +0 -1
  41. package/src/engine/graphics/ecs/mesh-v2/render/adapters/AbstractRenderAdapter.js +0 -130
  42. package/src/engine/graphics/ecs/mesh-v2/render/adapters/SGCacheKey.d.ts +0 -42
  43. package/src/engine/graphics/ecs/mesh-v2/render/adapters/SGCacheKey.d.ts.map +0 -1
  44. package/src/engine/graphics/ecs/mesh-v2/render/adapters/SGCacheKey.js +0 -71
  45. package/src/engine/graphics/ecs/mesh-v2/render/optimization/RuntimeDrawMethodOptimizer.d.ts +0 -20
  46. package/src/engine/graphics/ecs/mesh-v2/render/optimization/RuntimeDrawMethodOptimizer.d.ts.map +0 -1
  47. package/src/engine/graphics/ecs/mesh-v2/render/optimization/RuntimeDrawMethodOptimizer.js +0 -123
  48. package/src/engine/graphics/ecs/mesh-v2/three_object_to_entity_composition.d.ts +0 -8
  49. package/src/engine/graphics/ecs/mesh-v2/three_object_to_entity_composition.d.ts.map +0 -1
  50. package/src/engine/graphics/ecs/mesh-v2/three_object_to_entity_composition.js +0 -55
  51. package/src/engine/graphics/impostors/octahedral/util/load_mesh_for_bake.d.ts +0 -12
  52. package/src/engine/graphics/impostors/octahedral/util/load_mesh_for_bake.d.ts.map +0 -1
  53. package/src/engine/graphics/impostors/octahedral/util/load_mesh_for_bake.js +0 -31
  54. package/src/engine/graphics/sh3/build_injected_shader.d.ts +0 -51
  55. package/src/engine/graphics/sh3/build_injected_shader.d.ts.map +0 -1
  56. package/src/engine/graphics/sh3/build_injected_shader.js +0 -56
  57. package/src/engine/graphics/sh3/fromCubeRenderTarget.d.ts +0 -9
  58. package/src/engine/graphics/sh3/fromCubeRenderTarget.d.ts.map +0 -1
  59. package/src/engine/graphics/sh3/fromCubeRenderTarget.js +0 -142
  60. package/src/engine/graphics/sh3/shader/visualize.frag.d.ts +0 -3
  61. package/src/engine/graphics/sh3/shader/visualize.frag.d.ts.map +0 -1
  62. package/src/engine/graphics/sh3/shader/visualize.frag.js +0 -51
  63. package/src/engine/graphics/sh3/shader/visualize.vert.d.ts +0 -3
  64. package/src/engine/graphics/sh3/shader/visualize.vert.d.ts.map +0 -1
  65. package/src/engine/graphics/sh3/shader/visualize.vert.js +0 -12
  66. package/src/view/graphics/WebGLContextFailureView.d.ts.map +0 -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": "3.4.0",
9
+ "version": "3.6.0",
10
10
  "exports": {
11
11
  "./build/*": "./build/*",
12
12
  "./src/*": "./src/*",
@@ -3,15 +3,42 @@
3
3
  * Writes the unit separation normal (A→B: the direction A must move along
4
4
  * NEGATED to separate — equivalently, negate `out` for the B→A push axis;
5
5
  * the narrowphase sign-check sites canonicalise it against the body centres)
6
- * into `out` and returns the depth (positive on overlap, 0 if separated
7
- * `out` untouched on 0).
6
+ * into `out` and returns the depth. `out` is untouched unless the return is
7
+ * positive.
8
8
  *
9
9
  * @param {Float64Array|number[]} out destination for the unit normal, length ≥ 3
10
10
  * @param {SupportProvider} A world-space support provider (e.g. PosedShape3D)
11
11
  * @param {SupportProvider} B world-space support provider (e.g. PosedShape3D)
12
- * @returns {number} penetration depth, or 0 if separated
12
+ * @returns {number} the penetration depth (> 0) on overlap;
13
+ * {@link GJK_SEPARATED} when GJK PROVED the shapes apart; `0` for no usable
14
+ * axis — EPA degenerated on an established overlap, or GJK reached no
15
+ * verdict at all. `0` is not a separation and callers with a fallback
16
+ * should still run it
13
17
  */
14
18
  export function gjk_epa_penetration(out: Float64Array | number[], A: SupportProvider, B: SupportProvider): number;
19
+ /**
20
+ * Sentinel returned by {@link gjk_epa_penetration} when GJK PROVED the shapes
21
+ * apart — {@link GJK_APART}, a support plane with the whole Minkowski
22
+ * difference on the far side of the origin — as distinct from `0`, "no usable
23
+ * axis": a degenerate EPA, or a GJK that reached no verdict at all.
24
+ *
25
+ * The two need separate values because callers act on them differently. A
26
+ * proof of separation is the final answer and the pair is done; the rest is a
27
+ * gap in this query that a fallback (MPR) may still be able to fill. Note the
28
+ * asymmetry — this is claimed ONLY from the proof, never from GJK's other two
29
+ * non-overlap exits, which are inconclusive rather than negative.
30
+ * Collapsing both to `0`, as this function used to, sent every non-touching
31
+ * pair the broadphase produced into that fallback — where MPR, whose portal
32
+ * argument assumes an overlap it was never told to doubt, answered a question
33
+ * GJK had already settled and reported the gap as a penetration of its own
34
+ * size (see the separating-plane certificate in {@link mpr}).
35
+ *
36
+ * Negative so that the existing `depth > 0` guard at every call site keeps
37
+ * rejecting it unchanged.
38
+ *
39
+ * @type {number}
40
+ */
41
+ export const GJK_SEPARATED: number;
15
42
  /**
16
43
  * A convex shape exposing a world-space support mapping: `support(out, off, dx,
17
44
  * dy, dz)` writes the farthest point of the shape in direction `(dx, dy, dz)`
@@ -1 +1 @@
1
- {"version":3,"file":"gjk_epa_penetration.d.ts","sourceRoot":"","sources":["../../../../../../src/core/geom/3d/gjk/gjk_epa_penetration.js"],"names":[],"mappings":"AAofA;;;;;;;;;;;;GAYG;AACH,yCALW,YAAY,GAAC,MAAM,EAAE,KACrB,eAAe,KACf,eAAe,GACb,MAAM,CAQlB;;;;;;8BAvbY;IAAE,OAAO,SAAW,YAAY,QAAE,MAAM,QAAE,MAAM,QAAE,MAAM,QAAE,MAAM,KAAG,IAAI,CAAA;CAAE"}
1
+ {"version":3,"file":"gjk_epa_penetration.d.ts","sourceRoot":"","sources":["../../../../../../src/core/geom/3d/gjk/gjk_epa_penetration.js"],"names":[],"mappings":"AA2iBA;;;;;;;;;;;;;;;;GAgBG;AACH,yCATW,YAAY,GAAC,MAAM,EAAE,KACrB,eAAe,KACf,eAAe,GACb,MAAM,CA0BlB;AA7DD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,4BAFU,MAAM,CAEgB;;;;;;8BAzdnB;IAAE,OAAO,SAAW,YAAY,QAAE,MAAM,QAAE,MAAM,QAAE,MAAM,QAAE,MAAM,KAAG,IAAI,CAAA;CAAE"}
@@ -81,14 +81,45 @@ const CEN = new Float64Array(3); // polytope centroid (for outward orientation)
81
81
  * @typedef {{ support: function(Float64Array, number, number, number, number): void }} SupportProvider
82
82
  */
83
83
 
84
+ /**
85
+ * {@link gjk} enclosed the origin: the shapes overlap and {@link SIMPLEX} holds
86
+ * a rank-4 simplex EPA can expand from.
87
+ * @type {number}
88
+ */
89
+ const GJK_ENCLOSED = 4;
90
+
91
+ /**
92
+ * {@link gjk} found a support plane with the whole Minkowski difference on the
93
+ * far side of the origin. That is a proof of separation, and the only one of
94
+ * this function's non-overlap exits that is.
95
+ * @type {number}
96
+ */
97
+ const GJK_APART = -1;
98
+
99
+ /**
100
+ * {@link gjk} ran out of road: the search direction collapsed, or the iteration
101
+ * cap hit. Neither says the shapes are apart — the direction collapses when the
102
+ * origin lands exactly ON a simplex feature, which is a TOUCH (two spheres at
103
+ * exactly 2r do this), and the cap is just non-convergence on a smooth support.
104
+ * Callers must treat it as "no answer" and keep whatever fallback they have.
105
+ * @type {number}
106
+ */
107
+ const GJK_INCONCLUSIVE = 0;
108
+
84
109
  /**
85
110
  * GJK intersection. Fills {@link SIMPLEX} with a rank-4 simplex enclosing the
86
- * origin and returns 4 on overlap; returns 0 if the shapes are separated.
111
+ * origin and returns {@link GJK_ENCLOSED} on overlap.
87
112
  * `SIMPLEX` slot 0 is the most-recent point throughout (Muratori convention).
88
113
  *
114
+ * The two non-overlap verdicts are deliberately distinct: only
115
+ * {@link GJK_APART} is a proof, and collapsing it together with
116
+ * {@link GJK_INCONCLUSIVE} silently converts "I could not tell" into "they are
117
+ * not touching" — which drops touching contacts.
118
+ *
89
119
  * @param {SupportProvider} A first support provider
90
120
  * @param {SupportProvider} B second support provider
91
- * @returns {number} 4 if the origin is enclosed (shapes overlap), 0 if separated
121
+ * @returns {number} {@link GJK_ENCLOSED}, {@link GJK_APART}, or
122
+ * {@link GJK_INCONCLUSIVE}
92
123
  */
93
124
  function gjk(A, B) {
94
125
  minkowski_support(SIMPLEX, 0, A, B, 1, 0, 0);
@@ -102,11 +133,11 @@ function gjk(A, B) {
102
133
 
103
134
  for (let iter = 0; iter < GJK_MAX_ITER; iter++) {
104
135
  if (v3_length_sqr(DIR[0], DIR[1], DIR[2]) < DIR_EPS) {
105
- return 0; // search direction collapsed → touching/separated
136
+ return GJK_INCONCLUSIVE; // direction collapsed → touching, or apart; unknown
106
137
  }
107
138
  minkowski_support(_w, 0, A, B, DIR[0], DIR[1], DIR[2]);
108
139
  if (v3_dot(_w[0], _w[1], _w[2], DIR[0], DIR[1], DIR[2]) < 0) {
109
- return 0; // farthest point short of the origin → separated
140
+ return GJK_APART; // farthest point short of the origin → proven separated
110
141
  }
111
142
  // push the new point to slot 0, shifting the existing points down one slot
112
143
  // (copyWithin is memmove-style: the overlapping forward copy is well-defined)
@@ -118,10 +149,10 @@ function gjk(A, B) {
118
149
 
119
150
  count = do_simplex(count);
120
151
  if (count === 0) {
121
- return 4; // origin enclosed by the tetrahedron
152
+ return GJK_ENCLOSED; // origin enclosed by the tetrahedron
122
153
  }
123
154
  }
124
- return 0;
155
+ return GJK_INCONCLUSIVE; // iteration cap → non-convergent, not a separation
125
156
  }
126
157
 
127
158
  /**
@@ -498,23 +529,65 @@ function epa(A, B, out) {
498
529
  return FACE_DIST[best];
499
530
  }
500
531
 
532
+ /**
533
+ * Sentinel returned by {@link gjk_epa_penetration} when GJK PROVED the shapes
534
+ * apart — {@link GJK_APART}, a support plane with the whole Minkowski
535
+ * difference on the far side of the origin — as distinct from `0`, "no usable
536
+ * axis": a degenerate EPA, or a GJK that reached no verdict at all.
537
+ *
538
+ * The two need separate values because callers act on them differently. A
539
+ * proof of separation is the final answer and the pair is done; the rest is a
540
+ * gap in this query that a fallback (MPR) may still be able to fill. Note the
541
+ * asymmetry — this is claimed ONLY from the proof, never from GJK's other two
542
+ * non-overlap exits, which are inconclusive rather than negative.
543
+ * Collapsing both to `0`, as this function used to, sent every non-touching
544
+ * pair the broadphase produced into that fallback — where MPR, whose portal
545
+ * argument assumes an overlap it was never told to doubt, answered a question
546
+ * GJK had already settled and reported the gap as a penetration of its own
547
+ * size (see the separating-plane certificate in {@link mpr}).
548
+ *
549
+ * Negative so that the existing `depth > 0` guard at every call site keeps
550
+ * rejecting it unchanged.
551
+ *
552
+ * @type {number}
553
+ */
554
+ export const GJK_SEPARATED = -1;
555
+
501
556
  /**
502
557
  * Penetration depth + normal between two convex support providers at world pose.
503
558
  * Writes the unit separation normal (A→B: the direction A must move along
504
559
  * NEGATED to separate — equivalently, negate `out` for the B→A push axis;
505
560
  * the narrowphase sign-check sites canonicalise it against the body centres)
506
- * into `out` and returns the depth (positive on overlap, 0 if separated
507
- * `out` untouched on 0).
561
+ * into `out` and returns the depth. `out` is untouched unless the return is
562
+ * positive.
508
563
  *
509
564
  * @param {Float64Array|number[]} out destination for the unit normal, length ≥ 3
510
565
  * @param {SupportProvider} A world-space support provider (e.g. PosedShape3D)
511
566
  * @param {SupportProvider} B world-space support provider (e.g. PosedShape3D)
512
- * @returns {number} penetration depth, or 0 if separated
567
+ * @returns {number} the penetration depth (> 0) on overlap;
568
+ * {@link GJK_SEPARATED} when GJK PROVED the shapes apart; `0` for no usable
569
+ * axis — EPA degenerated on an established overlap, or GJK reached no
570
+ * verdict at all. `0` is not a separation and callers with a fallback
571
+ * should still run it
513
572
  */
514
573
  export function gjk_epa_penetration(out, A, B) {
515
- if (gjk(A, B) < 4) {
574
+ const verdict = gjk(A, B);
575
+
576
+ if (verdict === GJK_APART) {
577
+ return GJK_SEPARATED;
578
+ }
579
+
580
+ if (verdict !== GJK_ENCLOSED) {
581
+ // GJK_INCONCLUSIVE — no simplex to expand and no proof of separation
582
+ // either, so this reports the same "no usable axis" as a degenerate
583
+ // EPA and leaves the caller's fallback in play. Reporting it as
584
+ // GJK_SEPARATED would be an overclaim, and an expensive one: the
585
+ // collapse exit fires on EXACTLY touching pairs (two spheres at 2r),
586
+ // whose resting contact would then be dropped without anything ever
587
+ // looking at the geometry.
516
588
  return 0;
517
589
  }
590
+
518
591
  const depth = epa(A, B, out);
519
592
  return depth > 0 && Number.isFinite(depth) ? depth : 0;
520
593
  }
@@ -39,7 +39,13 @@
39
39
  * any narrowphase call site.
40
40
  *
41
41
  * On failure modes:
42
- * - Returns `false` if the shapes don't overlap.
42
+ * - Returns `false` if the shapes don't overlap, decided by a
43
+ * separating-plane certificate — the support of the Mink-diff along
44
+ * the portal normal fails to reach the origin — which is one exact
45
+ * evaluation carrying none of the refinement's error. A miss leaves
46
+ * `result` untouched: the separation distance is deliberately NOT
47
+ * written, because every caller reads the depth as |MTV| and cannot
48
+ * tell a gap from a penetration once the sign is gone.
43
49
  * - On a degenerate Mink-diff (shapes touching at a point, perfectly
44
50
  * coincident centres with collinear support points), returns
45
51
  * `true` with a small fallback MTV — same approach as EPA's
@@ -1 +1 @@
1
- {"version":3,"file":"mpr.d.ts","sourceRoot":"","sources":["../../../../../../src/core/geom/3d/gjk/mpr.js"],"names":[],"mappings":"AAsDA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,4BANW,YAAY,iBACZ,MAAM,iDAGJ,OAAO,CAqQnB"}
1
+ {"version":3,"file":"mpr.d.ts","sourceRoot":"","sources":["../../../../../../src/core/geom/3d/gjk/mpr.js"],"names":[],"mappings":"AAsDA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AACH,4BANW,YAAY,iBACZ,MAAM,iDAGJ,OAAO,CA8TnB"}
@@ -93,7 +93,13 @@ const V4 = new Float64Array(3);
93
93
  * any narrowphase call site.
94
94
  *
95
95
  * On failure modes:
96
- * - Returns `false` if the shapes don't overlap.
96
+ * - Returns `false` if the shapes don't overlap, decided by a
97
+ * separating-plane certificate — the support of the Mink-diff along
98
+ * the portal normal fails to reach the origin — which is one exact
99
+ * evaluation carrying none of the refinement's error. A miss leaves
100
+ * `result` untouched: the separation distance is deliberately NOT
101
+ * written, because every caller reads the depth as |MTV| and cannot
102
+ * tell a gap from a penetration once the sign is gone.
97
103
  * - On a degenerate Mink-diff (shapes touching at a point, perfectly
98
104
  * coincident centres with collinear support points), returns
99
105
  * `true` with a small fallback MTV — same approach as EPA's
@@ -116,6 +122,17 @@ export function mpr(result, result_offset, shape_a, shape_b) {
116
122
  // origins are interior to their geometry — true for spheres,
117
123
  // boxes, capsules, and any closed mesh whose bounding box contains
118
124
  // its centroid.
125
+ //
126
+ // This is a PRECONDITION, not a preference, and nothing here can
127
+ // check it: every argument below assumes the ray V0→origin starts
128
+ // inside. An authored ConvexHullShape3D whose vertices sit far from
129
+ // its own local origin (a level brush keeping map coordinates under
130
+ // an identity collider transform, say) violates it, and MPR then
131
+ // returns a confidently wrong MTV — measured at 17.8 m, the slab's
132
+ // half-width, for a 0.05 m overlap. Callers that can answer overlap
133
+ // some other way should do so and reach for MPR only when that
134
+ // answer is genuinely unavailable; {@link narrowphase_step} keeps it
135
+ // behind a GJK verdict for exactly this reason.
119
136
  V0[0] = shape_a.px - shape_b.px;
120
137
  V0[1] = shape_a.py - shape_b.py;
121
138
  V0[2] = shape_a.pz - shape_b.pz;
@@ -281,10 +298,22 @@ export function mpr(result, result_offset, shape_a, shape_b) {
281
298
  const inv_pn = 1 / Math.sqrt(pn_len_sqr);
282
299
  pn_x *= inv_pn; pn_y *= inv_pn; pn_z *= inv_pn;
283
300
 
284
- // Perpendicular distance from the origin to the portal plane,
285
- // = dot(V1, portal_normal). This is the candidate depth on
286
- // every iteration; we accept it once V4 fails to push the
287
- // portal further.
301
+ // Signed offset of the portal plane from the origin along the
302
+ // outward normal, = dot(V1, portal_normal), and the depth this
303
+ // returns once the portal settles. In exact arithmetic its sign
304
+ // would also BE the overlap test — V0 is interior and the ray
305
+ // V0→origin pierces the portal, so the origin is inside the
306
+ // Mink-diff exactly when it lies on V0's side of that plane —
307
+ // and a negative value would mean the ray left the Mink-diff
308
+ // before reaching the origin, making |portal_dist| a SEPARATION
309
+ // rather than a depth.
310
+ //
311
+ // It is not the test used, because this is an iterate: near zero
312
+ // its sign is refinement noise, not geometry. The certificate
313
+ // below decides instead, and getting a magnitude out of a
314
+ // separated pair — which every caller then reads as a depth,
315
+ // |MTV| having thrown the sign away — is the failure that made
316
+ // this loop worth re-deriving.
288
317
  const portal_dist = V1[0] * pn_x + V1[1] * pn_y + V1[2] * pn_z;
289
318
 
290
319
  // New support V4 in the portal normal direction.
@@ -292,8 +321,41 @@ export function mpr(result, result_offset, shape_a, shape_b) {
292
321
 
293
322
  const v4_dist = V4[0] * pn_x + V4[1] * pn_y + V4[2] * pn_z;
294
323
 
324
+ // Separating-plane certificate — and it has to be tested BEFORE
325
+ // the convergence branch below. If the farthest point of the
326
+ // Mink-diff along pn does not reach the origin, the plane
327
+ // through the origin with normal pn separates the two shapes,
328
+ // which is a proof of a miss. It can never reject a true
329
+ // overlap: the origin is then inside the Mink-diff, whose
330
+ // support is ≥ 0 in EVERY direction.
331
+ //
332
+ // The order is the whole point. A small shape near a large flat
333
+ // face seeds a portal already within MPR_TOLERANCE of the
334
+ // support plane, so the convergence test succeeds on the first
335
+ // iteration and this proof was never reached — the old order
336
+ // returned `true` with a negative portal_dist, and callers
337
+ // (which read the depth as |MTV|) saw the gap as a penetration
338
+ // of exactly its own size.
339
+ if (v4_dist < 0) {
340
+ return false;
341
+ }
342
+
295
343
  // Convergence: V4 doesn't extend the portal beyond the current
296
- // face (within tolerance). Emit MTV = normal × depth.
344
+ // face (within tolerance), so the portal face is the closest
345
+ // face of the Mink-diff and its offset is the depth.
346
+ //
347
+ // Note what does NOT appear here: a `portal_dist <= 0` gate. It
348
+ // is tempting — a negative offset is exactly what a miss looks
349
+ // like — but portal_dist is the one quantity in this loop that
350
+ // carries the refinement's error, and near zero that error is
351
+ // the whole signal. A capsule resting exactly on a floor
352
+ // converges at portal_dist ≈ −3e-6 with v4_dist ≈ +2.5e-6: a
353
+ // real touching contact whose sign is pure noise, which such a
354
+ // gate drops, taking the resting contact with it. The certificate
355
+ // above is error-free (one exact support evaluation) and is the
356
+ // right arbiter; by the time it passes, convergence bounds
357
+ // portal_dist below at −MPR_TOLERANCE, which is the same
358
+ // accuracy the depth itself is quoted to.
297
359
  if (v4_dist - portal_dist < MPR_TOLERANCE) {
298
360
  result[result_offset] = pn_x * portal_dist;
299
361
  result[result_offset + 1] = pn_y * portal_dist;
@@ -301,13 +363,6 @@ export function mpr(result, result_offset, shape_a, shape_b) {
301
363
  return true;
302
364
  }
303
365
 
304
- // V4 didn't pass the origin along the portal normal — the
305
- // shapes aren't actually overlapping (shouldn't happen after
306
- // successful portal discovery, but defensive).
307
- if (v4_dist < 0) {
308
- return false;
309
- }
310
-
311
366
  // Replace one of V1, V2, V3 with V4 such that the new portal
312
367
  // still contains the V0→origin ray. The split is determined by
313
368
  // three cross-product / dot tests against V4 × V0: signs of
@@ -360,6 +415,14 @@ export function mpr(result, result_offset, shape_a, shape_b) {
360
415
  }
361
416
  const inv_pn = 1 / Math.sqrt(pn_len_sqr);
362
417
  pn_x *= inv_pn; pn_y *= inv_pn; pn_z *= inv_pn;
418
+ // The portal never settled, so this face is a guess — but the
419
+ // separating-plane certificate costs one support call and holds
420
+ // regardless of how the refinement terminated. Apply the same rule
421
+ // both exits use rather than emitting an unexamined MTV.
422
+ minkowski_support(V4, 0, shape_a, shape_b, pn_x, pn_y, pn_z);
423
+ if (V4[0] * pn_x + V4[1] * pn_y + V4[2] * pn_z < 0) {
424
+ return false;
425
+ }
363
426
  const portal_dist = V1[0] * pn_x + V1[1] * pn_y + V1[2] * pn_z;
364
427
  result[result_offset] = pn_x * portal_dist;
365
428
  result[result_offset + 1] = pn_y * portal_dist;
@@ -10,7 +10,7 @@ import ObservedBoolean from "../core/model/ObservedBoolean.js";
10
10
  import ConcurrentExecutor from '../core/process/executor/ConcurrentExecutor.js';
11
11
  import EmptyView from "../view/elements/EmptyView.js";
12
12
  import { ViewStack } from "../view/elements/navigation/ViewStack.js";
13
- import { WebGLContextFailureView } from "../view/graphics/WebGLContextFailureView.js";
13
+ import { GraphicsContextFailureView } from "../view/graphics/GraphicsContextFailureView.js";
14
14
 
15
15
  import { AssetManager } from './asset/AssetManager.js';
16
16
  import { AssetPreloader } from "./asset/preloader/AssetPreloader.js";
@@ -416,7 +416,7 @@ class Engine {
416
416
  // Application state is left intact, which keeps a save-on-failure handler viable.
417
417
  this.#pause_simulation_for_context_loss();
418
418
 
419
- this.viewStack.addChild(new WebGLContextFailureView());
419
+ this.viewStack.addChild(new GraphicsContextFailureView());
420
420
  }
421
421
 
422
422
  #initialize_audio() {
@@ -203,7 +203,7 @@ already paused is left alone, and never resumed by us — hence the new `Ticker.
203
203
  accessors, since `pause()`/`resume()` throw when the loop is not cycling.
204
204
 
205
205
  On `Failed`: the simulation is paused, application state is left intact (so a save-on-failure handler
206
- stays viable), and `WebGLContextFailureView` explains the situation with a reload button, instead of
206
+ stays viable), and `GraphicsContextFailureView` explains the situation with a reload button, instead of
207
207
  leaving the player looking at a black canvas.
208
208
 
209
209
  ---
@@ -31,6 +31,12 @@ export class GraphicsEngine3 {
31
31
  * @returns {boolean}
32
32
  */
33
33
  get isGraphicsEngine(): boolean;
34
+ /**
35
+ * Danger zone.
36
+ * Be careful with what you do, with great Renderer comes great responsibility.
37
+ * @returns {Renderer|null}
38
+ */
39
+ get renderer(): Renderer;
34
40
  /**
35
41
  * The camera, in the shape the engine's existing consumers read it — `projectionMatrix` and
36
42
  * `matrixWorldInverse`, which is what `HeadsUpDisplaySystem` multiplies together. The real
@@ -317,6 +323,7 @@ export class GraphicsEngine3 {
317
323
  render(time_delta_seconds?: number): boolean;
318
324
  #private;
319
325
  }
326
+ import { Renderer } from "../../shade/renderer/Renderer.js";
320
327
  import { ShadeCameraAdapter } from "./ShadeCameraAdapter.js";
321
328
  import Signal from "../../core/events/signal/Signal.js";
322
329
  import Vector1 from "../../core/geom/Vector1.js";
@@ -1 +1 @@
1
- {"version":3,"file":"GraphicsEngine3.d.ts","sourceRoot":"","sources":["../../../../src/engine/graphics3/GraphicsEngine3.js"],"names":[],"mappings":"AAkDA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH;IACI;;;;;OAKG;IACH,gCAEC;IAkEG;;;;;;OAMG;IACH,QAFU,kBAAkB,CAEsB;IAElD;QACI;;;;;;;;WAQG;mBADO,MAAM;QAIhB;;;;;WAKG;oBADO,MAAM;QAIhB;;;;WAIG;qBADO,MAAM;QAIhB;;;;;;;WAOG;;QAGH;;;;;WAKG;;MAEN;IAED;;OAEG;IACH,YAFU,OAAO,CAEe;IAEhC;;;;;OAKG;IACH,4BAFU,OAAO,CAEyB;IAE1C;;;;;;OAMG;IACH,UAFU,SAAS,CAEY;IAG/B;;OAEG;IACH,YAFU,iBAAiB,GAAC,IAAI,CAEV;IAEtB;;;;;OAKG;IACH,UAFU,OAAO,CAEG;IAEpB;;;;;OAKG;IACH,UAFU,OAAO,CAEG;IAEpB;;OAEG;IACH,YAFU,MAAM,CAEG;IAoBvB;;;;;;;;;;;;OAYG;IACH,mDAEC;IAED;;;;;;;;OAQG;IACH,iBAFW,KAAK,QAoBf;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,iDAOC;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,2DAWC;IAED;;;;;;;;;;OAUG;IACH,oCAFa,MAAM,CAclB;IAED;;;;;;OAMG;IACH,8CAFa,OAAO,CAgBnB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,qBALW,KAAK,GACH,kBAAgB,IAAI,CAehC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,yBARW,KAAK,yBAEL,MAAM,UACN,MAAM,GACJ,QAAQ,SAAS,CAAC,CAwD9B;IAED;;;;OAIG;IACH,0BAEC;IAED;;;;;;;;;;OAUG;IACH,SAFa,QAAQ,IAAI,CAAC,CA4DzB;IAED;;;;;OAKG;IACH,aAiBC;IASD;;;;;;;;OAQG;IACH,8BAHW,OAAO,UAAQ,UACf,OAAO,UAAQ,QAQzB;IAED;;;;;;;;;;;;;;;OAeG;IACH,yBALW,MAAM,KACN,MAAM,6CAMhB;IAED;;;;;;;OAOG;IACH,mBA0BC;IAED;;;;;OAKG;IACH,4BAHW,MAAM,GACJ,OAAO,CAoDnB;;CACJ;mCAzvBkC,yBAAyB;mBAbzC,oCAAoC;oBACnC,4BAA4B;wBACxB,4BAA4B;sBAQ9B,kCAAkC;yCAJf,kDAAkD;sBAErE,qCAAqC;0BACjC,0CAA0C"}
1
+ {"version":3,"file":"GraphicsEngine3.d.ts","sourceRoot":"","sources":["../../../../src/engine/graphics3/GraphicsEngine3.js"],"names":[],"mappings":"AAkDA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH;IACI;;;;;OAKG;IACH,gCAEC;IAUD;;;;OAIG;IACH,yBAEC;IA0DG;;;;;;OAMG;IACH,QAFU,kBAAkB,CAEsB;IAElD;QACI;;;;;;;;WAQG;mBADO,MAAM;QAIhB;;;;;WAKG;oBADO,MAAM;QAIhB;;;;WAIG;qBADO,MAAM;QAIhB;;;;;;;WAOG;;QAGH;;;;;WAKG;;MAEN;IAED;;OAEG;IACH,YAFU,OAAO,CAEe;IAEhC;;;;;OAKG;IACH,4BAFU,OAAO,CAEyB;IAE1C;;;;;;OAMG;IACH,UAFU,SAAS,CAEY;IAG/B;;OAEG;IACH,YAFU,iBAAiB,GAAC,IAAI,CAEV;IAEtB;;;;;OAKG;IACH,UAFU,OAAO,CAEG;IAEpB;;;;;OAKG;IACH,UAFU,OAAO,CAEG;IAEpB;;OAEG;IACH,YAFU,MAAM,CAEG;IAoBvB;;;;;;;;;;;;OAYG;IACH,mDAEC;IAED;;;;;;;;OAQG;IACH,iBAFW,KAAK,QAoBf;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,iDAOC;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,2DAWC;IAED;;;;;;;;;;OAUG;IACH,oCAFa,MAAM,CAclB;IAED;;;;;;OAMG;IACH,8CAFa,OAAO,CAgBnB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,qBALW,KAAK,GACH,kBAAgB,IAAI,CAehC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,yBARW,KAAK,yBAEL,MAAM,UACN,MAAM,GACJ,QAAQ,SAAS,CAAC,CAwD9B;IAED;;;;OAIG;IACH,0BAEC;IAED;;;;;;;;;;OAUG;IACH,SAFa,QAAQ,IAAI,CAAC,CA4DzB;IAED;;;;;OAKG;IACH,aAiBC;IASD;;;;;;;;OAQG;IACH,8BAHW,OAAO,UAAQ,UACf,OAAO,UAAQ,QAQzB;IAED;;;;;;;;;;;;;;;OAeG;IACH,yBALW,MAAM,KACN,MAAM,6CAMhB;IAED;;;;;;;OAOG;IACH,mBA0BC;IAED;;;;;OAKG;IACH,4BAHW,MAAM,GACJ,OAAO,CAoDnB;;CACJ;yBAxwBwB,kCAAkC;mCAMxB,yBAAyB;mBAbzC,oCAAoC;oBACnC,4BAA4B;wBACxB,4BAA4B;sBAQ9B,kCAAkC;yCAJf,kDAAkD;sBAErE,qCAAqC;0BACjC,0CAA0C"}
@@ -92,6 +92,15 @@ export class GraphicsEngine3 {
92
92
  */
93
93
  #renderer = null;
94
94
 
95
+ /**
96
+ * Danger zone.
97
+ * Be careful with what you do, with great Renderer comes great responsibility.
98
+ * @returns {Renderer|null}
99
+ */
100
+ get renderer(){
101
+ return this.#renderer;
102
+ }
103
+
95
104
  /**
96
105
  * Trades internal resolution for frame time — see {@link dynamic_resolution}.
97
106
  *
@@ -64,11 +64,11 @@ export class FluidObstacleSystem extends System<any> {
64
64
  /**
65
65
  * @param {FluidComponent} fluid
66
66
  */
67
- static "__#350@#refresh_masks"(fluid: FluidComponent): void;
67
+ static "__#349@#refresh_masks"(fluid: FluidComponent): void;
68
68
  /**
69
69
  * @param {FluidComponent} fluid
70
70
  */
71
- static "__#350@#clear_field"(fluid: FluidComponent): void;
71
+ static "__#349@#clear_field"(fluid: FluidComponent): void;
72
72
  /**
73
73
  * Mark every cell of `fluid` whose centre lies within `inflation` of the
74
74
  * posed shape as solid. Iteration is clipped to the shape's world AABB
@@ -80,7 +80,7 @@ export class FluidObstacleSystem extends System<any> {
80
80
  * @param {number} inflation world-units SDF threshold
81
81
  * @param {Float64Array} point length-3 scratch
82
82
  */
83
- static "__#350@#voxelize"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array): void;
83
+ static "__#349@#voxelize"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array): void;
84
84
  /**
85
85
  * Write the obstacle's translation velocity onto every face of every cell
86
86
  * it voxelized — the moving-wall boundary condition. Runs AFTER the mask
@@ -100,7 +100,7 @@ export class FluidObstacleSystem extends System<any> {
100
100
  * @param {number} wvy
101
101
  * @param {number} wvz
102
102
  */
103
- static "__#350@#stamp_wall_velocity"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array, wvx: number, wvy: number, wvz: number): void;
103
+ static "__#349@#stamp_wall_velocity"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array, wvx: number, wvy: number, wvz: number): void;
104
104
  constructor();
105
105
  dependencies: (typeof FluidObstacle)[];
106
106
  components_used: (ResourceAccessSpecification<typeof Transform> | ResourceAccessSpecification<typeof RigidBody> | ResourceAccessSpecification<typeof Collider> | ResourceAccessSpecification<typeof FluidComponent> | ResourceAccessSpecification<typeof FluidObstacle>)[];
@@ -1 +1 @@
1
- {"version":3,"file":"narrowphase_step.d.ts","sourceRoot":"","sources":["../../../../../src/engine/physics/narrowphase/narrowphase_step.js"],"names":[],"mappings":"AAyyCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,qDAVW,YAAY,GAAC,MAAM,EAAE,iCAGrB;IAAC,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAA;CAAC,QAC5B;IAAC,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAA;CAAC,iCAErC;IAAC,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAA;CAAC,QAC5B;IAAC,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAA;CAAC,GACnC,MAAM,CAuClB;AAED;;;;;;;;;;;GAWG;AACH,uFALW,MAAM,MAAM;IAAC,QAAQ,WAAW;IAAC,SAAS,YAAW;CAAC,CAAC,CAAC,QA4LlE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,uEAJW,MAAM,UACN,MAAM;IAAC,QAAQ,WAAW;IAAC,SAAS,YAAW;CAAC,CAAC,UACjD,MAAM;IAAC,QAAQ,WAAW;IAAC,SAAS,YAAW;CAAC,CAAC,QA2H3D"}
1
+ {"version":3,"file":"narrowphase_step.d.ts","sourceRoot":"","sources":["../../../../../src/engine/physics/narrowphase/narrowphase_step.js"],"names":[],"mappings":"AA2zCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,qDAVW,YAAY,GAAC,MAAM,EAAE,iCAGrB;IAAC,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAA;CAAC,QAC5B;IAAC,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAA;CAAC,iCAErC;IAAC,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAA;CAAC,QAC5B;IAAC,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAC;IAAA,CAAC,EAAC,MAAM,CAAA;CAAC,GACnC,MAAM,CAuClB;AAED;;;;;;;;;;;GAWG;AACH,uFALW,MAAM,MAAM;IAAC,QAAQ,WAAW;IAAC,SAAS,YAAW;CAAC,CAAC,CAAC,QA4LlE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,uEAJW,MAAM,UACN,MAAM;IAAC,QAAQ,WAAW;IAAC,SAAS,YAAW;CAAC,CAAC,UACjD,MAAM;IAAC,QAAQ,WAAW;IAAC,SAAS,YAAW;CAAC,CAAC,QA2H3D"}
@@ -3,7 +3,7 @@ import { Triangle3D } from "../../../core/geom/3d/shape/Triangle3D.js";
3
3
  import { body_id_index } from "../body/BodyStorage.js";
4
4
  import { combine_friction, combine_restitution } from "../contact/combine_material.js";
5
5
  import { CONTACT_STRIDE, MAX_CONTACTS_PER_MANIFOLD } from "../contact/ManifoldStore.js";
6
- import { gjk_epa_penetration } from "../../../core/geom/3d/gjk/gjk_epa_penetration.js";
6
+ import { GJK_SEPARATED, gjk_epa_penetration } from "../../../core/geom/3d/gjk/gjk_epa_penetration.js";
7
7
  import { reduce_manifold_contacts } from "./reduce_manifold_contacts.js";
8
8
  import { mpr } from "../../../core/geom/3d/gjk/mpr.js";
9
9
  import { box_box_manifold, BOX_BOX_OUT_LENGTH } from "./box_box_manifold.js";
@@ -1124,12 +1124,15 @@ function dispatch_pair(count, colA, trA, colB, trB) {
1124
1124
  // expanding_polytope_algorithm pair was replaced because it returned
1125
1125
  // a non-minimal axis on degenerate simplices (see memory:
1126
1126
  // feedback_epa_unreliable_polytopes); MPR is kept as a secondary
1127
- // safety net for the rare case the robust query reports no overlap.
1127
+ // safety net for the case EPA degenerates on a real overlap, or
1128
+ // GJK reaches no verdict — never for a triangle GJK proved clear.
1128
1129
  let ex, ey, ez, depth;
1129
1130
  const pen_depth = gjk_epa_penetration(epa_result, posed_a, posed_b);
1130
1131
  if (pen_depth > 0 && Number.isFinite(pen_depth)) {
1131
1132
  ex = epa_result[0] * pen_depth; ey = epa_result[1] * pen_depth; ez = epa_result[2] * pen_depth;
1132
1133
  depth = pen_depth;
1134
+ } else if (pen_depth === GJK_SEPARATED) {
1135
+ continue;
1133
1136
  } else {
1134
1137
  if (!mpr(epa_result, 0, posed_a, posed_b)) continue;
1135
1138
  ex = epa_result[0]; ey = epa_result[1]; ez = epa_result[2];
@@ -1247,7 +1250,22 @@ function dispatch_pair(count, colA, trA, colB, trB) {
1247
1250
  if (pen_depth > 0 && Number.isFinite(pen_depth)) {
1248
1251
  ex = epa_result[0] * pen_depth; ey = epa_result[1] * pen_depth; ez = epa_result[2] * pen_depth;
1249
1252
  depth = pen_depth;
1253
+ } else if (pen_depth === GJK_SEPARATED) {
1254
+ // GJK proved the pair apart — the answer, not a gap in the query, so
1255
+ // the safety net below has nothing to add and must not be consulted.
1256
+ // This branch is the common case on this path, not an edge: the
1257
+ // broadphase hands over every pair whose fat AABBs touch, and most of
1258
+ // them are near misses. Asking MPR about them is what turned a clear
1259
+ // gap into a ContactBegin carrying a positive depth equal to the gap
1260
+ // — for a sphere near a large ConvexHullShape3D face, which is every
1261
+ // projectile in a brush-built level (mpr.js's separating-plane
1262
+ // certificate now refuses that answer at the source too).
1263
+ return count;
1250
1264
  } else {
1265
+ // No usable axis: EPA degenerated on a real overlap, or GJK itself
1266
+ // reached no verdict (its search direction collapses on an EXACTLY
1267
+ // touching pair). Neither is a separation, so the net still gets to
1268
+ // look. THIS is what it is for.
1251
1269
  if (!mpr(epa_result, 0, posed_a, posed_b)) return count;
1252
1270
  ex = epa_result[0]; ey = epa_result[1]; ez = epa_result[2];
1253
1271
  depth = Math.sqrt(ex * ex + ey * ey + ez * ez);
@@ -68,36 +68,59 @@ union, and each clone at its own arena address on a uniform stride.
68
68
  ## Assets
69
69
 
70
70
  `/test_assets/Michelle.glb`, which is gitignored and unpublished — see `test_assets/README.md`.
71
-
72
- ## What the floor disagreement turned out to be
73
-
74
- For a while the two images disagreed about where the floor ends: a smooth band along its far edge
75
- that the rasterizer drew and the tracer did not, several thousand pixels at an ordinary camera and
76
- growing as the camera approached the edge. It read like a hole in a tree, and it was not one.
77
-
78
- The slab was placed by writing `Node3D.transform_global`, which is **derived** — the doc comment on
79
- it says so. Its two consumers take placement from opposite ends: `Mesh.updateBoundsBasic` reads
80
- `transform_global` and publishes the result as the mesh's `meshes` row, and the GPU's `transforms`
81
- row carries the node's *local* TRS for the hierarchy pass to compose `node.global` from. Writing
82
- only the first left the second at identity, 0.208 m away:
83
-
84
- - the rasterizer multiplied positions by `node.global` and drew the slab at the geometry's own
85
- origin, its far edge ten rows higher up the screen than the floor actually ends;
86
- - the tracer tested each ray against the *translated* published box before traversing the
87
- *untranslated* geometry, so rays that would have struck the slab near its far edge were turned
88
- away by a box that was not around it.
89
-
90
- Neither renderer was wrong about the data it was given. Padding the published box shrank the band
91
- because it widened the interval the TLAS test allows — not because anything was tangent, which is
92
- why a few per cent of pad did what a rounding-scale slack could not.
93
-
94
- The invariant is in `add_floor.spec.js` and needs no device: published bounds must survive
95
- `updateMatrices`, because that is the walk which recomputes `transform_global` from the local
96
- transform. `chunk_ray_query_nearest.spec.js` holds the traversal itself against closed-form slab
97
- arithmetic in the emulator, including the case where the instance box and the node transform
98
- disagree.
99
-
100
- **`?count=N` still places its dancers the old way**, so they stack at the origin rather than
101
- spreading over a grid. That one is self-consistent — box and transform both say "at the origin" —
102
- so the two renderers agree about it, and unpicking it means separating the grid offset from the
103
- transform the glTF gave the mesh node.
71
+
72
+ ## What the floor disagreement turned out to be
73
+
74
+ For a while the two images disagreed about where the floor ends: a smooth band along its far edge
75
+ that the rasterizer drew and the tracer did not, several thousand pixels at an ordinary camera and
76
+ growing as the camera approached the edge. It read like a hole in a tree, and it was not one.
77
+
78
+ The slab was placed by writing `Node3D.transform_global`, which is **derived** — the doc comment on
79
+ it says so. Its two consumers take placement from opposite ends: `Mesh.updateBoundsBasic` reads
80
+ `transform_global` and publishes the result as the mesh's `meshes` row, and the GPU's `transforms`
81
+ row carries the node's *local* TRS for the hierarchy pass to compose `node.global` from. Writing
82
+ only the first left the second at identity, 0.208 m away:
83
+
84
+ - the rasterizer multiplied positions by `node.global` and drew the slab at the geometry's own
85
+ origin, its far edge ten rows higher up the screen than the floor actually ends;
86
+ - the tracer tested each ray against the *translated* published box before traversing the
87
+ *untranslated* geometry, so rays that would have struck the slab near its far edge were turned
88
+ away by a box that was not around it.
89
+
90
+ Neither renderer was wrong about the data it was given. Padding the published box shrank the band
91
+ because it widened the interval the TLAS test allows — not because anything was tangent, which is
92
+ why a few per cent of pad did what a rounding-scale slack could not.
93
+
94
+ The invariant is in `add_floor.spec.js` and needs no device: published bounds must survive
95
+ `updateMatrices`, because that is the walk which recomputes `transform_global` from the local
96
+ transform. `chunk_ray_query_nearest.spec.js` holds the traversal itself against closed-form slab
97
+ arithmetic in the emulator, including the case where the instance box and the node transform
98
+ disagree.
99
+
100
+ ## The quieter version of the same bug: `?count=N`
101
+
102
+ The dancers were placed the same way by writing `transform_global` and stacked at the origin
103
+ rather than spreading over a grid. Both renderers agreed about it, so there was no picture to look
104
+ at; the only symptom was that the characters did not go where they were put.
105
+
106
+ They also could not have been fixed by copying the floor's fix, because a skinned mesh's own node
107
+ is not its placement point at all. A skinned vertex lands at
108
+
109
+ ```
110
+ mesh.global × inverse(mesh.global) × joint.global × inverse_bind × v
111
+ ```
112
+
113
+ — `chunk_skin_blend_mesh_local` folds the leftmost inverse into each joint matrix and the rasterizer
114
+ re-applies `mesh.global`, so the mesh node's transform **cancels exactly**, and
115
+ `compute_skin_world_bounds` composes the published box out of joints without reading it either.
116
+ Michelle's file makes the point structurally: her mesh node `Ch03` carries no local transform, the
117
+ 0.01 centimetre scale and the up-axis rotation sit on the root above it, and the joint root
118
+ `mixamorig:Hips` is the mesh node's *sibling*. Nothing under `Ch03` drives a joint.
119
+
120
+ `place_character` therefore hangs each character's roots under a fresh parent whose whole transform
121
+ is the grid offset. That reaches the joints, leaves the file's own transform untouched below it, and
122
+ — because the offset is a *local* transform — survives the walk that recomputes `transform_global`.
123
+
124
+ `place_character.spec.js` holds the invariant the floor's cannot see: **published bounds must move
125
+ by the offset the character was placed at.** Its negative control is the bug itself — writing either
126
+ transform on the mesh node and watching the box not move.
@@ -19,7 +19,8 @@
19
19
  * actually sit under.
20
20
  *
21
21
  * Michelle is the shape that makes this concrete. Her file is one root carrying the centimetre
22
- * scale and the Z-up rotation, with the mesh node and the joint root as *siblings* beneath it:
22
+ * scale and the up-axis rotation (+90° about x, the file's Z-up into the renderer's Y-up), with the
23
+ * mesh node and the joint root as *siblings* beneath it:
23
24
  *
24
25
  * ```
25
26
  * "Character" R=(0.70711, 0, 0, 0.70711) S=(0.01, 0.01, 0.01)