@woosh/meep-engine 2.163.6 → 2.163.8

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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/core/geom/2d/line/line_segment_intersection_fraction_2d.d.ts +21 -0
  3. package/src/core/geom/2d/line/line_segment_intersection_fraction_2d.d.ts.map +1 -0
  4. package/src/core/geom/2d/line/line_segment_intersection_fraction_2d.js +42 -0
  5. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_face_island_erode.d.ts +2 -2
  6. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_face_island_erode.d.ts.map +1 -1
  7. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_face_island_erode.js +120 -179
  8. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_fill_small_holes.d.ts +9 -10
  9. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_fill_small_holes.d.ts.map +1 -1
  10. package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_fill_small_holes.js +20 -14
  11. package/src/core/geom/3d/topology/struct/binary/query/bt_face_island_flood_fill.d.ts +17 -0
  12. package/src/core/geom/3d/topology/struct/binary/query/bt_face_island_flood_fill.d.ts.map +1 -0
  13. package/src/core/geom/3d/topology/struct/binary/query/bt_face_island_flood_fill.js +45 -0
  14. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_build_boundary_euclidean_distance_field.d.ts +40 -0
  15. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_build_boundary_euclidean_distance_field.d.ts.map +1 -0
  16. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_build_boundary_euclidean_distance_field.js +84 -0
  17. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_compute_face_islands.d.ts.map +1 -1
  18. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_compute_face_islands.js +53 -78
  19. package/src/core/geom/vec3/v3_matrix3_rotate.d.ts +16 -0
  20. package/src/core/geom/vec3/v3_matrix3_rotate.d.ts.map +1 -0
  21. package/src/core/geom/vec3/v3_matrix3_rotate.js +49 -0
  22. package/src/core/geom/vec3/v3_orthonormal_matrix_from_normal.d.ts +2 -2
  23. package/src/core/geom/vec3/v3_orthonormal_matrix_from_normal.d.ts.map +1 -1
  24. package/src/core/geom/vec3/v3_orthonormal_matrix_from_normal.js +46 -46
  25. package/src/engine/graphics/sh3/path_tracer/sampling/getBiasedNormalSample.d.ts.map +1 -1
  26. package/src/engine/graphics/sh3/path_tracer/sampling/getBiasedNormalSample.js +6 -28
  27. package/src/engine/navigation/mesh/PATHFINDING_PLAN.md +185 -0
  28. package/src/engine/navigation/mesh/bt_mesh_face_find_path.d.ts +11 -0
  29. package/src/engine/navigation/mesh/bt_mesh_face_find_path.d.ts.map +1 -1
  30. package/src/engine/navigation/mesh/bt_mesh_face_find_path.js +623 -100
  31. package/src/engine/navigation/mesh/build/clip_soup_against_overhangs.d.ts +11 -0
  32. package/src/engine/navigation/mesh/build/clip_soup_against_overhangs.d.ts.map +1 -0
  33. package/src/engine/navigation/mesh/build/clip_soup_against_overhangs.js +472 -0
  34. package/src/engine/navigation/mesh/build/navmesh_build_topology.d.ts.map +1 -1
  35. package/src/engine/navigation/mesh/build/navmesh_build_topology.js +36 -39
  36. package/src/engine/navigation/mesh/navmesh_polyanya_find_path.d.ts +17 -0
  37. package/src/engine/navigation/mesh/navmesh_polyanya_find_path.d.ts.map +1 -0
  38. package/src/engine/navigation/mesh/navmesh_polyanya_find_path.js +613 -0
  39. package/src/engine/navigation/mesh/build/bt_mesh_carve_height_clearance.d.ts +0 -28
  40. package/src/engine/navigation/mesh/build/bt_mesh_carve_height_clearance.d.ts.map +0 -1
  41. package/src/engine/navigation/mesh/build/bt_mesh_carve_height_clearance.js +0 -358
  42. package/src/engine/navigation/mesh/build/enforce_agent_height_clearance.d.ts +0 -23
  43. package/src/engine/navigation/mesh/build/enforce_agent_height_clearance.d.ts.map +0 -1
  44. package/src/engine/navigation/mesh/build/enforce_agent_height_clearance.js +0 -319
@@ -1,82 +1,564 @@
1
1
  import { assert } from "../../../core/assert.js";
2
2
  import { Uint32Heap } from "../../../core/collection/heap/Uint32Heap.js";
3
- import { bt_face_get_centroid } from "../../../core/geom/3d/topology/struct/binary/query/bt_face_get_centroid.js";
3
+ import { NULL_POINTER } from "../../../core/geom/3d/topology/struct/binary/BinaryTopology.js";
4
4
  import {
5
5
  bt_face_get_neighbour_faces
6
6
  } from "../../../core/geom/3d/topology/struct/binary/query/bt_face_get_neighbour_faces.js";
7
+ import {
8
+ bt_mesh_compute_edge_distance_eikonal
9
+ } from "../../../core/geom/3d/topology/struct/binary/query/bt_mesh_compute_edge_distance_eikonal.js";
7
10
 
