@woosh/meep-engine 2.163.8 → 2.163.10

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/core/collection/heap/IndexedFloatMaxHeap.d.ts.map +1 -0
  3. package/src/core/{graph/metis/native/refine → collection/heap}/IndexedFloatMaxHeap.js +1 -1
  4. package/src/core/geom/2d/line/line_segment_intersection_fraction_2d.d.ts +4 -2
  5. package/src/core/geom/2d/line/line_segment_intersection_fraction_2d.d.ts.map +1 -1
  6. package/src/core/geom/2d/line/line_segment_intersection_fraction_2d.js +6 -4
  7. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_simplify.d.ts.map +1 -1
  8. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_simplify.js +2 -26
  9. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_simplify_by_error.d.ts +19 -0
  10. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_simplify_by_error.d.ts.map +1 -0
  11. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_simplify_by_error.js +555 -0
  12. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_swap_vertex_slots.d.ts +13 -0
  13. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_swap_vertex_slots.d.ts.map +1 -0
  14. package/src/core/geom/3d/topology/struct/binary/io/edge/bt_edge_swap_vertex_slots.js +28 -0
  15. package/src/core/graph/metis/native/bisection/BisectionScratch.d.ts +1 -1
  16. package/src/core/graph/metis/native/bisection/BisectionScratch.d.ts.map +1 -1
  17. package/src/core/graph/metis/native/bisection/BisectionScratch.js +1 -1
  18. package/src/core/graph/metis/native/refine/RefinementScratch.d.ts +1 -1
  19. package/src/core/graph/metis/native/refine/RefinementScratch.d.ts.map +1 -1
  20. package/src/core/graph/metis/native/refine/RefinementScratch.js +1 -1
  21. package/src/engine/navigation/mesh/NavigationMesh.d.ts +6 -0
  22. package/src/engine/navigation/mesh/NavigationMesh.d.ts.map +1 -1
  23. package/src/engine/navigation/mesh/NavigationMesh.js +145 -234
  24. package/src/engine/navigation/mesh/PATHFINDING_PLAN.md +61 -17
  25. package/src/engine/navigation/mesh/{navmesh_polyanya_find_path.d.ts → bt_mesh_face_find_path_polyanya.d.ts} +2 -2
  26. package/src/engine/navigation/mesh/bt_mesh_face_find_path_polyanya.d.ts.map +1 -0
  27. package/src/engine/navigation/mesh/{navmesh_polyanya_find_path.js → bt_mesh_face_find_path_polyanya.js} +117 -48
  28. package/src/engine/navigation/mesh/build/navmesh_build_topology.d.ts.map +1 -1
  29. package/src/engine/navigation/mesh/build/navmesh_build_topology.js +25 -0
  30. package/src/engine/navigation/mesh/bvh_segment_penetrates_mesh.d.ts +21 -0
  31. package/src/engine/navigation/mesh/bvh_segment_penetrates_mesh.d.ts.map +1 -0
  32. package/src/engine/navigation/mesh/bvh_segment_penetrates_mesh.js +133 -0
  33. package/src/core/graph/metis/native/refine/IndexedFloatMaxHeap.d.ts.map +0 -1
  34. package/src/engine/navigation/mesh/navmesh_polyanya_find_path.d.ts.map +0 -1
  35. /package/src/core/{graph/metis/native/refine → collection/heap}/IndexedFloatMaxHeap.d.ts +0 -0
