@woosh/meep-engine 2.163.3 → 2.163.4

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.4",
10
10
  "main": "build/meep.module.js",
11
11
  "module": "build/meep.module.js",
12
12
  "exports": {
@@ -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;SA0H5B"}
@@ -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,32 @@ 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);
257
+ const coarse_guard = Math.max(2 * agent_radius, agent_height / 2, 0.8);
231
258
  const res_sq = resolution * resolution;
259
+ const guard_sq = coarse_guard * coarse_guard;
260
+
261
+ // The agent fits at p iff nothing overhangs directly above it AND nothing overhangs within its body
262
+ // radius. (For r == 0 this is just the exact overhead ray.)
263
+ function agent_fits(px, py, pz) {
264
+ if (!point_has_clearance(source_bvh, source, px, py, pz, up_x, up_y, up_z, agent_height)) {
265
+ return false;
266
+ }
267
+ if (r > 0 && overhead_within_radius(source_bvh, source, px, py, pz, up_x, up_y, up_z, agent_height, r)) {
268
+ return false;
269
+ }
270
+ return true;
271
+ }
232
272
 
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.
273
+ // ---- Phase 1: tight, conformal refinement around the (dilated) obstacle outline ----
237
274
  let changed = true;
238
275
  let guard = 0;
239
276
 
