@woosh/meep-engine 2.163.4 → 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.4",
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 +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":"AA2NA;;;;;;;;;;;;;;;;;;GAkBG;AACH;IAPkC,IAAI;IACJ,MAAM;IACjB,UAAU;IACP,YAAY,EAA3B,MAAM;IACS,YAAY,EAA3B,MAAM;IACU,EAAE;SA0H5B"}
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"}
@@ -254,9 +254,7 @@ export function bt_mesh_carve_height_clearance({
254
254
 
255
255
  const r = Math.max(agent_radius, 0);
256
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);
258
257
  const res_sq = resolution * resolution;
259
- const guard_sq = coarse_guard * coarse_guard;
260
258
 
261
259
  // The agent fits at p iff nothing overhangs directly above it AND nothing overhangs within its body
262
260
  // radius. (For r == 0 this is just the exact overhead ray.)
@@ -310,9 +308,10 @@ export function bt_mesh_carve_height_clearance({
310
308
  let refine = false;
311
309
  if (n_block > 0 && n_clear > 0) {
312
310
  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)
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.
316
315
  if (footprint_has_overhead(source_bvh, source, up_x, up_y, up_z, agent_height, r)) {
317
316
  refine = true;
318
317
  }
@@ -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,QAkLxB;+BA7N8B,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,40 +167,47 @@ 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
- // --- 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);
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);
184
178
  }
185
179
 
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.
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.
190
185
  if (agent_height > 0) {
191
186
  bt_mesh_carve_height_clearance({
192
187
  mesh,
193
188
  source,
194
189
  source_bvh,
195
190
  agent_height,
196
- agent_radius,
191
+ agent_radius: 0,
197
192
  up,
198
193
  });
199
194
 
200
- // carve kills faces via the free-list; compact back to a dense, hole-free topology
195
+ bt_mesh_compact(mesh);
196
+ }
197
+
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);
205
+
206
+ for (const island of islands) {
207
+ bt_mesh_face_island_erode(mesh, island, agent_radius);
208
+ }
209
+
210
+ bt_mesh_cleanup_faceless_references(mesh);
201
211
  bt_mesh_compact(mesh);
202
212
  }
203
213