@@ -1,234 +1,145 @@
1
- import { BVH } from "../../../core/bvh2/bvh3/BVH.js";
2
- import { BinaryTopology, NULL_POINTER } from "../../../core/geom/3d/topology/struct/binary/BinaryTopology.js";
3
- import { bt_faces_shared_loop } from "../../../core/geom/3d/topology/struct/binary/query/bt_faces_shared_loop.js";
4
- import Vector3 from "../../../core/geom/Vector3.js";
5
- import { bt_mesh_face_find_path } from "./bt_mesh_face_find_path.js";
6
- import { navmesh_build_topology } from "./build/navmesh_build_topology.js";
7
- import { bvh_build_from_bt_mesh } from "./bvh_build_from_bt_mesh.js";
8
- import { bvh_query_nearest_face } from "./bvh_query_nearest_face.js";
9
- import { funnel_string_pull } from "./funnel_string_pull.js";
10
-
11
- /**
12
- * Hard cap on the number of faces we traverse in a single path query.
13
- * Limits scratch buffer sizes; paths longer than this are truncated by the A* search.
14
- * @type {number}
15
- */
16
- const MAX_FACE_PATH_LENGTH = 1024;
17
-
18
- // face path IDs or (later) final path vertex indices
19
- const scratch_array_u32 = new Uint32Array(MAX_FACE_PATH_LENGTH);
20
-
21
- // one more portal than face-path length (start portal + one between each consecutive pair of faces + goal portal)
22
- const MAX_PORTAL_COUNT = MAX_FACE_PATH_LENGTH + 1;
23
-
24
- // [left0, right0, left1, right1, ...]
25
- const scratch_portal_vertices = new Uint32Array(MAX_PORTAL_COUNT * 2);
26
-
27
- // flat XYZ triples, one per portal
28
- const scratch_portal_normals = new Float32Array(MAX_PORTAL_COUNT * 3);
29
-
30
- // flat XYZ triples; indices 0 and 1 are start/goal, then 2 vertices per intermediate portal
31
- const scratch_vertices = new Float32Array((2 + (MAX_PORTAL_COUNT - 2) * 2) * 3);
32
-
33
- // snapped (on-mesh) start/goal positions, reused across queries
34
- const scratch_start_point = new Float32Array(3);
35
- const scratch_goal_point = new Float32Array(3);
36
-
37
- export class NavigationMesh {
38
-
39
- topology = new BinaryTopology();
40
-
41
- /**
42
- * Used for raycasts and neighborhood search.
43
- * @type {BVH}
44
- */
45
- bvh = new BVH();
46
-
47
- /**
48
- * Build from given scene geometry
49
- * @param {BinaryTopology} source
50
- * @param {number} [agent_radius]
51
- * @param {number} [agent_height]
52
- * @param {number} [agent_max_step_height] agent can bridge vertical gaps in topology, such as stepping up a stair
53
- * @param {number} [agent_max_step_distance] agent can bridge lateral gaps in topology, such as stepping over a hole in the floor
54
- * @param {number} [agent_max_climb_angle] In radians, how steep of an angle can the agent go up by
55
- * @param {Vector3} [up] Defines world's "UP" direction, this is what the agent will respect for climbing constraint
56
- */
57
- build({
58
- source,
59
- agent_radius = 0,
60
- agent_height = 0,
61
- agent_max_step_height = 0,
62
- agent_max_step_distance = 0,
63
- agent_max_climb_angle = Math.PI / 4,
64
- up = Vector3.up,
65
- }) {
66
-
67
- navmesh_build_topology({
68
- destination: this.topology,
69
- source,
70
- agent_radius,
71
- agent_height,
72
- agent_max_step_height,
73
- agent_max_step_distance,
74
- agent_max_climb_angle,
75
- up,
76
- });
77
-
78
- bvh_build_from_bt_mesh(this.bvh, this.topology);
79
- }
80
-
81
-
82
- /**
83
- * Compute a walkable path between the two points.
84
- * The result is a sequence of 3d points written into `output`. The first and last points are the
85
- * start and goal snapped onto the mesh surface (the closest walkable point to each query position),
86
- * so they may differ from the raw `start_*`/`goal_*` inputs when those lie off the mesh.
87
- *
88
- * @param {Float32Array} output packed XYZ triples
89
- * @param {number} start_x
90
- * @param {number} start_y
91
- * @param {number} start_z
92
- * @param {number} goal_x
93
- * @param {number} goal_y
94
- * @param {number} goal_z
95
- * @returns {number} number of 3d points written to `output` (0 if no path was found)
96
- */
97
- find_path(
98
- output,
99
- start_x, start_y, start_z,
100
- goal_x, goal_y, goal_z
101
- ) {
102
-
103
- const mesh = this.topology;
104
- const bvh = this.bvh;
105
-
106
- const start_face_id = bvh_query_nearest_face(bvh, mesh, start_x, start_y, start_z, scratch_start_point);
107
-
108
- if (start_face_id === NULL_POINTER) {
109
- // probably topology is empty
110
- return 0;
111
- }
112
-
113
- const goal_face_id = bvh_query_nearest_face(bvh, mesh, goal_x, goal_y, goal_z, scratch_goal_point);
114
-
115
- if (goal_face_id === NULL_POINTER) {
116
- // should never happen if we got the start face
117
- return 0;
118
- }
119
-
120
- // snap the query points onto the mesh surface; the path begins/ends on the walkable surface,
121
- // not at the (possibly off-mesh) raw query coordinates
122
- const snapped_start_x = scratch_start_point[0];
123
- const snapped_start_y = scratch_start_point[1];
124
- const snapped_start_z = scratch_start_point[2];
125
-
126
- const snapped_goal_x = scratch_goal_point[0];
127
- const snapped_goal_y = scratch_goal_point[1];
128
- const snapped_goal_z = scratch_goal_point[2];
129
-
130
- if (goal_face_id === start_face_id) {
131
- // path within the same triangle
132
-
133
- output[0] = snapped_start_x;
134
- output[1] = snapped_start_y;
135
- output[2] = snapped_start_z;
136
-
137
- output[3] = snapped_goal_x;
138
- output[4] = snapped_goal_y;
139
- output[5] = snapped_goal_z;
140
-
141
- return 2;
142
- }
143
-
144
- const face_path_length = bt_mesh_face_find_path(scratch_array_u32, start_face_id, goal_face_id, mesh, MAX_FACE_PATH_LENGTH);
145
-
146
- if (face_path_length === 0) {
147
- // no face path exists (disconnected topology)
148
- return 0;
149
- }
150
-
151
- // build portals
152
- // ==================
153
-
154
- // initialize vertex data pool, vertex index 0 = start, vertex index 1 = goal (snapped on-mesh)
155
- scratch_vertices[0] = snapped_start_x;
156
- scratch_vertices[1] = snapped_start_y;
157
- scratch_vertices[2] = snapped_start_z;
158
-
159
- scratch_vertices[3] = snapped_goal_x;
160
- scratch_vertices[4] = snapped_goal_y;
161
- scratch_vertices[5] = snapped_goal_z;
162
-
163
- let next_vertex_index = 2;
164
-
165
- // start portal, degenerate portal at the start point, exiting the start face
166
- scratch_portal_vertices[0] = 0;
167
- scratch_portal_vertices[1] = 0;
168
-
169
- mesh.face_read_normal(scratch_portal_normals, 0, start_face_id);
170
-
171
- let portal_index = 1;
172
-
173
- // intermediate portals sit on the shared edge between two consecutive faces along the path;
174
- // the edge is read in the winding order of the face we are exiting, giving a directed (left, right) pair
175
- for (let i = 1; i < face_path_length; i++) {
176
-
177
- const face_from = scratch_array_u32[i - 1];
178
- const face_to = scratch_array_u32[i];
179
-
180
- const loop = bt_faces_shared_loop(mesh, face_from, face_to);
181
-
182
- const left_vertex = mesh.loop_read_vertex(loop);
183
- const right_vertex = mesh.loop_read_vertex(mesh.loop_read_next(loop));
184
-
185
- const left_index = next_vertex_index++;
186
- const right_index = next_vertex_index++;
187
-
188
- mesh.vertex_read_coordinate(scratch_vertices, left_index * 3, left_vertex);
189
- mesh.vertex_read_coordinate(scratch_vertices, right_index * 3, right_vertex);
190
-
191
- const portal_address = portal_index * 2;
192
- scratch_portal_vertices[portal_address] = left_index;
193
- scratch_portal_vertices[portal_address + 1] = right_index;
194
-
195
- mesh.face_read_normal(scratch_portal_normals, portal_index * 3, face_from);
196
-
197
- portal_index++;
198
- }
199
-
200
- // goal portal, degenerate at the goal point, uses the normal of the face containing the goal
201
- const goal_portal_address = portal_index * 2;
202
-
203
- scratch_portal_vertices[goal_portal_address] = 1;
204
- scratch_portal_vertices[goal_portal_address + 1] = 1;
205
-
206
- mesh.face_read_normal(scratch_portal_normals, portal_index * 3, goal_face_id);
207
-
208
- portal_index++;
209
-
210
- // execute string pulling
211
- const path_vertex_count = funnel_string_pull(
212
- scratch_array_u32,
213
- 0,
214
- scratch_portal_vertices,
215
- scratch_portal_normals,
216
- portal_index,
217
- scratch_vertices,
218
- );
219
-
220
- // build the final path
221
- for (let i = 0; i < path_vertex_count; i++) {
222
- const vertex_index = scratch_array_u32[i];
223
-
224
- output[i * 3] = scratch_vertices[vertex_index * 3];
225
- output[i * 3 + 1] = scratch_vertices[vertex_index * 3 + 1];
226
- output[i * 3 + 2] = scratch_vertices[vertex_index * 3 + 2];
227
-
228
- }
229
-
230
- return path_vertex_count;
231
-
232
- }
233
-
234
- }
1
+ import { BVH } from "../../../core/bvh2/bvh3/BVH.js";
2
+ import { BinaryTopology, NULL_POINTER } from "../../../core/geom/3d/topology/struct/binary/BinaryTopology.js";
3
+ import Vector3 from "../../../core/geom/Vector3.js";
4
+ import { navmesh_build_topology } from "./build/navmesh_build_topology.js";
5
+ import { bt_mesh_face_find_path_polyanya } from "./bt_mesh_face_find_path_polyanya.js";
6
+ import { bvh_build_from_bt_mesh } from "./bvh_build_from_bt_mesh.js";
7
+ import { bvh_query_nearest_face } from "./bvh_query_nearest_face.js";
8
+
9
+ // snapped (on-mesh) start/goal positions, reused across queries
10
+ const scratch_start_point = new Float32Array(3);
11
+ const scratch_goal_point = new Float32Array(3);
12
+
13
+ const scratch_v0 = new Float32Array(3);
14
+ const scratch_v1 = new Float32Array(3);
15
+ const scratch_v2 = new Float32Array(3);
16
+
17
+ // Fraction of the way toward the face centroid to pull a query point. The Polyanya cone search
18
+ // degenerates when a root (the start) or the goal lies exactly on a triangle edge -- and snapped,
19
+ // grid-aligned queries frequently do. Nudging the point strictly into the face interior conditions the
20
+ // search; 1e-3 is far enough off any edge for the search tolerances yet visually still "the same point".
21
+ const FACE_INTERIOR_PULL = 1e-3;
22
+
23
+ /** Move `point` a small fraction toward the centroid of `face`, so it sits strictly inside the triangle. */
24
+ function nudge_into_face(mesh, face, point) {
25
+ const l0 = mesh.face_read_loop(face);
26
+ const l1 = mesh.loop_read_next(l0);
27
+ const l2 = mesh.loop_read_next(l1);
28
+
29
+ mesh.vertex_read_coordinate(scratch_v0, 0, mesh.loop_read_vertex(l0));
30
+ mesh.vertex_read_coordinate(scratch_v1, 0, mesh.loop_read_vertex(l1));
31
+ mesh.vertex_read_coordinate(scratch_v2, 0, mesh.loop_read_vertex(l2));
32
+
33
+ const cx = (scratch_v0[0] + scratch_v1[0] + scratch_v2[0]) / 3;
34
+ const cy = (scratch_v0[1] + scratch_v1[1] + scratch_v2[1]) / 3;
35
+ const cz = (scratch_v0[2] + scratch_v1[2] + scratch_v2[2]) / 3;
36
+
37
+ point[0] += (cx - point[0]) * FACE_INTERIOR_PULL;
38
+ point[1] += (cy - point[1]) * FACE_INTERIOR_PULL;
39
+ point[2] += (cz - point[2]) * FACE_INTERIOR_PULL;
40
+ }
41
+
42
+ export class NavigationMesh {
43
+
44
+ topology = new BinaryTopology();
45
+
46
+ /**
47
+ * Used for raycasts and neighborhood search.
48
+ * @type {BVH}
49
+ */
50
+ bvh = new BVH();
51
+
52
+ /**
53
+ * Build from given scene geometry
54
+ * @param {BinaryTopology} source
55
+ * @param {number} [agent_radius]
56
+ * @param {number} [agent_height]
57
+ * @param {number} [agent_max_step_height] agent can bridge vertical gaps in topology, such as stepping up a stair
58
+ * @param {number} [agent_max_step_distance] agent can bridge lateral gaps in topology, such as stepping over a hole in the floor
59
+ * @param {number} [agent_max_climb_angle] In radians, how steep of an angle can the agent go up by
60
+ * @param {Vector3} [up] Defines world's "UP" direction, this is what the agent will respect for climbing constraint
61
+ */
62
+ build({
63
+ source,
64
+ agent_radius = 0,
65
+ agent_height = 0,
66
+ agent_max_step_height = 0,
67
+ agent_max_step_distance = 0,
68
+ agent_max_climb_angle = Math.PI / 4,
69
+ up = Vector3.up,
70
+ }) {
71
+
72
+ navmesh_build_topology({
73
+ destination: this.topology,
74
+ source,
75
+ agent_radius,
76
+ agent_height,
77
+ agent_max_step_height,
78
+ agent_max_step_distance,
79
+ agent_max_climb_angle,
80
+ up,
81
+ });
82
+
83
+ bvh_build_from_bt_mesh(this.bvh, this.topology);
84
+ }
85
+
86
+
87
+ /**
88
+ * Compute a walkable path between the two points.
89
+ * The result is a sequence of 3d points written into `output`. The first and last points are the
90
+ * start and goal snapped onto the mesh surface (the closest walkable point to each query position),
91
+ * so they may differ from the raw `start_*`/`goal_*` inputs when those lie off the mesh.
92
+ *
93
+ * The path itself is the exact any-angle geodesic on the navmesh surface (Polyanya). It turns at
94
+ * obstacle corners and is subdivided wherever it crosses a face boundary, so every segment lies on a
95
+ * single triangle it follows the surface exactly rather than flying over creases or tunnelling
96
+ * through folds. It is intrinsic to the surface — no global "up" is assumed — so it is correct on
97
+ * sloped and folded navmeshes as well as flat ones.
98
+ *
99
+ * @param {Float32Array} output packed XYZ triples
100
+ * @param {number} start_x
101
+ * @param {number} start_y
102
+ * @param {number} start_z
103
+ * @param {number} goal_x
104
+ * @param {number} goal_y
105
+ * @param {number} goal_z
106
+ * @returns {number} number of 3d points written to `output` (0 if no path was found)
107
+ */
108
+ find_path(
109
+ output,
110
+ start_x, start_y, start_z,
111
+ goal_x, goal_y, goal_z
112
+ ) {
113
+
114
+ const mesh = this.topology;
115
+ const bvh = this.bvh;
116
+
117
+ const start_face_id = bvh_query_nearest_face(bvh, mesh, start_x, start_y, start_z, scratch_start_point);
118
+
119
+ if (start_face_id === NULL_POINTER) {
120
+ // probably topology is empty
121
+ return 0;
122
+ }
123
+
124
+ const goal_face_id = bvh_query_nearest_face(bvh, mesh, goal_x, goal_y, goal_z, scratch_goal_point);
125
+
126
+ if (goal_face_id === NULL_POINTER) {
127
+ // should never happen if we got the start face
128
+ return 0;
129
+ }
130
+
131
+ // pull the snapped endpoints strictly inside their faces (see nudge_into_face)
132
+ nudge_into_face(mesh, start_face_id, scratch_start_point);
133
+ nudge_into_face(mesh, goal_face_id, scratch_goal_point);
134
+
135
+ // exact any-angle path between the query points snapped onto the walkable surface; returns 0 when
136
+ // the start and goal faces are in different connected components
137
+ return bt_mesh_face_find_path_polyanya(
138
+ output, mesh,
139
+ scratch_start_point[0], scratch_start_point[1], scratch_start_point[2], start_face_id,
140
+ scratch_goal_point[0], scratch_goal_point[1], scratch_goal_point[2], goal_face_id,
141
+ );
142
+
143
+ }
144
+
145
+ }
@@ -81,7 +81,7 @@ two far edges; visible pieces keep `root` (observable), pieces hidden behind a *
81
81
  interval endpoint turn there (the corner becomes the new root, non-observable). It returns the exact
