@woosh/meep-engine 2.163.3 → 2.163.5

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.
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "description": "Pure JavaScript game engine. Fully featured and production ready.",
7
7
  "type": "module",
8
8
  "author": "Alexander Goldring",
9
- "version": "2.163.3",
9
+ "version": "2.163.5",
10
10
  "main": "build/meep.module.js",
11
11
  "module": "build/meep.module.js",
12
12
  "exports": {
@@ -1 +1 @@
1
- {"version":3,"file":"bt_mesh_face_island_erode.d.ts","sourceRoot":"","sources":["../../../../../../../../../src/core/geom/3d/topology/struct/binary/io/bt_mesh_face_island_erode.js"],"names":[],"mappings":"AAeA;;;;;;;;;;;;GAYG;AACH,uEAHW,MAAM,EAAE,kBACR,MAAM,QAqVhB"}
1
+ {"version":3,"file":"bt_mesh_face_island_erode.d.ts","sourceRoot":"","sources":["../../../../../../../../../src/core/geom/3d/topology/struct/binary/io/bt_mesh_face_island_erode.js"],"names":[],"mappings":"AAoCA;;;;;;;;;;;;GAYG;AACH,uEAHW,MAAM,EAAE,kBACR,MAAM,QAgehB"}
@@ -6,13 +6,34 @@ import {
6
6
  } from "../../../../line/line3_compute_segment_point_distance_eikonal.js";
7
7
  import { NULL_POINTER } from "../BinaryTopology.js";
8
8
  import { bt_face_get_incenter } from "../query/bt_face_get_incenter.js";
9
- import { bt_mesh_build_boundary_distance_field } from "../query/bt_mesh_build_boundary_distance_field.js";
10
9
  import { bt_mesh_cleanup_faceless_references } from "./bt_mesh_cleanup_faceless_references.js";
11
10
  import { bt_edge_split } from "./edge/bt_edge_split.js";
12
11
  import { bt_face_kill } from "./face/bt_face_kill.js";
13
12
  import { bt_mesh_face_poke } from "./face/bt_face_poke.js";
14
13
  import { bt_face_get_neighbour_faces } from "../query/bt_face_get_neighbour_faces.js";
15
14
 
15
+ const _eb_a = new Float32Array(3);
16
+ const _eb_b = new Float32Array(3);
17
+ const _eb_p = new Float32Array(3);
18
+
19
+ /**
20
+ * Squared straight-line distance from point p to segment a-b in 3D.
21
+ */
22
+ function point_segment_distance_sq(px, py, pz, ax, ay, az, bx, by, bz) {
23
+ const abx = bx - ax, aby = by - ay, abz = bz - az;
24
+ const apx = px - ax, apy = py - ay, apz = pz - az;
25
+ const ab_len_sq = abx * abx + aby * aby + abz * abz;
26
+
27
+ let t = ab_len_sq > 0 ? (apx * abx + apy * aby + apz * abz) / ab_len_sq : 0;
28
+ if (t < 0) t = 0; else if (t > 1) t = 1;
29
+
30
+ const cx = ax + t * abx - px;
31
+ const cy = ay + t * aby - py;
32
+ const cz = az + t * abz - pz;
33
+
34
+ return cx * cx + cy * cy + cz * cz;
35
+ }
36
+
16
37
  /**
17
38
  * Shrinks an island of faces by a given distance.
18
39
  * Conceptually, this moves the outline of the island inwards.
@@ -160,13 +181,152 @@ export function bt_mesh_face_island_erode(
160
181
  }
161
182
  }
162
183
 
163
- // 3. Compute Geodesic Distance Field
184
+ // 3. Distance field: EXACT straight-line distance from each island vertex to the nearest boundary
185
+ // edge. Agent clearance is a Euclidean (line-of-sight) distance to the nearest wall, NOT a geodesic
186
+ // distance measured along the surface. The previous eikonal field was both semantically wrong and
187
+ // sensitive to the triangulation - it produced a lumpy, non-uniform inset (e.g. a 14x14 platform
188
+ // built from strips eroded by 0.45-0.65 instead of a uniform 0.4). Exact distance is uniform.
164
189
  /**
165
190
  * Vertex -> Distance from boundary
166
191
  * @type {Map<number, number>}
167
192
  */
168
193
  const vertex_boundary_distances = new Map();
169
- bt_mesh_build_boundary_distance_field(vertex_boundary_distances, mesh, island_vertices, boundary_vertices);
194
+
195
+ // boundary edge segments, cached by compute_field for reuse in the deviation test below
196
+ let boundary_segments = [];
197
+
198
+ // (re)compute the exact distance field for the current island vertices/boundary
199
+ function compute_field() {
200
+ vertex_boundary_distances.clear();
201
+
202
+ boundary_segments = [];
203
+ for (const e of boundary_edges_set) {
204
+ const bv1 = mesh.edge_read_vertex1(e);
205
+ const bv2 = mesh.edge_read_vertex2(e);
206
+ mesh.vertex_read_coordinate(_eb_a, 0, bv1);
207
+ mesh.vertex_read_coordinate(_eb_b, 0, bv2);
208
+ boundary_segments.push(_eb_a[0], _eb_a[1], _eb_a[2], _eb_b[0], _eb_b[1], _eb_b[2]);
209
+ }
210
+
211
+ for (const v of island_vertices) {
212
+ mesh.vertex_read_coordinate(_eb_p, 0, v);
213
+ vertex_boundary_distances.set(v, dist_to_boundary(_eb_p[0], _eb_p[1], _eb_p[2]));
214
+ }
215
+ }
216
+
217
+ // exact straight-line distance from a point to the nearest boundary edge
218
+ function dist_to_boundary(px, py, pz) {
219
+ const seg = boundary_segments;
220
+ let best_sq = Infinity;
221
+ for (let i = 0; i < seg.length; i += 6) {
222
+ const d_sq = point_segment_distance_sq(
223
+ px, py, pz,
224
+ seg[i], seg[i + 1], seg[i + 2],
225
+ seg[i + 3], seg[i + 4], seg[i + 5]
226
+ );
227
+ if (d_sq < best_sq) best_sq = d_sq;
228
+ }
229
+ return best_sq === Infinity ? Infinity : Math.sqrt(best_sq);
230
+ }
231
+
232
+ compute_field();
233
+
234
+ // 3a. CUT-BAND REFINEMENT. The inset contour is reconstructed piecewise-linearly from the faces the
235
+ // erosion threshold passes through, so on a coarse/skewed triangulation the cut chord deviates from
236
+ // the true offset (a strip-built platform eroded unevenly, 0.4 on one side and 0.6 on another). For
237
+ // each straddling face, place the two cut points and test whether their midpoint really lies at the
238
+ // erode distance; only refine (split the longest edge) when the chord deviates. Already-accurate
239
+ // cuts (e.g. a plain quad's straight sides) are left coarse, so detail is added only where needed.
240
+ {
241
+ const fine = Math.max(erode_distance * 0.5, 0.1);
242
+ const fine_sq = fine * fine;
243
+ const tol = Math.max(erode_distance * 0.15, 0.04);
244
+ let rounds = 0;
245
+
246
+ const ca = new Float32Array(3), cb = new Float32Array(3), cc = new Float32Array(3);
247
+
248
+ while (rounds < 48) {
249
+ rounds++;
250
+
251
+ const targets = new Set();
252
+
253
+ for (let i = 0; i < island_faces.length; i++) {
254
+ const f = island_faces[i];
255
+ if (!mesh.faces.is_allocated(f)) continue;
256
+
257
+ const la = mesh.face_read_loop(f);
258
+ const lb = mesh.loop_read_next(la);
259
+ const lc = mesh.loop_read_next(lb);
260
+ const v1 = mesh.loop_read_vertex(la);
261
+ const v2 = mesh.loop_read_vertex(lb);
262
+ const v3 = mesh.loop_read_vertex(lc);
263
+
264
+ const d1 = vertex_boundary_distances.get(v1);
265
+ const d2 = vertex_boundary_distances.get(v2);
266
+ const d3 = vertex_boundary_distances.get(v3);
267
+
268
+ const dmin = Math.min(d1, d2, d3);
269
+ const dmax = Math.max(d1, d2, d3);
270
+
271
+ // does the erode threshold pass through this face?
272
+ if (!(dmin < erode_distance && dmax > erode_distance)) continue;
273
+
274
+ mesh.vertex_read_coordinate(ca, 0, v1);
275
+ mesh.vertex_read_coordinate(cb, 0, v2);
276
+ mesh.vertex_read_coordinate(cc, 0, v3);
277
+
278
+ const e_ab = (ca[0] - cb[0]) ** 2 + (ca[1] - cb[1]) ** 2 + (ca[2] - cb[2]) ** 2;
279
+ const e_bc = (cb[0] - cc[0]) ** 2 + (cb[1] - cc[1]) ** 2 + (cb[2] - cc[2]) ** 2;
280
+ const e_ca = (cc[0] - ca[0]) ** 2 + (cc[1] - ca[1]) ** 2 + (cc[2] - ca[2]) ** 2;
281
+ const longest = Math.max(e_ab, e_bc, e_ca);
282
+
283
+ if (longest <= fine_sq) continue;
284
+
285
+ // the two cut points where the threshold crosses this face's edges
286
+ let mx = 0, my = 0, mz = 0, crossings = 0;
287
+ // edge (v1,v2)
288
+ if ((d1 < erode_distance) !== (d2 < erode_distance)) {
289
+ const t = (erode_distance - d1) / (d2 - d1);
290
+ mx += ca[0] + t * (cb[0] - ca[0]); my += ca[1] + t * (cb[1] - ca[1]); mz += ca[2] + t * (cb[2] - ca[2]); crossings++;
291
+ }
292
+ // edge (v2,v3)
293
+ if ((d2 < erode_distance) !== (d3 < erode_distance)) {
294
+ const t = (erode_distance - d2) / (d3 - d2);
295
+ mx += cb[0] + t * (cc[0] - cb[0]); my += cb[1] + t * (cc[1] - cb[1]); mz += cb[2] + t * (cc[2] - cb[2]); crossings++;
296
+ }
297
+ // edge (v3,v1)
298
+ if ((d3 < erode_distance) !== (d1 < erode_distance)) {
299
+ const t = (erode_distance - d3) / (d1 - d3);
300
+ mx += cc[0] + t * (ca[0] - cc[0]); my += cc[1] + t * (ca[1] - cc[1]); mz += cc[2] + t * (ca[2] - cc[2]); crossings++;
301
+ }
302
+
303
+ if (crossings === 2) {
304
+ mx *= 0.5; my *= 0.5; mz *= 0.5;
305
+ const true_d = dist_to_boundary(mx, my, mz);
306
+ if (Math.abs(true_d - erode_distance) <= tol) {
307
+ // the straight cut here is already accurate - leave this face coarse
308
+ continue;
309
+ }
310
+ }
311
+
312
+ const sl = (longest === e_ab) ? la : (longest === e_bc) ? lb : lc;
313
+ targets.add(mesh.loop_read_edge(sl));
314
+ }
315
+
316
+ if (targets.size === 0) break;
317
+
318
+ for (const e of targets) {
319
+ if (mesh.edges.is_allocated(e)) bt_edge_split(mesh, e, 0.5);
320
+ }
321
+
322
+ island_faces = recompute_island_faces(faces);
323
+ ({ island_vertices, island_edges, boundary_vertices, boundary_edges_set } = collect_island_sets(island_faces));
324
+
325
+ compute_field();
326
+
327
+ if (mesh.faces.size > 200000) break;
328
+ }
329
+ }
170
330
  // ---------------------------------------------------------
171
331
  // 3.5. PEAK RESCUE PASS (The Fix)
172
332
  // Handle cases where a face is "submerged" by vertex values
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Resolve T-junctions: where a vertex lies on the interior of a boundary edge (rather than at one of
3
+ * its endpoints), split that edge at the vertex so the two pieces can later be welded/fused into a
4
+ * shared edge.
5
+ *
6
+ * This is required for meshes assembled from independently-authored faces of differing sizes - e.g. an
7
+ * L-shaped or ring floor built from rectangles, where one rectangle's corner falls in the middle of a
8
+ * neighbour's edge. Without it those faces touch only at a single vertex, so edge-based neighbour
9
+ * queries (island detection, erosion, path-finding) treat them as DISCONNECTED.
10
+ *
11
+ * Call AFTER an initial vertex-merge/edge-fuse, then merge + fuse again so the new split vertices weld
12
+ * onto the T-junction vertices and the resulting duplicate edges fuse.
13
+ *
14
+ * @param {BinaryTopology} mesh
15
+ * @param {number} [tolerance] max distance from a vertex to an edge for it to count as "on" the edge
16
+ * @returns {number} number of splits performed
17
+ */
18
+ export function bt_mesh_resolve_t_junctions(mesh: BinaryTopology, tolerance?: number): number;
19
+ //# sourceMappingURL=bt_mesh_resolve_t_junctions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bt_mesh_resolve_t_junctions.d.ts","sourceRoot":"","sources":["../../../../../../../../../src/core/geom/3d/topology/struct/binary/io/bt_mesh_resolve_t_junctions.js"],"names":[],"mappings":"AAQA;;;;;;;;;;;;;;;;GAgBG;AACH,8EAHW,MAAM,GACJ,MAAM,CAuElB"}
@@ -0,0 +1,95 @@
1
+ import { NULL_POINTER } from "../BinaryTopology.js";
2
+ import { bt_edge_split } from "./edge/bt_edge_split.js";
3
+
4
+ const _a = new Float32Array(3);
5
+ const _b = new Float32Array(3);
6
+ const _p = new Float32Array(3);
7
+ const _hit = new Float32Array(3);
8
+
9
+ /**
10
+ * Resolve T-junctions: where a vertex lies on the interior of a boundary edge (rather than at one of
11
+ * its endpoints), split that edge at the vertex so the two pieces can later be welded/fused into a
12
+ * shared edge.
13
+ *
14
+ * This is required for meshes assembled from independently-authored faces of differing sizes - e.g. an
15
+ * L-shaped or ring floor built from rectangles, where one rectangle's corner falls in the middle of a
16
+ * neighbour's edge. Without it those faces touch only at a single vertex, so edge-based neighbour
17
+ * queries (island detection, erosion, path-finding) treat them as DISCONNECTED.
18
+ *
19
+ * Call AFTER an initial vertex-merge/edge-fuse, then merge + fuse again so the new split vertices weld
20
+ * onto the T-junction vertices and the resulting duplicate edges fuse.
21
+ *
22
+ * @param {BinaryTopology} mesh
23
+ * @param {number} [tolerance] max distance from a vertex to an edge for it to count as "on" the edge
24
+ * @returns {number} number of splits performed
25
+ */
26
+ export function bt_mesh_resolve_t_junctions(mesh, tolerance = 1e-4) {
27
+ const tol_sq = tolerance * tolerance;
28
+
29
+ let total = 0;
30
+ let rounds = 0;
31
+ let changed = true;
32
+
33
+ while (changed && rounds < 256) {
34
+ changed = false;
35
+ rounds++;
36
+
37
+ const edge_count = mesh.edges.size;
38
+
39
+ for (let e = 0; e < edge_count; e++) {
40
+ if (!mesh.edges.is_allocated(e)) continue;
41
+
42
+ // only boundary edges (a single radial loop) can carry a T-junction
43
+ const l = mesh.edge_read_loop(e);
44
+ if (l === NULL_POINTER) continue;
45
+ if (mesh.loop_read_radial_next(l) !== l) continue;
46
+
47
+ const v1 = mesh.edge_read_vertex1(e);
48
+ const v2 = mesh.edge_read_vertex2(e);
49
+
50
+ mesh.vertex_read_coordinate(_a, 0, v1);
51
+ mesh.vertex_read_coordinate(_b, 0, v2);
52
+
53
+ const abx = _b[0] - _a[0], aby = _b[1] - _a[1], abz = _b[2] - _a[2];
54
+ const ab_sq = abx * abx + aby * aby + abz * abz;
55
+ if (ab_sq <= tol_sq) continue;
56
+
57
+ const t_margin = tolerance / Math.sqrt(ab_sq);
58
+
59
+ let split_t = -1;
60
+ let split_v = -1;
61
+
62
+ const vertex_count = mesh.vertices.size;
63
+ for (let v = 0; v < vertex_count; v++) {
64
+ if (v === v1 || v === v2) continue;
65
+ if (!mesh.vertices.is_allocated(v)) continue;
66
+
67
+ mesh.vertex_read_coordinate(_p, 0, v);
68
+
69
+ const t = ((_p[0] - _a[0]) * abx + (_p[1] - _a[1]) * aby + (_p[2] - _a[2]) * abz) / ab_sq;
70
+ if (t <= t_margin || t >= 1 - t_margin) continue;
71
+
72
+ const cx = _a[0] + t * abx - _p[0];
73
+ const cy = _a[1] + t * aby - _p[1];
74
+ const cz = _a[2] + t * abz - _p[2];
75
+ if (cx * cx + cy * cy + cz * cz > tol_sq) continue;
76
+
77
+ split_t = t;
78
+ split_v = v;
79
+ break;
80
+ }
81
+
82
+ if (split_t >= 0) {
83
+ // snapshot the on-edge vertex coordinate, split, then snap the new vertex exactly onto it
84
+ mesh.vertex_read_coordinate(_hit, 0, split_v);
85
+ const nv = bt_edge_split(mesh, e, split_t);
86
+ mesh.vertex_write_coordinate(nv, _hit, 0);
87
+
88
+ total++;
89
+ changed = true;
90
+ }
91
+ }
92
+ }
93
+
94
+ return total;
95
+ }
@@ -1,17 +1,18 @@
1
1
  /**
2
- * Carve holes into a (welded, connected) walkable topology wherever an agent of the given height
3
- * would not fit under overhead geometry.
2
+ * Carve holes into a (welded) walkable topology wherever an agent of the given height+radius would not
3
+ * fit under overhead geometry. INTENDED TO RUN AFTER the agent-radius boundary erosion: the boundary is
4
+ * already handled, so this only removes the obstacle footprints (dilated by the agent radius).
4
5
  *
5
- * Operates on the topology rather than a triangle soup so that all subdivision is conformal:
6
- * {@link bt_edge_split} re-triangulates every face around a split edge, so neighbouring faces can
7
- * never disagree on a shared edge (no cracks / T-junctions). Refinement is adaptive - only faces whose
8
- * column actually contains an overhang are subdivided, and only down to `resolution` - so clear areas
9
- * stay coarse and the subdivision hugs the obstacle contour instead of tessellating whole triangles.
6
+ * All subdivision is conformal ({@link bt_edge_split} re-triangulates every face around a split edge),
7
+ * and culling whole faces ({@link bt_face_kill}) never cracks neighbours - so this cannot disconnect a
8
+ * passable region. Refinement is tight: faces straddling the (dilated) obstacle outline are refined to
9
+ * `resolution`; a still-large all-clear face whose column has overhead is refined down to a coarse
10
+ * guard so a small obstacle cannot hide unsampled; everything else stays coarse.
10
11
  *
11
12
  * @param {object} params
12
13
  * @param {BinaryTopology} params.mesh walkable topology to carve (modified in place)
13
14
  * @param {BinaryTopology} params.source original source mesh (walkable + overhead geometry)
14
- * @param {BVH} params.source_bvh BVH over `source` (leaves carry source face IDs)
15
+ * @param {BVH} params.source_bvh BVH over `source`
15
16
  * @param {number} params.agent_height
16
17
  * @param {number} params.agent_radius
17
18
  * @param {Vector3} params.up world up direction
@@ -1 +1 @@
1
- {"version":3,"file":"bt_mesh_carve_height_clearance.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/navigation/mesh/build/bt_mesh_carve_height_clearance.js"],"names":[],"mappings":"AA2LA;;;;;;;;;;;;;;;;;GAiBG;AACH;IAPkC,IAAI;IACJ,MAAM;IACjB,UAAU;IACP,YAAY,EAA3B,MAAM;IACS,YAAY,EAA3B,MAAM;IACU,EAAE;SAuH5B"}
1
+ {"version":3,"file":"bt_mesh_carve_height_clearance.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/navigation/mesh/build/bt_mesh_carve_height_clearance.js"],"names":[],"mappings":"AA2NA;;;;;;;;;;;;;;;;;;GAkBG;AACH;IAPkC,IAAI;IACJ,MAAM;IACjB,UAAU;IACP,YAAY,EAA3B,MAAM;IACS,YAAY,EAA3B,MAAM;IACU,EAAE;SAyH5B"}
@@ -15,26 +15,10 @@ import {
15
15
  computeTriangleRayIntersectionBarycentric
16
16
  } from "../../../../core/geom/3d/triangle/computeTriangleRayIntersectionBarycentric.js";
17
17
 
18
- /**
19
- * Lift a ray origin off the surface so the upward clearance ray does not immediately re-hit the floor.
20
- * @type {number}
21
- */
18
+ /** Lift a ray origin off the surface so the upward clearance ray does not re-hit the floor. */
22
19
  const SURFACE_EPSILON = 1e-4;
23
20
 
24
- /**
25
- * Horizontal inset applied to a face footprint before testing for overhead geometry. Prevents a
26
- * neighbour that merely *touches* a face along a shared edge (e.g. an adjacent platform at a slightly
27
- * different height) from being mistaken for an overhang. A real overhang overlaps the interior with
28
- * positive area and still registers.
29
- * @type {number}
30
- */
31
- const FOOTPRINT_INSET = 1e-2;
32
-
33
- /**
34
- * Safety cap on the number of faces the refinement is allowed to grow to, so a pathological input
35
- * cannot loop forever.
36
- * @type {number}
37
- */
21
+ /** Safety cap on the face count during refinement. */
38
22
  const MAX_FACES = 200000;
39
23
 
40
24
  // reused scratch
@@ -50,11 +34,9 @@ const vc = new Float32Array(3);
50
34
 
51
35
  const overhead_hits = [];
52
36
  const query_aabb = new Float32Array(6);
37
+ const scratch_normal = new Float32Array(3);
53
38
 
54
- /**
55
- * True if there is no source geometry within `agent_height` directly above the point along `up`.
56
- * (Mirrors the clearance ray-cast used by the old soup-based pass.)
57
- */
39
+ /** True if nothing in `source` sits directly above the point within `agent_height` (exact ray test). */
58
40
  function point_has_clearance(source_bvh, source, px, py, pz, up_x, up_y, up_z, agent_height) {
59
41
  const origin_x = px + up_x * SURFACE_EPSILON;
60
42
  const origin_y = py + up_y * SURFACE_EPSILON;
@@ -73,9 +55,15 @@ function point_has_clearance(source_bvh, source, px, py, pz, up_x, up_y, up_z, a
73
55
  const face_id = source_bvh.node_get_user_data(node);
74
56
 
75
57
  const loop_a = source.face_read_loop(face_id);
76
- if (loop_a === NULL_POINTER) {
58
+ if (loop_a === NULL_POINTER) continue;
59
+
60
+ // Only DOWNWARD-facing geometry (a ceiling / overhang underside) can obstruct head clearance.
61
+ // Up-facing walkable floors above (a higher tier, a step the agent climbs) must NOT block.
62
+ source.face_read_normal(scratch_normal, 0, face_id);
63
+ if (scratch_normal[0] * up_x + scratch_normal[1] * up_y + scratch_normal[2] * up_z >= -1e-3) {
77
64
  continue;
78
65
  }
66
+
79
67
  const loop_b = source.loop_read_next(loop_a);
80
68
  const loop_c = source.loop_read_next(loop_b);
81
69
 
@@ -91,62 +79,92 @@ function point_has_clearance(source_bvh, source, px, py, pz, up_x, up_y, up_z, a
91
79
  tri_b[0], tri_b[1], tri_b[2],
92
80
  tri_c[0], tri_c[1], tri_c[2]
93
81
  );
94
- if (!hit) {
95
- continue;
96
- }
82
+ if (!hit) continue;
97
83
 
98
84
  const t = intersection_result[0];
99
- if (t > 0 && t <= agent_height) {
100
- // overhead obstruction within the agent's height
101
- return false;
102
- }
85
+ if (t > 0 && t <= agent_height) return false;
103
86
  }
104
87
 
105
88
  return true;
106
89
  }
107
90
 
108
91
  /**
109
- * Read the three vertices of a triangular face into va/vb/vc and return its loops.
92
+ * True if any source geometry sits above the point within `agent_height`, anywhere inside a horizontal
93
+ * disc of radius `r` (approximated by an axis-aligned box). Used to dilate the obstacle footprint by
94
+ * the agent radius - the agent's body cannot occupy a spot whose `r`-neighbourhood is overhung.
110
95
  */
111
- function read_face_triangle(mesh, face_id) {
112
- const la = mesh.face_read_loop(face_id);
113
- const lb = mesh.loop_read_next(la);
114
- const lc = mesh.loop_read_next(lb);
96
+ function overhead_within_radius(source_bvh, source, px, py, pz, up_x, up_y, up_z, agent_height, r) {
97
+ const p_along_up = px * up_x + py * up_y + pz * up_z;
115
98
 
116
- mesh.vertex_read_coordinate(va, 0, mesh.loop_read_vertex(la));
117
- mesh.vertex_read_coordinate(vb, 0, mesh.loop_read_vertex(lb));
118
- mesh.vertex_read_coordinate(vc, 0, mesh.loop_read_vertex(lc));
99
+ // expand on the axes perpendicular to up by r, then sweep up by agent_height
100
+ const ex = r * (1 - Math.abs(up_x));
101
+ const ey = r * (1 - Math.abs(up_y));
102
+ const ez = r * (1 - Math.abs(up_z));
119
103
 
120
- return { la, lb, lc };
104
+ let min_x = px - ex, min_y = py - ey, min_z = pz - ez;
105
+ let max_x = px + ex, max_y = py + ey, max_z = pz + ez;
106
+
107
+ const dx = up_x * agent_height, dy = up_y * agent_height, dz = up_z * agent_height;
108
+ if (dx > 0) max_x += dx; else min_x += dx;
109
+ if (dy > 0) max_y += dy; else min_y += dy;
110
+ if (dz > 0) max_z += dz; else min_z += dz;
111
+
112
+ query_aabb[0] = min_x; query_aabb[1] = min_y; query_aabb[2] = min_z;
113
+ query_aabb[3] = max_x; query_aabb[4] = max_y; query_aabb[5] = max_z;
114
+
115
+ const count = bvh_query_user_data_overlaps_aabb(overhead_hits, 0, source_bvh, query_aabb);
116
+
117
+ for (let i = 0; i < count; i++) {
118
+ const fid = overhead_hits[i];
119
+ const la = source.face_read_loop(fid);
120
+ if (la === NULL_POINTER) continue;
121
+
122
+ // only downward-facing geometry counts as an overhang (see point_has_clearance)
123
+ source.face_read_normal(scratch_normal, 0, fid);
124
+ if (scratch_normal[0] * up_x + scratch_normal[1] * up_y + scratch_normal[2] * up_z >= -1e-3) {
125
+ continue;
126
+ }
127
+
128
+ const lb = source.loop_read_next(la);
129
+ const lc = source.loop_read_next(lb);
130
+
131
+ source.vertex_read_coordinate(tri_a, 0, source.loop_read_vertex(la));
132
+ source.vertex_read_coordinate(tri_b, 0, source.loop_read_vertex(lb));
133
+ source.vertex_read_coordinate(tri_c, 0, source.loop_read_vertex(lc));
134
+
135
+ const cand_min_along_up = Math.min(
136
+ tri_a[0] * up_x + tri_a[1] * up_y + tri_a[2] * up_z,
137
+ tri_b[0] * up_x + tri_b[1] * up_y + tri_b[2] * up_z,
138
+ tri_c[0] * up_x + tri_c[1] * up_y + tri_c[2] * up_z
139
+ );
140
+
141
+ if (cand_min_along_up > p_along_up + 1e-3 && cand_min_along_up <= p_along_up + agent_height + 1e-3) {
142
+ return true;
143
+ }
144
+ }
145
+
146
+ return false;
121
147
  }
122
148
 
123
149
  /**
124
- * Does the upward column (height `agent_height` along `up`) over this face's footprint contain any
125
- * source geometry that sits *above* the face? Uses a loose axis-aligned box (conservative), inset
126
- * horizontally so edge-touching neighbours do not count. May report true for a face whose triangle
127
- * does not actually pass under the overhang, which only costs a little extra refinement, never a
128
- * wrong cull.
150
+ * Does the column over this face's footprint (inflated horizontally by `pad`) contain any DOWNWARD-
151
+ * facing geometry above it within `agent_height`? Footprint-wide (not just the centroid), so an
152
+ * obstacle sitting anywhere under a large face is detected and the face is refined toward it.
129
153
  */
130
- function column_has_overhead(source_bvh, source, face_max_along_up, up_x, up_y, up_z, agent_height) {
131
- let min_x = Math.min(va[0], vb[0], vc[0]);
132
- let min_y = Math.min(va[1], vb[1], vc[1]);
133
- let min_z = Math.min(va[2], vb[2], vc[2]);
134
- let max_x = Math.max(va[0], vb[0], vc[0]);
135
- let max_y = Math.max(va[1], vb[1], vc[1]);
136
- let max_z = Math.max(va[2], vb[2], vc[2]);
137
-
138
- // inset the footprint on the axes perpendicular to up (so touching-only neighbours are excluded)
139
- const ex = FOOTPRINT_INSET * (1 - Math.abs(up_x));
140
- const ey = FOOTPRINT_INSET * (1 - Math.abs(up_y));
141
- const ez = FOOTPRINT_INSET * (1 - Math.abs(up_z));
142
- min_x += ex; max_x -= ex;
143
- min_y += ey; max_y -= ey;
144
- min_z += ez; max_z -= ez;
145
- if (min_x > max_x) { const m = (min_x + max_x) * 0.5; min_x = m; max_x = m; }
146
- if (min_y > max_y) { const m = (min_y + max_y) * 0.5; min_y = m; max_y = m; }
147
- if (min_z > max_z) { const m = (min_z + max_z) * 0.5; min_z = m; max_z = m; }
148
-
149
- // sweep the box by agent_height along the up direction
154
+ function footprint_has_overhead(source_bvh, source, up_x, up_y, up_z, agent_height, pad) {
155
+ const face_max_along_up = Math.max(
156
+ va[0] * up_x + va[1] * up_y + va[2] * up_z,
157
+ vb[0] * up_x + vb[1] * up_y + vb[2] * up_z,
158
+ vc[0] * up_x + vc[1] * up_y + vc[2] * up_z
159
+ );
160
+
161
+ let min_x = Math.min(va[0], vb[0], vc[0]) - pad;
162
+ let min_y = Math.min(va[1], vb[1], vc[1]) - pad;
163
+ let min_z = Math.min(va[2], vb[2], vc[2]) - pad;
164
+ let max_x = Math.max(va[0], vb[0], vc[0]) + pad;
165
+ let max_y = Math.max(va[1], vb[1], vc[1]) + pad;
166
+ let max_z = Math.max(va[2], vb[2], vc[2]) + pad;
167
+
150
168
  const dx = up_x * agent_height, dy = up_y * agent_height, dz = up_z * agent_height;
151
169
  if (dx > 0) max_x += dx; else min_x += dx;
152
170
  if (dy > 0) max_y += dy; else min_y += dy;
@@ -159,24 +177,26 @@ function column_has_overhead(source_bvh, source, face_max_along_up, up_x, up_y,
159
177
 
160
178
  for (let i = 0; i < count; i++) {
161
179
  const fid = overhead_hits[i];
162
-
163
180
  const la = source.face_read_loop(fid);
164
181
  if (la === NULL_POINTER) continue;
182
+
183
+ source.face_read_normal(scratch_normal, 0, fid);
184
+ if (scratch_normal[0] * up_x + scratch_normal[1] * up_y + scratch_normal[2] * up_z >= -1e-3) {
185
+ continue;
186
+ }
187
+
165
188
  const lb = source.loop_read_next(la);
166
189
  const lc = source.loop_read_next(lb);
167
-
168
190
  source.vertex_read_coordinate(tri_a, 0, source.loop_read_vertex(la));
169
191
  source.vertex_read_coordinate(tri_b, 0, source.loop_read_vertex(lb));
170
192
  source.vertex_read_coordinate(tri_c, 0, source.loop_read_vertex(lc));
171
193
 
172
- // lowest extent of the candidate along up
173
194
  const cand_min_along_up = Math.min(
174
195
  tri_a[0] * up_x + tri_a[1] * up_y + tri_a[2] * up_z,
175
196
  tri_b[0] * up_x + tri_b[1] * up_y + tri_b[2] * up_z,
176
197
  tri_c[0] * up_x + tri_c[1] * up_y + tri_c[2] * up_z
177
198
  );
178
199
 
179
- // strictly above the face (the face's own/coplanar floor reads <= face_max_along_up)
180
200
  if (cand_min_along_up > face_max_along_up + 1e-3) {
181
201
  return true;
182
202
  }
@@ -185,20 +205,33 @@ function column_has_overhead(source_bvh, source, face_max_along_up, up_x, up_y,
185
205
  return false;
186
206
  }
187
207
 
208
+ function read_face_triangle(mesh, face_id) {
209
+ const la = mesh.face_read_loop(face_id);
210
+ const lb = mesh.loop_read_next(la);
211
+ const lc = mesh.loop_read_next(lb);
212
+
213
+ mesh.vertex_read_coordinate(va, 0, mesh.loop_read_vertex(la));
214
+ mesh.vertex_read_coordinate(vb, 0, mesh.loop_read_vertex(lb));
215
+ mesh.vertex_read_coordinate(vc, 0, mesh.loop_read_vertex(lc));
216
+
217
+ return { la, lb, lc };
218
+ }
219
+
188
220
  /**
189
- * Carve holes into a (welded, connected) walkable topology wherever an agent of the given height
190
- * would not fit under overhead geometry.
221
+ * Carve holes into a (welded) walkable topology wherever an agent of the given height+radius would not
222
+ * fit under overhead geometry. INTENDED TO RUN AFTER the agent-radius boundary erosion: the boundary is
223
+ * already handled, so this only removes the obstacle footprints (dilated by the agent radius).
191
224
  *
192
- * Operates on the topology rather than a triangle soup so that all subdivision is conformal:
193
- * {@link bt_edge_split} re-triangulates every face around a split edge, so neighbouring faces can
194
- * never disagree on a shared edge (no cracks / T-junctions). Refinement is adaptive - only faces whose
195
- * column actually contains an overhang are subdivided, and only down to `resolution` - so clear areas
196
- * stay coarse and the subdivision hugs the obstacle contour instead of tessellating whole triangles.
225
+ * All subdivision is conformal ({@link bt_edge_split} re-triangulates every face around a split edge),
226
+ * and culling whole faces ({@link bt_face_kill}) never cracks neighbours - so this cannot disconnect a
227
+ * passable region. Refinement is tight: faces straddling the (dilated) obstacle outline are refined to
228
+ * `resolution`; a still-large all-clear face whose column has overhead is refined down to a coarse
229
+ * guard so a small obstacle cannot hide unsampled; everything else stays coarse.
197
230
  *
198
231
  * @param {object} params
199
232
  * @param {BinaryTopology} params.mesh walkable topology to carve (modified in place)
200
233
  * @param {BinaryTopology} params.source original source mesh (walkable + overhead geometry)
201
- * @param {BVH} params.source_bvh BVH over `source` (leaves carry source face IDs)
234
+ * @param {BVH} params.source_bvh BVH over `source`
202
235
  * @param {number} params.agent_height
203
236
  * @param {number} params.agent_radius
204
237
  * @param {Vector3} params.up world up direction
@@ -212,28 +245,30 @@ export function bt_mesh_carve_height_clearance({
212
245
  up,
213
246
  }) {
214
247
 
215
- if (agent_height <= 0 || source_bvh.root === NULL_NODE) {
216
- // nothing overhead can obstruct anything
217
- return;
218
- }
248
+ if (agent_height <= 0 || source_bvh.root === NULL_NODE) return;
219
249
 
220
- // normalize up
221
250
  let up_x = up.x, up_y = up.y, up_z = up.z;
222
251
  const up_len = Math.sqrt(up_x * up_x + up_y * up_y + up_z * up_z);
223
- if (up_len === 0) {
224
- return;
225
- }
252
+ if (up_len === 0) return;
226
253
  up_x /= up_len; up_y /= up_len; up_z /= up_len;
227
254
 
228
- // Contour resolution: how finely the obstacle outline is resolved. Tied to the agent footprint;
229
- // floored so a tiny radius cannot trigger runaway refinement.
230
- const resolution = Math.max(agent_radius > 0 ? agent_radius : agent_height / 4, 0.25);
255
+ const r = Math.max(agent_radius, 0);
256
+ const resolution = Math.max(agent_radius > 0 ? agent_radius : agent_height / 4, 0.3);
231
257
  const res_sq = resolution * resolution;
232
258
 
233
- // ---- Phase 1: adaptive, conformal refinement around overhangs ----
234
- // Repeated fixed-point passes: split the longest edge of any face that (a) is still larger than
235
- // the contour resolution and (b) has overhead geometry in its column. Splitting is conformal, so
236
- // the mesh stays crack-free throughout.
259
+ // The agent fits at p iff nothing overhangs directly above it AND nothing overhangs within its body
260
+ // radius. (For r == 0 this is just the exact overhead ray.)
261
+ function agent_fits(px, py, pz) {
262
+ if (!point_has_clearance(source_bvh, source, px, py, pz, up_x, up_y, up_z, agent_height)) {
263
+ return false;
264
+ }
265
+ if (r > 0 && overhead_within_radius(source_bvh, source, px, py, pz, up_x, up_y, up_z, agent_height, r)) {
266
+ return false;
267
+ }
268
+ return true;
269
+ }
270
+
271
+ // ---- Phase 1: tight, conformal refinement around the (dilated) obstacle outline ----
237
272
  let changed = true;
238
273
  let guard = 0;
239
274
 
@@ -244,53 +279,58 @@ export function bt_mesh_carve_height_clearance({
244
279
  const face_count = mesh.faces.size;
245
280
 
246
281
  for (let f = 0; f < face_count; f++) {
247
- if (!mesh.faces.is_allocated(f)) {
248
- continue;
249
- }
282
+ if (!mesh.faces.is_allocated(f)) continue;
250
283
 
251
284
  read_face_triangle(mesh, f);
252
285
 
253
- // longest edge (by squared length); read_face_triangle puts va/vb/vc in loop order
254
- // la=(a,b), lb=(b,c), lc=(c,a)
255
286
  const ab = (va[0] - vb[0]) ** 2 + (va[1] - vb[1]) ** 2 + (va[2] - vb[2]) ** 2;
256
287
  const bc = (vb[0] - vc[0]) ** 2 + (vb[1] - vc[1]) ** 2 + (vb[2] - vc[2]) ** 2;
257
288
  const ca = (vc[0] - va[0]) ** 2 + (vc[1] - va[1]) ** 2 + (vc[2] - va[2]) ** 2;
258
-
259
289
  const longest_sq = Math.max(ab, bc, ca);
260
290
 
261
- if (longest_sq <= res_sq) {
262
- // small enough to resolve the contour
263
- continue;
291
+ if (longest_sq <= res_sq) continue;
292
+
293
+ const mab_x = (va[0] + vb[0]) * 0.5, mab_y = (va[1] + vb[1]) * 0.5, mab_z = (va[2] + vb[2]) * 0.5;
294
+ const mbc_x = (vb[0] + vc[0]) * 0.5, mbc_y = (vb[1] + vc[1]) * 0.5, mbc_z = (vb[2] + vc[2]) * 0.5;
295
+ const mca_x = (vc[0] + va[0]) * 0.5, mca_y = (vc[1] + va[1]) * 0.5, mca_z = (vc[2] + va[2]) * 0.5;
296
+ const cx = (va[0] + vb[0] + vc[0]) / 3, cy = (va[1] + vb[1] + vc[1]) / 3, cz = (va[2] + vb[2] + vc[2]) / 3;
297
+
298
+ const samples = [
299
+ va[0], va[1], va[2], vb[0], vb[1], vb[2], vc[0], vc[1], vc[2],
300
+ mab_x, mab_y, mab_z, mbc_x, mbc_y, mbc_z, mca_x, mca_y, mca_z,
301
+ cx, cy, cz,
302
+ ];
303
+ let n_clear = 0, n_block = 0;
304
+ for (let s = 0; s < samples.length; s += 3) {
305
+ if (agent_fits(samples[s], samples[s + 1], samples[s + 2])) n_clear++; else n_block++;
264
306
  }
265
307
 
266
- const face_max_along_up = Math.max(
267
- va[0] * up_x + va[1] * up_y + va[2] * up_z,
268
- vb[0] * up_x + vb[1] * up_y + vb[2] * up_z,
269
- vc[0] * up_x + vc[1] * up_y + vc[2] * up_z
270
- );
271
-
272
- if (!column_has_overhead(source_bvh, source, face_max_along_up, up_x, up_y, up_z, agent_height)) {
273
- // fully clear column - keep this face coarse
274
- continue;
308
+ let refine = false;
309
+ if (n_block > 0 && n_clear > 0) {
310
+ refine = true; // (dilated) outline crosses this face -> hug it at `resolution`
311
+ } else if (n_block === 0) {
312
+ // all clear by sampling, but a small obstacle can hide entirely between the samples of a
313
+ // coarse face. If any downward-facing overhang sits under the face footprint, keep
314
+ // refining down to the contour resolution so even sub-sample obstacles get caught.
315
+ if (footprint_has_overhead(source_bvh, source, up_x, up_y, up_z, agent_height, r)) {
316
+ refine = true;
317
+ }
275
318
  }
319
+ // n_clear === 0 (fully blocked): leave it for the cull phase
320
+
321
+ if (!refine) continue;
276
322
 
277
323
  const { la, lb, lc } = read_face_triangle(mesh, f);
278
324
  let split_loop;
279
- if (longest_sq === ab) {
280
- split_loop = la;
281
- } else if (longest_sq === bc) {
282
- split_loop = lb;
283
- } else {
284
- split_loop = lc;
285
- }
325
+ if (longest_sq === ab) split_loop = la;
326
+ else if (longest_sq === bc) split_loop = lb;
327
+ else split_loop = lc;
286
328
 
287
329
  bt_edge_split(mesh, mesh.loop_read_edge(split_loop), 0.5);
288
330
  changed = true;
289
331
  }
290
332
 
291
- if (mesh.faces.size > MAX_FACES) {
292
- break;
293
- }
333
+ if (mesh.faces.size > MAX_FACES) break;
294
334
  }
295
335
 
296
336
  // ---- Phase 2: cull blocked faces (whole-face, conformal) ----
@@ -298,19 +338,14 @@ export function bt_mesh_carve_height_clearance({
298
338
  const face_count = mesh.faces.size;
299
339
 
300
340
  for (let f = 0; f < face_count; f++) {
301
- if (!mesh.faces.is_allocated(f)) {
302
- continue;
303
- }
341
+ if (!mesh.faces.is_allocated(f)) continue;
304
342
 
305
343
  read_face_triangle(mesh, f);
306
-
307
344
  const cx = (va[0] + vb[0] + vc[0]) / 3;
308
345
  const cy = (va[1] + vb[1] + vc[1]) / 3;
309
346
  const cz = (va[2] + vb[2] + vc[2]) / 3;
310
347
 
311
- if (!point_has_clearance(source_bvh, source, cx, cy, cz, up_x, up_y, up_z, agent_height)) {
312
- faces_to_kill.push(f);
313
- }
348
+ if (!agent_fits(cx, cy, cz)) faces_to_kill.push(f);
314
349
  }
315
350
 
316
351
  for (let i = 0; i < faces_to_kill.length; i++) {
@@ -1 +1 @@
1
- {"version":3,"file":"navmesh_build_topology.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/navigation/mesh/build/navmesh_build_topology.js"],"names":[],"mappings":"AA2CA;;;;;;;;;;GAUG;AACH,wKATW,cAAc,QAiLxB;+BA5N8B,mEAAmE"}
1
+ {"version":3,"file":"navmesh_build_topology.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/navigation/mesh/build/navmesh_build_topology.js"],"names":[],"mappings":"AA8CA;;;;;;;;;;GAUG;AACH,wKATW,cAAc,QAyLxB;+BAvO8B,mEAAmE"}
@@ -39,6 +39,9 @@ import Vector3 from "../../../../core/geom/Vector3.js";
39
39
  import { bvh_build_from_bt_mesh } from "../bvh_build_from_bt_mesh.js";
40
40
  import { bvh_build_from_unindexed_triangles } from "./bvh_build_from_unindexed_triangles.js";
41
41
  import { bt_mesh_carve_height_clearance } from "./bt_mesh_carve_height_clearance.js";
42
+ import {
43
+ bt_mesh_resolve_t_junctions
44
+ } from "../../../../core/geom/3d/topology/struct/binary/io/bt_mesh_resolve_t_junctions.js";
42
45
 
43
46
 
44
47
  /**
@@ -164,41 +167,49 @@ export function navmesh_build_topology({
164
167
  // which is what the loop-based neighbour queries rely on
165
168
  bt_mesh_fuse_duplicate_edges(mesh);
166
169
 
167
- // Carve holes wherever an agent of the given height would not fit under overhead geometry.
168
- // Done on the welded topology (rather than the raw soup) so the subdivision is conformal -
169
- // bt_edge_split re-triangulates every face around a shared edge, so neighbours can never crack
170
- // apart - and adaptive, hugging each obstacle's contour instead of tessellating whole triangles.
170
+ // Resolve T-junctions: when faces of differing sizes abut (an L-shaped or ring floor built from
171
+ // rectangles, a small tile against a big one), one face's corner can land in the middle of
172
+ // another's edge. Those faces touch at a single vertex only, so edge-based neighbour queries
173
+ // treat them as disconnected. Split such edges at the stray vertex, then re-weld/fuse so the
174
+ // pieces share a real edge.
175
+ if (bt_mesh_resolve_t_junctions(mesh, 1e-4) > 0) {
176
+ bt_merge_verts_by_distance(mesh, 1e-6);
177
+ bt_mesh_fuse_duplicate_edges(mesh);
178
+ }
179
+
180
+ // --- Carve obstacle footprints first (no radius dilation here) ---
181
+ // Cull the floor directly under any overhang, opening a hole at each obstacle footprint. The
182
+ // agent-radius dilation is handled by the erosion below, which treats these holes as boundaries
183
+ // and insets them (and the outer edge) by exactly agent_radius - giving both a uniform inset and
184
+ // a smooth, consistent outline around obstacles from a single, robust pass.
171
185
  if (agent_height > 0) {
172
186
  bt_mesh_carve_height_clearance({
173
187
  mesh,
174
188
  source,
175
189
  source_bvh,
176
190
  agent_height,
177
- agent_radius,
191
+ agent_radius: 0,
178
192
  up,
179
193
  });
180
- }
181
-
182
- const islands = bt_mesh_compute_face_islands(mesh);
183
194
 
184
- // enabled us to modify islands independently
185
- bt_mesh_face_decouple_islands(mesh, islands);
186
-
187
- // shrink islands to agent_radius
188
- for (const island of islands) {
189
- bt_mesh_face_island_erode(mesh, island, agent_radius);
195
+ bt_mesh_compact(mesh);
190
196
  }
191
197
 
192
- // TODO attempt to reduce mesh complexity by re-triangulating flat areas or running a very constrained decimation
198
+ // --- Then erode every boundary (outer edge + obstacle holes) by the agent radius ---
199
+ // The erosion uses an exact Euclidean distance field with cut-band refinement, so it stays
200
+ // accurate and connected even on the hole-punched mesh the carve produces.
201
+ {
202
+ const islands = bt_mesh_compute_face_islands(mesh);
203
+
204
+ bt_mesh_face_decouple_islands(mesh, islands);
193
205
 
194
- // remove dangling references
195
- bt_mesh_cleanup_faceless_references(mesh);
206
+ for (const island of islands) {
207
+ bt_mesh_face_island_erode(mesh, island, agent_radius);
208
+ }
196
209
 
197
- // Island erosion (above) kills faces via the pool's free-list, leaving holes in the ID space.
198
- // `faces.size` is a high-water mark, not a live count, so consumers that iterate `0..faces.size`
199
- // (e.g. bvh_build_from_bt_mesh) would touch freed slots. Compact so every consumer sees a dense,
200
- // hole-free topology - this is the single invariant downstream code relies on.
201
- bt_mesh_compact(mesh);
210
+ bt_mesh_cleanup_faceless_references(mesh);
211
+ bt_mesh_compact(mesh);
212
+ }
202
213
 
203
214
  // bridge across small steps / gaps so stair-separated or slightly-broken tiers are reachable.
204
215
  // Opt-in: with both step params 0 (the default) the topology is left untouched.