8
- const open = new Uint32Heap();
11
+ /**
12
+ * Note that we limit the supported number of neighbors, a reasonable mesh will fit this criteria
13
+ * @type {Uint32Array}
14
+ */
15
+ const neighbors = new Uint32Array(256);
16
+
17
+ /**
18
+ * Geodesic distance (along the surface) from the goal face to every reached vertex. Rebuilt per
19
+ * query; reused to avoid re-allocation.
20
+ * @type {Map<number, number>}
21
+ */
22
+ const geodesic_distance = new Map();
9
23
 
10
24
  /**
25
+ * Vertices whose geodesic distance has been finalised by the Fast Marching pass.
11
26
  * @type {Set<number>}
12
27
  */
13
- const closed = new Set();
28
+ const frozen_vertices = new Set();
14
29
 
15
30
  /**
16
- * Least known traversal cost (sum of centroid-to-centroid distances) from start to each face.
17
- * @type {Map<number, number>}
31
+ * Faces already settled by the corridor trace / fallback search. Reused across passes.
32
+ * @type {Set<number>}
33
+ */
34
+ const visited_faces = new Set();
35
+
36
+ /**
37
+ * Priority queue, reused by the Fast Marching fill (ordered by f = distance + heuristic) and by the
38
+ * fallback corridor search (ordered by distance to goal).
39
+ * @type {Uint32Heap}
18
40
  */
19
- const g_score = new Map();
41
+ const open = new Uint32Heap();
20
42
 
21
43
  /**
22
- * Reconstruction parent pointers: face -> the face we reached it from on the best path.
44
+ * Reconstruction parent pointers for the fallback search: face -> the face we reached it from.
23
45
  * @type {Map<number, number>}
24
46
  */
25
47
  const came_from = new Map();
26
48
 
49
+ const scratch_face_vertices = [0, 0, 0];
50
+
51
+ // scratch vertex position used by the Fast Marching pass (kept apart from the trace's pos_* scratch)
52
+ const scratch_vertex_pos = new Float64Array(3);
53
+
54
+ // the three vertex positions of the face currently being traced
55
+ const pos_a = new Float64Array(3);
56
+ const pos_b = new Float64Array(3);
57
+ const pos_c = new Float64Array(3);
58
+
59
+ // in-plane orthonormal basis of the current face (origin at vertex A)
60
+ const basis_u = new Float64Array(3);
61
+ const basis_v = new Float64Array(3);
62
+
63
+ // the goal-distance field value at each vertex of the current face
64
+ const scratch_field = new Float64Array(3);
65
+
66
+ const scratch_centroid = new Float64Array(3);
67
+
68
+ // communicates the ray parameter of the chosen exit edge out of pick_exit_edge (avoids allocation)
69
+ let last_exit_t = 0;
70
+
71
+ // number of vertices the last Fast Marching pass froze; bounds the corridor trace's iteration count
72
+ let frozen_count = 0;
73
+
27
74
  /**
28
- * Note that we limit the supported number of neighbors, a reasonable mesh will fit this criteria
29
- * @type {Uint32Array}
75
+ * Read the three vertex IDs of a triangular face into `out` (length 3). Faces are assumed triangular.
76
+ *
77
+ * @param {number[]} out
78
+ * @param {BinaryTopology} topology
79
+ * @param {number} face
80
+ * @returns {boolean} false if the face has no loop (uninitialised / out of bounds)
30
81
  */