@@ -244,53 +281,57 @@ export function bt_mesh_carve_height_clearance({
244
281
  const face_count = mesh.faces.size;
245
282
 
246
283
  for (let f = 0; f < face_count; f++) {
247
- if (!mesh.faces.is_allocated(f)) {
248
- continue;
249
- }
284
+ if (!mesh.faces.is_allocated(f)) continue;
250
285
 
251
286
  read_face_triangle(mesh, f);
252
287
 
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
288
  const ab = (va[0] - vb[0]) ** 2 + (va[1] - vb[1]) ** 2 + (va[2] - vb[2]) ** 2;
256
289
  const bc = (vb[0] - vc[0]) ** 2 + (vb[1] - vc[1]) ** 2 + (vb[2] - vc[2]) ** 2;
257
290
  const ca = (vc[0] - va[0]) ** 2 + (vc[1] - va[1]) ** 2 + (vc[2] - va[2]) ** 2;
258
-
259
291
  const longest_sq = Math.max(ab, bc, ca);
260
292
 
261
- if (longest_sq <= res_sq) {
262
- // small enough to resolve the contour
263
- continue;
293
+ if (longest_sq <= res_sq) continue;
294
+
295
+ 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;
296
+ 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;
297
+ 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;
298
+ 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;
299
+
300
+ const samples = [
301
+ va[0], va[1], va[2], vb[0], vb[1], vb[2], vc[0], vc[1], vc[2],
302
+ mab_x, mab_y, mab_z, mbc_x, mbc_y, mbc_z, mca_x, mca_y, mca_z,
303
+ cx, cy, cz,
304
+ ];
305
+ let n_clear = 0, n_block = 0;
306
+ for (let s = 0; s < samples.length; s += 3) {
307
+ if (agent_fits(samples[s], samples[s + 1], samples[s + 2])) n_clear++; else n_block++;
264
308
  }
265
309
 
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;
310
+ let refine = false;
311
+ if (n_block > 0 && n_clear > 0) {
312
+ refine = true; // (dilated) outline crosses this face -> hug it at `resolution`
313
+ } else if (n_block === 0 && longest_sq > guard_sq) {
314
+ // all clear by sampling, but still large enough to hide an obstacle between samples:
315
+ // refine if any downward-facing overhang sits under the whole face footprint (+ radius)
316
+ if (footprint_has_overhead(source_bvh, source, up_x, up_y, up_z, agent_height, r)) {
317
+ refine = true;
318
+ }
275
319
  }
320
+ // n_clear === 0 (fully blocked): leave it for the cull phase
321
+
322
+ if (!refine) continue;
276
323
 
277
324
  const { la, lb, lc } = read_face_triangle(mesh, f);
278
325
  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
- }
326
+ if (longest_sq === ab) split_loop = la;
327
+ else if (longest_sq === bc) split_loop = lb;
328
+ else split_loop = lc;
286
329
 
287
330
  bt_edge_split(mesh, mesh.loop_read_edge(split_loop), 0.5);
288
331
  changed = true;
289
332
  }
290
333
 
291
- if (mesh.faces.size > MAX_FACES) {
292
- break;
293
- }
334
+ if (mesh.faces.size > MAX_FACES) break;
294
335
  }
295
336
 
296
337
  // ---- Phase 2: cull blocked faces (whole-face, conformal) ----
@@ -298,19 +339,14 @@ export function bt_mesh_carve_height_clearance({
298
339
  const face_count = mesh.faces.size;
299
340
 
300
341
  for (let f = 0; f < face_count; f++) {
301
- if (!mesh.faces.is_allocated(f)) {
302
- continue;
303
- }
342
+ if (!mesh.faces.is_allocated(f)) continue;
304
343
 
305
344
  read_face_triangle(mesh, f);
306
-
307
345
  const cx = (va[0] + vb[0] + vc[0]) / 3;
308
346
  const cy = (va[1] + vb[1] + vc[1]) / 3;
309
347
  const cz = (va[2] + vb[2] + vc[2]) / 3;
310
348
 
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
- }
349
+ if (!agent_fits(cx, cy, cz)) faces_to_kill.push(f);
314
350
  }
315
351
 
316
352
  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":"AA2CA;;;;;;;;;;GAUG;AACH,wKATW,cAAc,QAkLxB;+BA7N8B,mEAAmE"}
@@ -164,10 +164,29 @@ export function navmesh_build_topology({
164
164
  // which is what the loop-based neighbour queries rely on
165
165
  bt_mesh_fuse_duplicate_edges(mesh);
166
166
 
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.
167
+ // --- Agent-radius erosion FIRST, on the clean welded surface ---
168
+ // Erosion is reliable on a clean triangulation; running it BEFORE the obstacle carve avoids
169
+ // feeding it the irregular, hole-punched mesh the carve would otherwise produce (which made the
170
+ // vertex-sampled distance field mis-estimate the inset, both over-culling and fragmenting).
171
+ {
172
+ const islands = bt_mesh_compute_face_islands(mesh);
173
+
174
+ // decouple so islands can be eroded independently
175
+ bt_mesh_face_decouple_islands(mesh, islands);
176
+
177
+ for (const island of islands) {
178
+ bt_mesh_face_island_erode(mesh, island, agent_radius);
179
+ }
180
+
181
+ // remove dangling references, then compact (erosion frees faces, leaving holes in the ID space)
182
+ bt_mesh_cleanup_faceless_references(mesh);
183
+ bt_mesh_compact(mesh);
184
+ }
185
+
186
+ // --- Then carve the obstacle footprints (dilated by the agent radius) ---
187
+ // Runs on the already-eroded surface. Conformal edge-splits + whole-face culls only: this can
188
+ // never crack a passable region apart, it can only open holes where the agent genuinely cannot
189
+ // fit under an overhang.
171
190
  if (agent_height > 0) {
172
191
  bt_mesh_carve_height_clearance({
173
192
  mesh,
@@ -177,29 +196,11 @@ export function navmesh_build_topology({
177
196
  agent_radius,
178
197
  up,
179
198
  });
180
- }
181
-
182
- const islands = bt_mesh_compute_face_islands(mesh);
183
199
 
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);
200
+ // carve kills faces via the free-list; compact back to a dense, hole-free topology
201
+ bt_mesh_compact(mesh);
190
202
  }
191
203
 
192
- // TODO attempt to reduce mesh complexity by re-triangulating flat areas or running a very constrained decimation
193
-
194
- // remove dangling references
195
- bt_mesh_cleanup_faceless_references(mesh);
196
-
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);
202
-
203
204
  // bridge across small steps / gaps so stair-separated or slightly-broken tiers are reachable.
204
205
  // Opt-in: with both step params 0 (the default) the topology is left untouched.
205
206
  if (agent_max_step_height > 0 || agent_max_step_distance > 0) {