82
82
  point path, turning only at obstacle corners — no corridor, no funnel post-pass.
83
83
 
84
- Built as a **standalone 2-D module** `navmesh_polyanya_find_path` (xy-plane; not yet wired into the
84
+ Built as a **standalone 2-D module** `bt_mesh_face_find_path_polyanya` (xy-plane; not yet wired into the
85
85
  3-D `NavigationMesh`). Two things were essential to get it correct *and* terminating:
86
86
  - **Reflex-corner restriction:** only boundary vertices whose incident-triangle angles sum to > 180°
87
87
  are turning roots. Admitting flat/convex boundary vertices makes the search blow up combinatorially.
@@ -101,8 +101,8 @@ Built as a **standalone 2-D module** `navmesh_polyanya_find_path` (xy-plane; not
101
101
  (p50 36, p99 126, min 0.17 for local queries), ~24 searches/s — **~64× faster**. `shortest_path.spec`
102
102
  and all 92 navigation tests stay green; the gradient trace + graph-walk fallback are kept, with a
103
103
  full-field re-solve as the backstop for under-resolved hole-winding corridors.
104
- 3. **[done]** Phase 2. Standalone exact `navmesh_polyanya_find_path` (2-D). Validated by
105
- `navmesh_polyanya_find_path.spec.js`: exact on analytic oracles (straight line, single hole,
104
+ 3. **[done]** Phase 2. Standalone exact `bt_mesh_face_find_path_polyanya` (2-D). Validated by
105
+ `bt_mesh_face_find_path_polyanya.spec.js`: exact on analytic oracles (straight line, single hole,
106
106
  U-barrier, asymmetric wall — all corner-hugging to 4 decimals) and, over 60 random pairs on a holed
107
107
  mesh, never longer than the FMM+funnel path and never shorter than the straight line (strictly
108
108
  shorter on detours). Benchmark on the same ~100k-face mesh: **mean 7.1 ms/search** (p50 3.3, p90 5.5,
@@ -120,20 +120,21 @@ Built as a **standalone 2-D module** `navmesh_polyanya_find_path` (xy-plane; not
120
120
  cost.
121
121
 
122
122
  **GC-free node storage (done):** search nodes moved out of per-node object literals into fixed
123
- 16-word records in a `BinaryElementPool` buffer, referenced by integer id, with the open list a
124
- `Uint32Heap4` (id + f32 score) instead of an object heap. The buffer is driven by a monotonic
125
- counter reset per query (nodes are never released mid-search and `is_allocated()` is never read), so
126
- the pool's per-node occupancy bit and its O(capacity) `clear()` itself a spike after a large query
127
- are bypassed; the buffer only ever grows (geometrically) and the cached `data_*` views are
128
- re-read after a grow. This is a deliberate **latency**, not throughput, choice: it is a touch slower
123
+ 16-word records in a flat `ArrayBuffer` (uint32 + float32 views overlaid), referenced by integer id,
124
+ with the open list a `Uint32Heap4` (id + f32 score) instead of an object heap. Allocation is a
125
+ monotonic bump counter reset per query; nodes are never released mid-search, so there is no free list
126
+ and no occupancy bookkeeping the buffer only ever grows (by doubling, copying the records over) and
127
+ the two cached views are re-read after a grow. This is a deliberate **latency**, not throughput, choice: it is a touch slower
129
128
  on mean throughput (**~4.7 → ~6.0 ms**, p99 6.8 → 18.3 on the bench — object literals are cheap and
130
129
  the typical query is tiny, avg ~2 points, so there was little GC to remove and the typed-array
131
130
  bookkeeping costs more than it saves *in aggregate*), but pathfinding is a hot path and the point is
132
131
  to emit **zero garbage** so it never triggers a GC pause that jitters the rest of the engine's frame.
133
- Unpredictable pauses are worse than a predictable constant cost here. (Two earlier pool variants were
134
- measured — `allocate()`/`clear()` was slower still; pool-as-buffer is the kept one.)
132
+ Unpredictable pauses are worse than a predictable constant cost here. (Storage was first prototyped on
133
+ `BinaryElementPool`, but the never-free, bump-allocated access pattern reads none of its allocator —
134
+ no `allocate()`/`release()`/`is_allocated()`/`clear()` — so it was reduced to the raw `ArrayBuffer` it
135
+ degenerates to; the `allocate()`/`clear()` pool variants measured slower still.)
135
136
 
136
- **3-D / intrinsic (done):** `navmesh_polyanya_find_path` takes 3-D start/goal and writes a 3-D point
137
+ **3-D / intrinsic (done):** `bt_mesh_face_find_path_polyanya` takes 3-D start/goal and writes a 3-D point
137
138
  path. It assumes **no global "up"** — the search follows the surface by UNFOLDING. Each node carries the
138
139
  2-D positions of its entry edge in a frame accumulated along its own corridor; expanding flattens the
139
140
  next triangle into that frame by placing the apex from its two 3-D edge lengths (an isometry of the
@@ -169,11 +170,54 @@ Built as a **standalone 2-D module** `navmesh_polyanya_find_path` (xy-plane; not
169
170
  per-node work (an extra unfold + a 3-D touch) is minor by comparison. This is the price of "follow face
170
171
  normals" with no up assumption, and it buys exactness on curved surfaces.
171
172
 
172
- Remaining levers: a tighter still-admissible heuristic (the biggest win e.g. a 3-D touch distance
173
- that accounts for the next face's tilt, or a cached lower-bound field), the paper's advanced interval
174
- pruning, and wiring `navmesh_polyanya_find_path` into `NavigationMesh.find_path` as an alternative
175
- backend (it now matches that contract 3-D points in, 3-D points out, no up-vector — and just needs
176
- BVH start/goal snapping).
173
+ **Intermediate-node pruning tried, does NOT pay off (two benches).** Implemented the Anya/Polyanya
174
+ pruning (collapse single-observable-successor "pass-through" nodes inline: no heap, no heuristic, no
175
+ alloc), exact and `spec`-green. On the open Eden-blob bench it was flat in the bulk (mean/p50/throughput
176
+ unchanged) with only a tail improvement (max 4.47 2.38 s); on a deliberately corridor-heavy mesh
177
+ (`bt_mesh_face_find_path_polyanya_rooms.bench.spec.js`, below) a deterministic same-queries measure was **identical**
178
+ (40 queries: 4261 ms baseline vs 4246 ms pruned). Root cause: the weak heuristic floods the *open
179
+ rooms*, where the cone fans into many successors; the narrow corridors where chaining fires are a small
180
+ fraction. Pruning attacks per-node overhead, but the bottleneck is the *node count* the heuristic
181
+ forces. Not committed.
182
+
183
+ **Rooms-and-corridors bench (`bt_mesh_face_find_path_polyanya_rooms.bench.spec.js`, `.skip`).** 25×25 square rooms,
184
+ each at one of 5 random heights, connected N/S/E/W by narrow ramped corridors (~99k faces, genuinely
185
+ non-planar). It dramatises the heuristic weakness: **mean ~2.4 s/search** (vs 9 ms on the open blob),
186
+ ~0.4 searches/s, because the straight-3-D-chord estimate cuts through walls and across levels, so A*
187
+ explores most of the mesh. This is the standing stress case for any heuristic work.
188
+
189
+ **Wired into `NavigationMesh.find_path` (done).** It replaces the face-corridor + funnel string-pull:
190
+ snap start/goal via the BVH, then run Polyanya. One robustness measure was needed — the snapped points
191
+ are pulled a hair (1e-3) toward their face centroid, because the cone search degenerates when a root or
192
+ the goal lies exactly on a triangle edge, which snapped grid-aligned queries frequently do (the funnel
193
+ sidestepped it by orienting on face normals, not a cone). Full `navigation/mesh` suite stays green.
194
+
195
+ **Path validation + a finding (done).** Both benches keep their paths and validate them against a
196
+ BVH: `bvh_segment_penetrates_mesh` flags any piece that crosses a triangle interior transversally, and
197
+ sampling reports how far each piece strays off the surface. The finding: a **corner-only** waypoint path
198
+ is correct only AT its turning corners, so the straight chord between two corners on different levels
199
+ flew over / cut through the ramped geometry between them (the funnel had the same property) — on the
200
+ multi-level rooms bench ≈4 % of pieces penetrated, maxOff up to the full height range.
201
+
202
+ **Surface-following output (done — fixes the above).** The reconstruction no longer emits bare corner
203
+ waypoints; it emits the point where the geodesic crosses each face-boundary edge (`line(root→next) ∩
204
+ edge`, lifted to 3-D by lerping the edge endpoints), plus the turning corner at each root change. Every
205
+ emitted segment therefore lies within a single triangle, so the path neither flies over a convex crease
206
+ nor tunnels through a concave fold. Two subtleties: (1) the geodesic often grazes a shared grid vertex,
207
+ where `line ∩ edge` reports no crossing (-1, both endpoints on one side of the line) — snapped to the nearer endpoint;
208
+ (2) flat stretches would otherwise gain a point at every interior edge, so **collinear runs are collapsed
209
+ to their endpoints** (perpendicular offset / span < `COLLINEAR_EPS = 1e-4`). The flat-mesh output is thus
210
+ unchanged (minimal corner polyline — the sparse-output specs still hold); subdivision only adds points
211
+ where the surface actually folds. **Both benches now ASSERT `penetrating === 0 && offSurface === 0`**
212
+ (the rooms bench flipped from report to assert), and `bt_mesh_face_find_path_polyanya.spec.js` adds
213
+ minimal surface oracles: two triangles folded into a convex ridge and a concave valley, and a 3-triangle
214
+ folded strip — each asserts the path sticks to the surface (`maxOffSurface < 1e-3`) and bends over the
215
+ crease rather than chording across it.
216
+
217
+ Remaining lever: a tighter still-admissible heuristic is now clearly **the** win — the rooms bench shows
218
+ the chord heuristic is catastrophic on walled/multi-level meshes; candidates are a cached lower-bound
219
+ distance-to-goal field (one FMM-style sweep per query, like `bt_mesh_face_find_path`) or an
220
+ obstacle-aware estimate.
177
221
 
178
222
  ## Gates
179
223
 
@@ -13,5 +13,5 @@
13
13
  * @param {number} goal_face triangle containing the goal point
14
14
  * @returns {number} number of path POINTS written (3 numbers each), 0 if no path
15
15
  */
16
- export function navmesh_polyanya_find_path(output: number[] | Float64Array | Float32Array, topology: BinaryTopology, sx: number, sy: number, sz: number, start_face: number, gx: number, gy: number, gz: number, goal_face: number): number;
17
- //# sourceMappingURL=navmesh_polyanya_find_path.d.ts.map
16
+ export function bt_mesh_face_find_path_polyanya(output: number[] | Float64Array | Float32Array, topology: BinaryTopology, sx: number, sy: number, sz: number, start_face: number, gx: number, gy: number, gz: number, goal_face: number): number;
17
+ //# sourceMappingURL=bt_mesh_face_find_path_polyanya.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bt_mesh_face_find_path_polyanya.d.ts","sourceRoot":"","sources":["../../../../../src/engine/navigation/mesh/bt_mesh_face_find_path_polyanya.js"],"names":[],"mappings":"AA2TA;;;;;;;;;;;;;;GAcG;AACH,wDAZW,MAAM,EAAE,GAAC,YAAY,GAAC,YAAY,gCAElC,MAAM,MACN,MAAM,MACN,MAAM,cACN,MAAM,MACN,MAAM,MACN,MAAM,MACN,MAAM,aACN,MAAM,GACJ,MAAM,CA+HlB"}