31
- const neighbors = new Uint32Array(256);
82
+ function face_read_vertices(out, topology, face) {
83
+ const loop_a = topology.face_read_loop(face);
84
+
85
+ if (loop_a === NULL_POINTER) {
86
+ return false;
87
+ }
32
88
 
33
- const scratch_array_f32 = new Float32Array(6);
89
+ const loop_b = topology.loop_read_next(loop_a);
90
+ const loop_c = topology.loop_read_next(loop_b);
91
+
92
+ out[0] = topology.loop_read_vertex(loop_a);
93
+ out[1] = topology.loop_read_vertex(loop_b);
94
+ out[2] = topology.loop_read_vertex(loop_c);
95
+
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Straight-line distance between two vertices.
101
+ */
102
+ function vertex_distance(topology, a, b) {
103
+ topology.vertex_read_coordinate(scratch_vertex_pos, 0, a);
104
+ const ax = scratch_vertex_pos[0], ay = scratch_vertex_pos[1], az = scratch_vertex_pos[2];
105
+
106
+ topology.vertex_read_coordinate(scratch_vertex_pos, 0, b);
107
+ const dx = ax - scratch_vertex_pos[0], dy = ay - scratch_vertex_pos[1], dz = az - scratch_vertex_pos[2];
108
+
109
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
110
+ }
111
+
112
+ /**
113
+ * Straight-line distance from a vertex to a point. Admissible/consistent heuristic for the geodesic
114
+ * (true geodesic distance is never shorter than the straight line), used to make the fill goal-directed.
115
+ */
116
+ function vertex_distance_to_point(topology, vertex, px, py, pz) {
117
+ topology.vertex_read_coordinate(scratch_vertex_pos, 0, vertex);
118
+ const dx = scratch_vertex_pos[0] - px, dy = scratch_vertex_pos[1] - py, dz = scratch_vertex_pos[2] - pz;
119
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
120
+ }
121
+
122
+ /**
123
+ * The vertex of `face` that is neither `a` nor `b`, or -1 if the face does not have one.
124
+ */
125
+ function third_vertex(topology, face, a, b) {
126
+ let loop = topology.face_read_loop(face);
127
+
128
+ for (let i = 0; i < 3; i++) {
129
+ const v = topology.loop_read_vertex(loop);
130
+
131
+ if (v !== a && v !== b) {
132
+ return v;
133
+ }
134
+
135
+ loop = topology.loop_read_next(loop);
136
+ }
137
+
138
+ return -1;
139
+ }
34
140
 
35
141
  /**
36
- * Straight-line distance between the centroids of two faces.
142
+ * Solve the geodesic distance field outward from the goal face using Fast Marching (the exact Eikonal
143
+ * triangle update). The field is goal-directed: the heap is ordered by `distance + h`, where `h` is the
144
+ * straight-line distance to the start centroid, so the wavefront sweeps toward the start (exploring the
145
+ * start<->goal "ellipse" rather than the whole component). With `early_terminate` it stops the instant
146
+ * all three start-face vertices are finalised -- by then every vertex on the geodesic corridor (all of
147
+ * which are nearer the goal, hence finalised earlier) is resolved.
37
148
  *
38
- * This is used both as the step cost (between adjacent faces) and as the heuristic (from a face to
39
- * the goal). Because, by the triangle inequality, the straight-line distance from any face to the
40
- * goal is never greater than the summed centroid-to-centroid distance of an actual face path between
41
- * them, the heuristic is admissible AND consistent. A* therefore returns a shortest-distance corridor
42
- * (under the centroid metric) rather than a fewest-faces one.
149
+ * The heap uses lazy deletion (stale entries are skipped on pop) instead of an O(n) decrease-key.
43
150
  *
44
- * @param {number} a
45
- * @param {number} b
46
151
  * @param {BinaryTopology} topology
47
- * @returns {number}
152
+ * @param {number} goal_face seed of the wavefront (distance 0)
153
+ * @param {number} start_face heuristic target / early-termination target
154
+ * @param {boolean} early_terminate
155
+ * @returns {boolean} true if the start face was reached (a path exists), false otherwise
48
156
  */
49
- function face_centroid_distance(a, b, topology) {
50
- bt_face_get_centroid(scratch_array_f32, 0, topology, a);
51
- bt_face_get_centroid(scratch_array_f32, 3, topology, b);
157
+ function solve_geodesic_field(topology, goal_face, start_face, early_terminate) {
158
+ geodesic_distance.clear();
159
+ frozen_vertices.clear();
160
+ open.clear();
161
+
162
+ // start-face vertices (early-termination target) and centroid (heuristic target)
163
+ face_read_vertices(scratch_face_vertices, topology, start_face);
164
+ const start_v0 = scratch_face_vertices[0];
165
+ const start_v1 = scratch_face_vertices[1];
166
+ const start_v2 = scratch_face_vertices[2];
167
+
168
+ topology.vertex_read_coordinate(pos_a, 0, start_v0);
169
+ topology.vertex_read_coordinate(pos_b, 0, start_v1);
170
+ topology.vertex_read_coordinate(pos_c, 0, start_v2);
171
+ const start_x = (pos_a[0] + pos_b[0] + pos_c[0]) / 3;
172
+ const start_y = (pos_a[1] + pos_b[1] + pos_c[1]) / 3;
173
+ const start_z = (pos_a[2] + pos_b[2] + pos_c[2]) / 3;
174
+
175
+ // seed the goal-face vertices at distance 0
176
+ face_read_vertices(scratch_face_vertices, topology, goal_face);
177
+ for (let i = 0; i < 3; i++) {
178
+ const gv = scratch_face_vertices[i];
179
+ if (!geodesic_distance.has(gv)) {
180
+ geodesic_distance.set(gv, 0);
181
+ open.insert(gv, vertex_distance_to_point(topology, gv, start_x, start_y, start_z));
182
+ }
183
+ }
52
184
 
53
- const dx = scratch_array_f32[0] - scratch_array_f32[3];
54
- const dy = scratch_array_f32[1] - scratch_array_f32[4];
55
- const dz = scratch_array_f32[2] - scratch_array_f32[5];
185
+ frozen_count = 0;
56
186
 
57
- return Math.sqrt(dx * dx + dy * dy + dz * dz);
187
+ while (!open.is_empty()) {
188
+ const v = open.pop_min();
189
+
190
+ if (frozen_vertices.has(v)) {
191
+ continue; // stale heap entry (lazy deletion)
192
+ }
193
+
194
+ frozen_vertices.add(v);
195
+ frozen_count++;
196
+
197
+ if (early_terminate
198
+ && (v === start_v0 || v === start_v1 || v === start_v2)
199
+ && frozen_vertices.has(start_v0) && frozen_vertices.has(start_v1) && frozen_vertices.has(start_v2)
200
+ ) {
201
+ return true; // start face fully resolved; the whole corridor is finalised
202
+ }
203
+
204
+ const g_v = geodesic_distance.get(v);
205
+
206
+ // walk v's disk cycle (every edge incident to v)
207
+ const edge_first = topology.vertex_read_edge(v);
208
+ if (edge_first === NULL_POINTER) {
209
+ continue;
210
+ }
211
+
212
+ let edge = edge_first;
213
+ do {
214
+ const ev1 = topology.edge_read_vertex1(edge);
215
+ const ev2 = topology.edge_read_vertex2(edge);
216
+ const v_is_first = ev1 === v;
217
+ const w = v_is_first ? ev2 : ev1;
218
+
219
+ if (frozen_vertices.has(w)) {
220
+ // both ends of this edge are finalised -> propagate the wavefront across each incident
221
+ // triangle to its third vertex with the exact Eikonal update
222
+ const g_w = geodesic_distance.get(w);
223
+
224
+ const loop_first = topology.edge_read_loop(edge);
225
+ if (loop_first !== NULL_POINTER) {
226
+ let loop = loop_first;
227
+ do {
228
+ const face = topology.loop_read_face(loop);
229
+ const t = third_vertex(topology, face, ev1, ev2);
230
+
231
+ if (t !== -1 && !frozen_vertices.has(t)) {
232
+ const d = bt_mesh_compute_edge_distance_eikonal(topology, v, w, g_v, g_w, t);
233
+ const existing = geodesic_distance.get(t);
234
+
235
+ if (existing === undefined || d < existing) {
236
+ geodesic_distance.set(t, d);
237
+ open.insert(t, d + vertex_distance_to_point(topology, t, start_x, start_y, start_z));
238
+ }
239
+ }
240
+
241
+ loop = topology.loop_read_radial_next(loop);
242
+ } while (loop !== loop_first && loop !== NULL_POINTER);
243
+ }
244
+ } else {
245
+ // graph-distance relaxation, so w gets a value even before a finalised front forms across
246
+ // it; a later Eikonal update lowers it to the true geodesic before w itself is frozen
247
+ const d = g_v + vertex_distance(topology, v, w);
248
+ const existing = geodesic_distance.get(w);
249
+
250
+ if (existing === undefined || d < existing) {
251
+ geodesic_distance.set(w, d);
252
+ open.insert(w, d + vertex_distance_to_point(topology, w, start_x, start_y, start_z));
253
+ }
254
+ }
255
+
256
+ edge = v_is_first ? topology.edge_read_v1_disk_next(edge) : topology.edge_read_v2_disk_next(edge);
257
+ } while (edge !== edge_first && edge !== NULL_POINTER);
258
+ }
259
+
260
+ // the heap drained: if we were early-terminating, the start was never reached. Either way, report
261
+ // whether the start face's vertices ended up finalised (a full solve still answers reachability).
262
+ return frozen_vertices.has(start_v0) && frozen_vertices.has(start_v1) && frozen_vertices.has(start_v2);
58
263
  }
59
264
 
60
265
  /**
61
- * Walk parent pointers from goal back to start, writing the face sequence into `output` in
62
- * START -> GOAL order. Never writes more than `max_path_length` entries: if the corridor is longer
63
- * than that it does not fit the caller's buffer and 0 is returned (no partial/garbage result is used).
266
+ * The face on the far side of `loop`'s edge, or {@link NULL_POINTER} if the edge is a boundary.
267
+ */
268
+ function loop_opposite_face(topology, loop, face) {
269
+ let radial = topology.loop_read_radial_next(loop);
270
+
271
+ while (radial !== loop) {
272
+ const f = topology.loop_read_face(radial);
273
+
274
+ if (f !== face) {
275
+ return f;
276
+ }
277
+
278
+ radial = topology.loop_read_radial_next(radial);
279
+ }
280
+
281
+ return NULL_POINTER;
282
+ }
283
+
284
+ /**
285
+ * Average of a face's three vertices' distances to the goal. The average (rather than the minimum)
286
+ * keeps a single near-goal vertex of an oversized triangle from making the whole face look close.
287
+ * Returns Infinity if any vertex was not reached by the fill.
288
+ */
289
+ function face_geodesic_distance(topology, face) {
290
+ if (!face_read_vertices(scratch_face_vertices, topology, face)) {
291
+ return Infinity;
292
+ }
293
+
294
+ const da = geodesic_distance.get(scratch_face_vertices[0]);
295
+ const db = geodesic_distance.get(scratch_face_vertices[1]);
296
+ const dc = geodesic_distance.get(scratch_face_vertices[2]);
297
+
298
+ if (da === undefined || db === undefined || dc === undefined) {
299
+ return Infinity;
300
+ }
301
+
302
+ return (da + db + dc) / 3;
303
+ }
304
+
305
+ /**
306
+ * Load the geometry of `face` into module scratch: the three vertex positions, the goal-distance field
307
+ * value at each vertex, and an in-plane orthonormal basis (origin at vertex A, x-axis along A->B).
308
+ */
309
+ function load_face_geometry(topology, face) {
310
+ face_read_vertices(scratch_face_vertices, topology, face);
311
+
312
+ topology.vertex_read_coordinate(pos_a, 0, scratch_face_vertices[0]);
313
+ topology.vertex_read_coordinate(pos_b, 0, scratch_face_vertices[1]);
314
+ topology.vertex_read_coordinate(pos_c, 0, scratch_face_vertices[2]);
315
+
316
+ scratch_field[0] = geodesic_distance.get(scratch_face_vertices[0]);
317
+ scratch_field[1] = geodesic_distance.get(scratch_face_vertices[1]);
318
+ scratch_field[2] = geodesic_distance.get(scratch_face_vertices[2]);
319
+
320
+ const e1x = pos_b[0] - pos_a[0], e1y = pos_b[1] - pos_a[1], e1z = pos_b[2] - pos_a[2];
321
+ const e2x = pos_c[0] - pos_a[0], e2y = pos_c[1] - pos_a[1], e2z = pos_c[2] - pos_a[2];
322
+
323
+ const e1_len = Math.hypot(e1x, e1y, e1z);
324
+ basis_u[0] = e1x / e1_len;
325
+ basis_u[1] = e1y / e1_len;
326
+ basis_u[2] = e1z / e1_len;
327
+
328
+ // n = e1 x e2 (face normal); v = normalize(n x u) completes the in-plane right-handed basis
329
+ const nx = e1y * e2z - e1z * e2y;
330
+ const ny = e1z * e2x - e1x * e2z;
331
+ const nz = e1x * e2y - e1y * e2x;
332
+
333
+ const vx = ny * basis_u[2] - nz * basis_u[1];
334
+ const vy = nz * basis_u[0] - nx * basis_u[2];
335
+ const vz = nx * basis_u[1] - ny * basis_u[0];
336
+ const v_len = Math.hypot(vx, vy, vz);
337
+ basis_v[0] = vx / v_len;
338
+ basis_v[1] = vy / v_len;
339
+ basis_v[2] = vz / v_len;
340
+ }
341
+
342
+ /** Project a world point onto the current face basis x-axis (origin = vertex A). */
343
+ function project_x(px, py, pz) {
344
+ return (px - pos_a[0]) * basis_u[0] + (py - pos_a[1]) * basis_u[1] + (pz - pos_a[2]) * basis_u[2];
345
+ }
346
+
347
+ /** Project a world point onto the current face basis y-axis (origin = vertex A). */
348
+ function project_y(px, py, pz) {
349
+ return (px - pos_a[0]) * basis_v[0] + (py - pos_a[1]) * basis_v[1] + (pz - pos_a[2]) * basis_v[2];
350
+ }
351
+
352
+ /**
353
+ * Parameter `t` at which ray (ox,oy)+t*(dx,dy) crosses segment (e0)->(e1) within the segment, or
354
+ * Infinity if it does not cross ahead of the origin.
355
+ */
356
+ function ray_segment_t(ox, oy, dx, dy, e0x, e0y, e1x, e1y) {
357
+ const sx = e1x - e0x;
358
+ const sy = e1y - e0y;
359
+
360
+ const denom = dx * sy - dy * sx;
361
+
362
+ if (denom === 0) {
363
+ return Infinity; // parallel
364
+ }
365
+
366
+ const wx = e0x - ox;
367
+ const wy = e0y - oy;
368
+
369
+ const t = (wx * sy - wy * sx) / denom; // distance along the ray
370
+ const u = (wx * dy - wy * dx) / denom; // position along the segment
371
+
372
+ if (t > 1e-9 && u >= -1e-9 && u <= 1 + 1e-9) {
373
+ return t;
374
+ }
375
+
376
+ return Infinity;
377
+ }
378
+
379
+ /**
380
+ * Among the three edges of a triangle (in the face's 2D basis), find the one the ray (ox,oy)+t*(dx,dy)
381
+ * exits through (smallest t > 0), and return the face on its far side. Stores the chosen t in
382
+ * {@link last_exit_t}.
383
+ */
384
+ function pick_exit_edge(
385
+ ox, oy, dx, dy,
386
+ bx, cx, cy,
387
+ topology, face, loop_ab, loop_bc, loop_ca
388
+ ) {
389
+ let best_t = Infinity;
390
+ let best_face = NULL_POINTER;
391
+
392
+ let t = ray_segment_t(ox, oy, dx, dy, 0, 0, bx, 0); // edge AB
393
+ if (t < best_t) { best_t = t; best_face = loop_opposite_face(topology, loop_ab, face); }
394
+
395
+ t = ray_segment_t(ox, oy, dx, dy, bx, 0, cx, cy); // edge BC
396
+ if (t < best_t) { best_t = t; best_face = loop_opposite_face(topology, loop_bc, face); }
397
+
398
+ t = ray_segment_t(ox, oy, dx, dy, cx, cy, 0, 0); // edge CA
399
+ if (t < best_t) { best_t = t; best_face = loop_opposite_face(topology, loop_ca, face); }
400
+
401
+ last_exit_t = best_t;
402
+ return best_face;
403
+ }
404
+
405
+ /**
406
+ * The unvisited neighbour face with the smallest average goal-distance. Used as a robust fallback when
407
+ * the gradient march cannot pick a forward exit edge.
408
+ */
409
+ function lowest_unvisited_neighbour(topology, face) {
410
+ const count = bt_face_get_neighbour_faces(neighbors, 0, topology, face);
411
+
412
+ let best_face = NULL_POINTER;
413
+ let best_distance = Infinity;
414
+
415
+ for (let i = 0; i < count; i++) {
416
+ const neighbor = neighbors[i];
417
+
418
+ if (visited_faces.has(neighbor)) {
419
+ continue;
420
+ }
421
+
422
+ const distance = face_geodesic_distance(topology, neighbor);
423
+
424
+ if (distance < best_distance) {
425
+ best_distance = distance;
426
+ best_face = neighbor;
427
+ }
428
+ }
429
+
430
+ return best_face;
431
+ }
432
+
433
+ /** Write the centroid of a triangular face into {@link scratch_centroid}. */
434
+ function face_centroid(topology, face) {
435
+ face_read_vertices(scratch_face_vertices, topology, face);
436
+
437
+ topology.vertex_read_coordinate(pos_a, 0, scratch_face_vertices[0]);
438
+ topology.vertex_read_coordinate(pos_b, 0, scratch_face_vertices[1]);
439
+ topology.vertex_read_coordinate(pos_c, 0, scratch_face_vertices[2]);
440
+
441
+ scratch_centroid[0] = (pos_a[0] + pos_b[0] + pos_c[0]) / 3;
442
+ scratch_centroid[1] = (pos_a[1] + pos_b[1] + pos_c[1]) / 3;
443
+ scratch_centroid[2] = (pos_a[2] + pos_b[2] + pos_c[2]) / 3;
444
+ }
445
+
446
+ /**
447
+ * Trace the steepest descent of the geodesic field from the start face to the goal face, writing the
448
+ * face corridor into `output` in START -> GOAL order. Following the in-plane gradient downhill crosses
449
+ * exactly the faces the true geodesic does, so the corridor admits the shortest path. Returns 0 if the
450
+ * descent cannot complete (e.g. it has to wind around a hole, where the field's gradient is no longer
451
+ * a reliable guide) -- the caller then falls back to a graph search.
64
452
  *
65
- * @param {number[]|Uint32Array} output
66
- * @param {number} start_face
67
- * @param {number} goal_face
68
- * @param {Map<number, number>} came_from
69
- * @param {number} max_path_length
70
- * @returns {number} number of faces written, or 0 if the corridor exceeds `max_path_length`
71
- */
72
- function construct_path(output, start_face, goal_face, came_from, max_path_length) {
453
+ * @returns {number} faces written, or 0 on failure / if the corridor exceeds `max_path_length`
454
+ */
455
+ function trace_corridor(output, topology, start_face, goal_face, max_path_length) {
456
+ visited_faces.clear();
457
+
458
+ face_centroid(topology, start_face);
459
+ let world_x = scratch_centroid[0];
460
+ let world_y = scratch_centroid[1];
461
+ let world_z = scratch_centroid[2];
462
+
463
+ let current = start_face;
464
+ let previous = NULL_POINTER;
465
+ let length = 0;
466
+
467
+ // each step adds a fresh face; the count of finalised vertices bounds the reachable faces
468
+ const iteration_cap = frozen_count * 2 + 8;
469
+
470
+ for (let iteration = 0; iteration < iteration_cap; iteration++) {
471
+ if (length >= max_path_length) {
472
+ return 0;
473
+ }
474
+
475
+ output[length] = current;
476
+ length++;
477
+
478
+ if (current === goal_face) {
479
+ return length;
480
+ }
481
+
482
+ visited_faces.add(current);
483
+
484
+ load_face_geometry(topology, current);
485
+
486
+ const ga = scratch_field[0], gb = scratch_field[1], gc = scratch_field[2];
487
+
488
+ const px = project_x(world_x, world_y, world_z);
489
+ const py = project_y(world_x, world_y, world_z);
490
+
491
+ const bx = project_x(pos_b[0], pos_b[1], pos_b[2]); // B.y == 0 by construction
492
+ const cx = project_x(pos_c[0], pos_c[1], pos_c[2]);
493
+ const cy = project_y(pos_c[0], pos_c[1], pos_c[2]);
494
+
495
+ // in-plane gradient of the (linear) field; descend it (negative gradient points at the goal)
496
+ const grad_x = (gb - ga) / bx;
497
+ const grad_y = (gc - ga - grad_x * cx) / cy;
498
+
499
+ let dir_x = -grad_x;
500
+ let dir_y = -grad_y;
501
+ const dir_len = Math.hypot(dir_x, dir_y);
502
+
503
+ let next = NULL_POINTER;
504
+
505
+ if (dir_len > 1e-12) {
506
+ dir_x /= dir_len;
507
+ dir_y /= dir_len;
508
+
509
+ const loop_a = topology.face_read_loop(current);
510
+ const loop_b = topology.loop_read_next(loop_a);
511
+ const loop_c = topology.loop_read_next(loop_b);
512
+
513
+ next = pick_exit_edge(px, py, dir_x, dir_y, bx, cx, cy, topology, current, loop_a, loop_b, loop_c);
514
+
515
+ if (next !== NULL_POINTER && next !== previous && !visited_faces.has(next)) {
516
+ // follow the gradient: re-enter the next face at the exit point (on the shared edge)
517
+ const exit_x = px + dir_x * last_exit_t;
518
+ const exit_y = py + dir_y * last_exit_t;
519
+
520
+ world_x = pos_a[0] + exit_x * basis_u[0] + exit_y * basis_v[0];
521
+ world_y = pos_a[1] + exit_x * basis_u[1] + exit_y * basis_v[1];
522
+ world_z = pos_a[2] + exit_x * basis_u[2] + exit_y * basis_v[2];
523
+
524
+ previous = current;
525
+ current = next;
526
+ continue;
527
+ }
528
+ }
529
+
530
+ // the gradient gave no usable forward edge (a boundary/hole, a vertex hit, or it pointed into
531
+ // an already-traced face). Fall back to the lowest-field unvisited neighbour, re-entering at
532
+ // its centroid. If that happens we are near a hole, so the caller will likely re-run as a
533
+ // graph search; here we just keep the corridor connected.
534
+ next = lowest_unvisited_neighbour(topology, current);
535
+
536
+ if (next === NULL_POINTER) {
537
+ return 0;
538
+ }
539
+
540
+ face_centroid(topology, next);
541
+ world_x = scratch_centroid[0];
542
+ world_y = scratch_centroid[1];
543
+ world_z = scratch_centroid[2];
544
+
545
+ previous = current;
546
+ current = next;
547
+ }
548
+
549
+ return 0;
550
+ }
551
+
552
+ /**
553
+ * Walk parent pointers from goal back to start, writing the face sequence into `output` in
554
+ * START -> GOAL order, or 0 if the corridor exceeds `max_path_length`.
555
+ */
556
+ function construct_path(output, start_face, goal_face, max_path_length) {
73
557
  let length = 0;
74
558
  let current = goal_face;
75
559
 
76
- // walk back to the start, writing in reverse (goal -> start)
77
560
  while (true) {
78
561
  if (length >= max_path_length) {
79
- // corridor does not fit the output buffer; refuse rather than writing out of bounds
80
562
  return 0;
81
563
  }
82
564
 
@@ -90,7 +572,6 @@ function construct_path(output, start_face, goal_face, came_from, max_path_lengt
90
572
  current = came_from.get(current);
91
573
  }
92
574
 
93
- // reverse in place to get START -> GOAL order
94
575
  const half_length = length >> 1;
95
576
 
96
577
  for (let i = 0; i < half_length; i++) {
@@ -104,11 +585,84 @@ function construct_path(output, start_face, goal_face, came_from, max_path_lengt
104
585
  return length;
105
586
  }
106
587
 
588
+ /**
589
+ * Fallback corridor finder: a best-first walk over faces, always expanding the one currently closest
590
+ * to the goal on the geodesic field. Always finds a corridor when one exists within the resolved field
591
+ * (it routes around holes the gradient trace could not), at the cost of not necessarily hugging the
592
+ * exact geodesic.
593
+ *
594
+ * @returns {number} faces written, or 0 if no path / it exceeds `max_path_length`
595
+ */
596
+ function search_corridor(output, topology, start_face, goal_face, max_path_length) {
597
+ open.clear();
598
+ came_from.clear();
599
+ visited_faces.clear();
600
+
601
+ open.insert(start_face, face_geodesic_distance(topology, start_face));
602
+ visited_faces.add(start_face);
603
+
604
+ while (!open.is_empty()) {
605
+ const current = open.pop_min();
606
+
607
+ if (current === goal_face) {
608
+ return construct_path(output, start_face, goal_face, max_path_length);
609
+ }
610
+
611
+ const count = bt_face_get_neighbour_faces(neighbors, 0, topology, current);
612
+
613
+ for (let i = 0; i < count; i++) {
614
+ const neighbor = neighbors[i];
615
+
616
+ if (visited_faces.has(neighbor)) {
617
+ continue;
618
+ }
619
+
620
+ const distance = face_geodesic_distance(topology, neighbor);
621
+
622
+ if (distance === Infinity) {
623
+ continue; // outside the resolved field
624
+ }
625
+
626
+ visited_faces.add(neighbor);
627
+ came_from.set(neighbor, current);
628
+ open.insert(neighbor, distance);
629
+ }
630
+ }
631
+
632
+ return 0;
633
+ }
634
+
635
+ /**
636
+ * Extract the corridor from the resolved field: gradient trace first (hugs the geodesic), graph-walk
637
+ * fallback for winding around holes.
638
+ *
639
+ * @returns {number} faces written, or 0 if neither method produced a corridor
640
+ */
641
+ function extract_corridor(output, topology, start_face, goal_face, max_path_length) {
642
+ const traced = trace_corridor(output, topology, start_face, goal_face, max_path_length);
643
+
644
+ if (traced !== 0) {
645
+ return traced;
646
+ }
647
+
648
+ return search_corridor(output, topology, start_face, goal_face, max_path_length);
649
+ }
107
650
 
108
651
  /**
109
652
  * Find a shortest path through topology faces.
110
653
  * If a path is found - the result will contain start and goal faces.
111
654
  *
655
+ * The corridor is steered by an exact-geodesic distance field rather than a face-graph metric. A
656
+ * goal-directed Fast Marching pass (exact Eikonal triangle update) solves the true along-surface
657
+ * distance-to-goal at each vertex, sweeping toward the start and stopping as soon as the start face is
658
+ * resolved. Because the field measures the real walked distance (not centroid-to-centroid hops), the
659
+ * corridor heads straight at the goal and crosses a finely tessellated patch instead of skirting around
660
+ * it. The corridor is then extracted by descending the field's gradient from the start (which hugs the
661
+ * geodesic, so the downstream funnel/string-pull recovers the exact shortest path); where the gradient
662
+ * is an unreliable guide -- winding around a hole -- a best-first graph walk on the same field takes
663
+ * over. A full (non-early-terminated) solve is used as a backstop for the rare case the bounded fill
664
+ * left the corridor under-resolved.
665
+ *
112
666
  * NOTE: if either start or goal faces are not part of the topology - an empty path will be produced.
113
667
  *
114
668
  * @param {number[]|Uint32Array} output path will be written here as a sequence of face IDs
@@ -135,73 +689,42 @@ export function bt_mesh_face_find_path(
135
689
  assert.defined(topology, 'topology');
136
690
  assert.equal(topology.isBinaryTopology, true, 'topology.isBinaryTopology !== true');
137
691
 
138
- open.clear();
139
- closed.clear();
140
- g_score.clear();
141
- came_from.clear();
142
-
143
- g_score.set(start_face_id, 0);
144
-
145
- open.insert(start_face_id, face_centroid_distance(start_face_id, goal_face_id, topology));
146
-
147
- while (!open.is_empty()) {
148
-
149
- const current_node = open.pop_min();
150
-
151
- // Lazy deletion: a face may sit in the heap multiple times if its g-score improved after an
152
- // earlier push. The lowest-f entry is popped first; later popped duplicates land on an
153
- // already-closed face and must be skipped.
154
- if (closed.has(current_node)) {
155
- continue;
692
+ if (start_face_id === goal_face_id) {
693
+ // trivial path; still validate the face actually exists
694
+ if (!topology.faces.is_allocated(start_face_id)) {
695
+ return 0;
156
696
  }
157
697
 
158
- if (current_node === goal_face_id) {
159
- // Reached the goal
160
- return construct_path(output, start_face_id, goal_face_id, came_from, max_path_length);
698
+ if (max_path_length < 1) {
699
+ return 0;
161
700
  }
162
701
 
163
- closed.add(current_node);
164
-
165
- const current_g_score = g_score.get(current_node);
166
-
167
- const neighbor_count = bt_face_get_neighbour_faces(neighbors, 0, topology, current_node);
168
-
169
- for (let i = 0; i < neighbor_count; i++) {
170
-
171
- const neighbor = neighbors[i];
172
-
173
- if (closed.has(neighbor)) {
174
- // already closed
175
- continue;
176
- }
177
-
178
- // step cost is the real geometric distance between adjacent face centroids, so the
179
- // search minimises corridor length rather than the number of triangles crossed
180
- const traversal_cost = face_centroid_distance(current_node, neighbor, topology);
181
-
182
- const cost_so_far = current_g_score + traversal_cost;
183
-
184
- if (!g_score.has(neighbor) || cost_so_far < g_score.get(neighbor)) {
185
- // better path to this neighbour
186
-
187
- g_score.set(neighbor, cost_so_far);
188
- came_from.set(neighbor, current_node);
189
-
190
- const remaining_heuristic = face_centroid_distance(neighbor, goal_face_id, topology);
191
-
192
- const f_score = cost_so_far + remaining_heuristic;
702
+ output[0] = start_face_id;
703
+ return 1;
704
+ }
193
705
 
194
- // Lazy push: always insert. If neighbor was already on the open list, the older
195
- // (worse-f) entry will be skipped when popped (see closed-check at the top of the
196
- // loop). This avoids an O(n) find_index_by_id scan per relaxation.
197
- open.insert(neighbor, f_score);
706
+ // both faces must be part of the topology
707
+ if (!topology.faces.is_allocated(start_face_id) || !topology.faces.is_allocated(goal_face_id)) {
708
+ return 0;
709
+ }
198
710
 
199
- }
711
+ // Fast path: goal-directed fill bounded to the start<->goal region. When the start is unreachable
712
+ // this drains exhaustively (it never early-exits), so a false return is a definitive "no path" --
713
+ // no second solve needed.
714
+ if (!solve_geodesic_field(topology, goal_face_id, start_face_id, true)) {
715
+ return 0; // no path between the two faces
716
+ }
200
717
 
201
- }
718
+ const corridor = extract_corridor(output, topology, start_face_id, goal_face_id, max_path_length);
202
719
 
720
+ if (corridor !== 0) {
721
+ return corridor;
203
722
  }
204
723
 
205
- // No result found
206
- return 0;
724
+ // Backstop: the bounded fill resolved enough to reach the start but left the corridor
725
+ // under-resolved for the extractor (rare; a corridor that winds far around a hole). A full solve
726
+ // resolves the whole reachable component, then the extractor cannot fail for a connected pair.
727
+ solve_geodesic_field(topology, goal_face_id, start_face_id, false);
728
+
729
+ return extract_corridor(output, topology, start_face_id, goal_face_id, max_path_length);
207
730
  }