@driftengine/nav 4.0.0

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/src/query.ts ADDED
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Search and funnel, on the search that already ships.
3
+ *
4
+ * **`NavSearch` in `@driftengine/core` is reused rather than reimplemented.** It is A* over a CSR
5
+ * graph, allocation-free, and tested; what it needs is a graph, and a polygon mesh is one — nodes
6
+ * are polygons, edges are the adjacency the mesh already records. So this package supplies a
7
+ * different graph to the same search rather than carrying a second one, and there is exactly one
8
+ * A* in this repository.
9
+ *
10
+ * **The funnel is what makes a path a straight line.** A polygon path is a corridor, and walking
11
+ * its centres is the defect that makes agents zigzag across open ground — visibly, and in a way
12
+ * that reads as bad AI rather than as a missing algorithm. The funnel pulls a string through the
13
+ * portals between consecutive polygons and the result is the shortest path inside the corridor,
14
+ * which across an empty room is one segment.
15
+ *
16
+ * **And the corridor is chosen over portals rather than over polygon centres**, which is what makes
17
+ * it the corridor holding the shortest path rather than one near it — `portalGraph.ts` carries that
18
+ * argument and the measurement that opened it. The search hands back the portals themselves, so
19
+ * nothing here reconstructs them from a polygon path any more.
20
+ *
21
+ * **Nothing here allocates.** A query runs inside a frame, possibly once per agent, and
22
+ * `navSearch.ts` already holds that line; a funnel that allocated would give it back.
23
+ */
24
+ import { NavSearch } from '@driftengine/core';
25
+
26
+ import { polyVertexCount, type PolyMesh } from './polymesh.ts';
27
+ import { attachGoal, attachStart, buildPortalGraph, type PortalGraph } from './portalGraph.ts';
28
+
29
+ /** Twice the signed area of the triangle, which is the sign of the turn and nothing more. */
30
+ function turn(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): number {
31
+ return (bx - ax) * (cz - az) - (cx - ax) * (bz - az);
32
+ }
33
+
34
+ /** A polygon's centre, which is what its graph node sits at. */
35
+ function centreOf(mesh: PolyMesh, poly: number, out: Float64Array): void {
36
+ const count = polyVertexCount(mesh, poly);
37
+ let x = 0;
38
+ let z = 0;
39
+ for (let at = 0; at < count; at += 1) {
40
+ const vertex = mesh.polys[poly * mesh.maxVertsPerPoly + at] as number;
41
+ x += mesh.vertices[vertex * 2] as number;
42
+ z += mesh.vertices[vertex * 2 + 1] as number;
43
+ }
44
+ out[0] = count === 0 ? 0 : x / count;
45
+ out[1] = count === 0 ? 0 : z / count;
46
+ }
47
+
48
+ /** Whether a point is inside a polygon, edges included. */
49
+ function insidePoly(mesh: PolyMesh, poly: number, x: number, z: number): boolean {
50
+ const count = polyVertexCount(mesh, poly);
51
+ if (count < 3) return false;
52
+ let negative = false;
53
+ let positive = false;
54
+ for (let at = 0; at < count; at += 1) {
55
+ const a = mesh.polys[poly * mesh.maxVertsPerPoly + at] as number;
56
+ const b = mesh.polys[poly * mesh.maxVertsPerPoly + ((at + 1) % count)] as number;
57
+ const side = turn(
58
+ mesh.vertices[a * 2] as number,
59
+ mesh.vertices[a * 2 + 1] as number,
60
+ mesh.vertices[b * 2] as number,
61
+ mesh.vertices[b * 2 + 1] as number,
62
+ x,
63
+ z,
64
+ );
65
+ if (side < 0) negative = true;
66
+ if (side > 0) positive = true;
67
+ }
68
+ return !(negative && positive);
69
+ }
70
+
71
+ /** Distance from a point to a polygon, zero inside it. */
72
+ function distanceToPoly(mesh: PolyMesh, poly: number, x: number, z: number): number {
73
+ if (insidePoly(mesh, poly, x, z)) return 0;
74
+ const count = polyVertexCount(mesh, poly);
75
+ let best = Infinity;
76
+ for (let at = 0; at < count; at += 1) {
77
+ const a = mesh.polys[poly * mesh.maxVertsPerPoly + at] as number;
78
+ const b = mesh.polys[poly * mesh.maxVertsPerPoly + ((at + 1) % count)] as number;
79
+ const ax = mesh.vertices[a * 2] as number;
80
+ const az = mesh.vertices[a * 2 + 1] as number;
81
+ const dx = (mesh.vertices[b * 2] as number) - ax;
82
+ const dz = (mesh.vertices[b * 2 + 1] as number) - az;
83
+ const lengthSq = dx * dx + dz * dz;
84
+ let t = lengthSq === 0 ? 0 : ((x - ax) * dx + (z - az) * dz) / lengthSq;
85
+ t = Math.max(0, Math.min(1, t));
86
+ const px = x - (ax + t * dx);
87
+ const pz = z - (az + t * dz);
88
+ best = Math.min(best, Math.sqrt(px * px + pz * pz));
89
+ }
90
+ return best;
91
+ }
92
+
93
+ /**
94
+ * The polygon a point is in, or the nearest within `extent`. `-1` beyond it. The point and the
95
+ * extent are in metres, which the mesh's placement turns into its cells.
96
+ *
97
+ * **`-1` rather than the nearest whatever the distance**, because an agent asked to walk to a
98
+ * place that is not on the mesh has to be told. Snapping across the map produces a path to
99
+ * somewhere nobody asked for, which is worse than no path at all.
100
+ */
101
+ export function nearestPoly(mesh: PolyMesh, x: number, z: number, extent: number): number {
102
+ const cellX = (x - mesh.originX) / mesh.cellSize;
103
+ const cellZ = (z - mesh.originZ) / mesh.cellSize;
104
+ const reach = extent / mesh.cellSize;
105
+ let best = -1;
106
+ let bestDistance = Infinity;
107
+ for (let poly = 0; poly < mesh.polyCount; poly += 1) {
108
+ const distance = distanceToPoly(mesh, poly, cellX, cellZ);
109
+ if (distance > reach || distance >= bestDistance) continue;
110
+ best = poly;
111
+ bestDistance = distance;
112
+ }
113
+ return best;
114
+ }
115
+
116
+ /**
117
+ * A query, holding everything a search needs so that running one allocates nothing.
118
+ *
119
+ * One per agent, or one shared by a caller that queries serially. The scratch is sized by the mesh
120
+ * because a polygon path cannot be longer than the mesh has polygons.
121
+ */
122
+ export class NavMeshQuery {
123
+ readonly mesh: PolyMesh;
124
+ readonly portals: PortalGraph;
125
+ private readonly search: NavSearch;
126
+ /** The nodes a search returns: the start, the portals it crosses, then the goal. */
127
+ private readonly nodePath: Uint32Array;
128
+ /** Portal ends, two points per portal: left x, left z, right x, right z. */
129
+ private readonly corridor: Float64Array;
130
+ private readonly centre = new Float64Array(2);
131
+
132
+ constructor(mesh: PolyMesh) {
133
+ this.mesh = mesh;
134
+ this.portals = buildPortalGraph(mesh);
135
+ this.search = new NavSearch(this.portals.graph);
136
+ this.nodePath = new Uint32Array(this.portals.portalCount + 2);
137
+ this.corridor = new Float64Array((this.portals.portalCount + 1) * 4);
138
+ }
139
+
140
+ /**
141
+ * The polygons from `from` to `to`, written into `out`. Zero where there is no route.
142
+ *
143
+ * Asked between the two polygons' centres, because a polygon is a region and a search over
144
+ * portals needs a point. What comes back is the corridor a path between those two points would
145
+ * be funnelled through, which is what a caller asking this question wants.
146
+ */
147
+ findPolyPath(from: number, to: number, out: Uint32Array): number {
148
+ if (from < 0 || to < 0 || from >= this.mesh.polyCount || to >= this.mesh.polyCount) return 0;
149
+ if (from === to) {
150
+ if (out.length < 1) return 0;
151
+ out[0] = from;
152
+ return 1;
153
+ }
154
+ centreOf(this.mesh, from, this.centre);
155
+ const fromX = this.mesh.originX + (this.centre[0] as number) * this.mesh.cellSize;
156
+ const fromZ = this.mesh.originZ + (this.centre[1] as number) * this.mesh.cellSize;
157
+ centreOf(this.mesh, to, this.centre);
158
+ const toX = this.mesh.originX + (this.centre[0] as number) * this.mesh.cellSize;
159
+ const toZ = this.mesh.originZ + (this.centre[1] as number) * this.mesh.cellSize;
160
+
161
+ const crossed = this.search_(from, fromX, fromZ, to, toX, toZ);
162
+ if (crossed < 0) return 0;
163
+ return this.walkPolygons(from, crossed, out);
164
+ }
165
+
166
+ /**
167
+ * A path from one point to another, as world positions in `out`, x and z per step.
168
+ *
169
+ * Returns how many points were written, or zero for no route — which is a real answer, not an
170
+ * error: a mesh with two components is a world with a gap in it, and an agent that asks for the
171
+ * other side has to be told rather than thrown at inside a frame.
172
+ */
173
+ findPath(
174
+ fromX: number,
175
+ fromZ: number,
176
+ toX: number,
177
+ toZ: number,
178
+ extent: number,
179
+ out: Float64Array,
180
+ ): number {
181
+ const startPoly = nearestPoly(this.mesh, fromX, fromZ, extent);
182
+ const endPoly = nearestPoly(this.mesh, toX, toZ, extent);
183
+ if (startPoly < 0 || endPoly < 0) return 0;
184
+ if (startPoly === endPoly) {
185
+ if (out.length < 4) return 0;
186
+ out[0] = fromX;
187
+ out[1] = fromZ;
188
+ out[2] = toX;
189
+ out[3] = toZ;
190
+ return 2;
191
+ }
192
+
193
+ const crossed = this.search_(startPoly, fromX, fromZ, endPoly, toX, toZ);
194
+ if (crossed < 0) return 0;
195
+ if (crossed === 0) {
196
+ /* Two polygons the search joined with no portal between them cannot happen; say so rather
197
+ than returning a straight line through whatever is in the way. */
198
+ return 0;
199
+ }
200
+ this.fillCorridor(startPoly, crossed);
201
+ return funnel(this.corridor, crossed, fromX, fromZ, toX, toZ, out);
202
+ }
203
+
204
+ /** The search itself: how many portals the path crosses, or −1 for no route. */
205
+ private search_(
206
+ startPoly: number,
207
+ fromX: number,
208
+ fromZ: number,
209
+ endPoly: number,
210
+ toX: number,
211
+ toZ: number,
212
+ ): number {
213
+ attachStart(this.portals, startPoly, fromX, fromZ);
214
+ attachGoal(this.portals, endPoly, toX, toZ);
215
+ const count = this.search.find(this.portals.startNode, this.portals.goalNode, this.nodePath);
216
+ /* The start and the goal are nodes too, so a route of N portals comes back as N + 2. */
217
+ return count === 0 ? -1 : count - 2;
218
+ }
219
+
220
+ /**
221
+ * The portals the path crosses, as the funnel wants them: a left end and a right end **for the
222
+ * direction of travel**, in metres.
223
+ *
224
+ * Swapping the two is a legal path and a visibly worse one — the funnel tightens the wrong way
225
+ * and bulges away from every corner instead of hugging it. Measured on three maps rather than
226
+ * reasoned about, because the convention depends on the winding, on which way `z` runs and on the
227
+ * order `buildPolyMesh` emits an edge in: 10.75 against 18.30 on a wall, 8.75 against 12.65 on a
228
+ * corner, 8.60 against 24.70 on a zigzag.
229
+ */
230
+ private fillCorridor(startPoly: number, crossed: number): void {
231
+ const { originX, originZ, cellSize, vertices } = this.mesh;
232
+ let current = startPoly;
233
+ for (let at = 0; at < crossed; at += 1) {
234
+ const portal = this.nodePath[at + 1] as number;
235
+ const near = this.portals.sides[portal * 2] as number;
236
+ const leaving = current === near ? 0 : 2;
237
+ const a = this.portals.ends[portal * 4 + leaving] as number;
238
+ const b = this.portals.ends[portal * 4 + leaving + 1] as number;
239
+ this.corridor[at * 4] = originX + (vertices[a * 2] as number) * cellSize;
240
+ this.corridor[at * 4 + 1] = originZ + (vertices[a * 2 + 1] as number) * cellSize;
241
+ this.corridor[at * 4 + 2] = originX + (vertices[b * 2] as number) * cellSize;
242
+ this.corridor[at * 4 + 3] = originZ + (vertices[b * 2 + 1] as number) * cellSize;
243
+ current = current === near ? (this.portals.sides[portal * 2 + 1] as number) : near;
244
+ }
245
+ }
246
+
247
+ /** The polygons a portal path passes through, which is the corridor named the other way. */
248
+ private walkPolygons(startPoly: number, crossed: number, out: Uint32Array): number {
249
+ if (out.length < crossed + 1) return 0;
250
+ let current = startPoly;
251
+ out[0] = current;
252
+ for (let at = 0; at < crossed; at += 1) {
253
+ const portal = this.nodePath[at + 1] as number;
254
+ const near = this.portals.sides[portal * 2] as number;
255
+ current = current === near ? (this.portals.sides[portal * 2 + 1] as number) : near;
256
+ out[at + 1] = current;
257
+ }
258
+ return crossed + 1;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Pull a string through the portals: the shortest path inside the corridor.
264
+ *
265
+ * The funnel narrows as each portal end tightens it and, when the two sides cross, the apex moves
266
+ * to whichever side caused the crossing and a corner is emitted. Across an empty room no portal
267
+ * ever crosses, so the whole path is two points — which is the property that stops agents
268
+ * zigzagging.
269
+ */
270
+ function funnel(
271
+ portals: Float64Array,
272
+ portalCount: number,
273
+ fromX: number,
274
+ fromZ: number,
275
+ toX: number,
276
+ toZ: number,
277
+ out: Float64Array,
278
+ ): number {
279
+ /*
280
+ * Written open rather than through a closure, and that is not style. A closure is an allocation,
281
+ * and this runs once per agent per re-path — two thousand queries with one in them cost half a
282
+ * megabyte, which the allocation test measured before this was changed.
283
+ */
284
+ let written = 0;
285
+ if (out.length < 2) return 0;
286
+ out[0] = fromX;
287
+ out[1] = fromZ;
288
+ written = 1;
289
+
290
+ let apexX = fromX;
291
+ let apexZ = fromZ;
292
+ let leftX = fromX;
293
+ let leftZ = fromZ;
294
+ let rightX = fromX;
295
+ let rightZ = fromZ;
296
+ let leftAt = 0;
297
+ let rightAt = 0;
298
+
299
+ for (let at = 0; at <= portalCount; at += 1) {
300
+ const px = at < portalCount ? (portals[at * 4] as number) : toX;
301
+ const pz = at < portalCount ? (portals[at * 4 + 1] as number) : toZ;
302
+ const qx = at < portalCount ? (portals[at * 4 + 2] as number) : toX;
303
+ const qz = at < portalCount ? (portals[at * 4 + 3] as number) : toZ;
304
+
305
+ if (turn(apexX, apexZ, rightX, rightZ, qx, qz) <= 0) {
306
+ if (apexX === rightX && apexZ === rightZ) {
307
+ rightX = qx;
308
+ rightZ = qz;
309
+ rightAt = at;
310
+ } else if (turn(apexX, apexZ, leftX, leftZ, qx, qz) > 0) {
311
+ rightX = qx;
312
+ rightZ = qz;
313
+ rightAt = at;
314
+ } else {
315
+ /* The right side crossed the left: the left vertex is a corner of the path. */
316
+ if (written * 2 + 1 >= out.length) return written;
317
+ if (out[(written - 1) * 2] !== leftX || out[(written - 1) * 2 + 1] !== leftZ) {
318
+ out[written * 2] = leftX;
319
+ out[written * 2 + 1] = leftZ;
320
+ written += 1;
321
+ }
322
+ apexX = leftX;
323
+ apexZ = leftZ;
324
+ rightX = apexX;
325
+ rightZ = apexZ;
326
+ rightAt = leftAt;
327
+ at = leftAt;
328
+ continue;
329
+ }
330
+ }
331
+
332
+ if (turn(apexX, apexZ, leftX, leftZ, px, pz) >= 0) {
333
+ if (apexX === leftX && apexZ === leftZ) {
334
+ leftX = px;
335
+ leftZ = pz;
336
+ leftAt = at;
337
+ } else if (turn(apexX, apexZ, rightX, rightZ, px, pz) < 0) {
338
+ leftX = px;
339
+ leftZ = pz;
340
+ leftAt = at;
341
+ } else {
342
+ if (written * 2 + 1 >= out.length) return written;
343
+ if (out[(written - 1) * 2] !== rightX || out[(written - 1) * 2 + 1] !== rightZ) {
344
+ out[written * 2] = rightX;
345
+ out[written * 2 + 1] = rightZ;
346
+ written += 1;
347
+ }
348
+ apexX = rightX;
349
+ apexZ = rightZ;
350
+ leftX = apexX;
351
+ leftZ = apexZ;
352
+ leftAt = rightAt;
353
+ at = rightAt;
354
+ continue;
355
+ }
356
+ }
357
+ }
358
+
359
+ /* A corner emitted twice is a step of zero length, which a follower would stall on. */
360
+ if (
361
+ written * 2 + 1 < out.length &&
362
+ (out[(written - 1) * 2] !== toX || out[(written - 1) * 2 + 1] !== toZ)
363
+ ) {
364
+ out[written * 2] = toX;
365
+ out[written * 2 + 1] = toZ;
366
+ written += 1;
367
+ }
368
+ return written;
369
+ }
package/src/regions.ts ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Walkable space partitioned into regions, by watershed on a distance field.
3
+ *
4
+ * **A flood fill would say a world is one region, and that is the wrong answer.** Two rooms joined
5
+ * by a corridor one cell wide are connected, so a flood fill gives one region — and the contour of
6
+ * that region wraps through the corridor and back, which simplifies into a shape that crosses
7
+ * itself, which produces a polygon the funnel walks out of. The split has to happen at the
8
+ * corridor, and what marks the corridor is that it is *close to a wall on both sides*.
9
+ *
10
+ * So: distance to the nearest edge, then flood downhill from the local maxima. A room is a basin;
11
+ * a corridor is the watershed between two of them.
12
+ *
13
+ * **A region smaller than the threshold is merged and never left as a hole.** A hole in a
14
+ * navigation mesh is a place an agent refuses to stand for no reason anybody can see, and the
15
+ * smallest ones come from a corner of the voxel field surviving erosion by a cell.
16
+ */
17
+ import type { VoxelField } from './voxelise.ts';
18
+
19
+ const SPAN_STRIDE = 3;
20
+ const NONE = -1;
21
+
22
+ export interface RegionSettings {
23
+ /** A region with fewer spans than this is merged into whichever neighbour it touches most. */
24
+ readonly minRegionSpans: number;
25
+ /** How many cells of height an agent can step across. Beyond it, a floor is a wall. */
26
+ readonly maxStep: number;
27
+ }
28
+
29
+ export interface RegionField {
30
+ /** Which region each span belongs to, by span index. `-1` for an unwalkable span. */
31
+ readonly regionOf: Int32Array;
32
+ readonly count: number;
33
+ /** Flat pairs of region identifiers that touch, ascending and without duplicates. */
34
+ readonly links: Int32Array;
35
+ readonly linkCount: number;
36
+ }
37
+
38
+ /** The index of the `at`-th span in a column, or `-1`. Spans are numbered across the whole field. */
39
+ export function spanIndexAt(field: VoxelField, x: number, z: number, at = 0): number {
40
+ if (x < 0 || z < 0 || x >= field.width || z >= field.depth) return NONE;
41
+ const column = x + z * field.width;
42
+ const from = (field.columnStart[column] as number) / SPAN_STRIDE;
43
+ const to = (field.columnStart[column + 1] as number) / SPAN_STRIDE;
44
+ return from + at < to ? from + at : NONE;
45
+ }
46
+
47
+ export function regionOfSpan(regions: RegionField, span: number): number {
48
+ return span < 0 ? NONE : (regions.regionOf[span] ?? NONE);
49
+ }
50
+
51
+ export function regionsLinked(regions: RegionField, a: number, b: number): boolean {
52
+ const lo = Math.min(a, b);
53
+ const hi = Math.max(a, b);
54
+ for (let at = 0; at < regions.linkCount; at += 1) {
55
+ if (regions.links[at * 2] === lo && regions.links[at * 2 + 1] === hi) return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ const NEIGHBOURS: readonly (readonly [number, number])[] = [
61
+ [-1, 0],
62
+ [1, 0],
63
+ [0, -1],
64
+ [0, 1],
65
+ ];
66
+
67
+ /** Every span an agent could step to from this one, appended to `out`. */
68
+ function neighboursOf(
69
+ field: VoxelField,
70
+ span: number,
71
+ columnOf: Int32Array,
72
+ maxStep: number,
73
+ out: number[],
74
+ ): void {
75
+ out.length = 0;
76
+ const column = columnOf[span] as number;
77
+ const x = column % field.width;
78
+ const z = (column - x) / field.width;
79
+ const floor = field.spans[span * SPAN_STRIDE] as number;
80
+
81
+ for (const [dx, dz] of NEIGHBOURS) {
82
+ const nx = x + dx;
83
+ const nz = z + dz;
84
+ if (nx < 0 || nz < 0 || nx >= field.width || nz >= field.depth) continue;
85
+ const other = nx + nz * field.width;
86
+ const from = (field.columnStart[other] as number) / SPAN_STRIDE;
87
+ const to = (field.columnStart[other + 1] as number) / SPAN_STRIDE;
88
+ for (let at = from; at < to; at += 1) {
89
+ if (field.spans[at * SPAN_STRIDE + 2] !== 1) continue;
90
+ if (Math.abs((field.spans[at * SPAN_STRIDE] as number) - floor) > maxStep) continue;
91
+ out.push(at);
92
+ }
93
+ }
94
+ }
95
+
96
+ /** How many sides of this span lead nowhere an agent could go. */
97
+ function borders(
98
+ field: VoxelField,
99
+ span: number,
100
+ columnOf: Int32Array,
101
+ maxStep: number,
102
+ scratch: number[],
103
+ ): boolean {
104
+ neighboursOf(field, span, columnOf, maxStep, scratch);
105
+ return scratch.length < NEIGHBOURS.length;
106
+ }
107
+
108
+ export function buildRegions(field: VoxelField, settings: RegionSettings): RegionField {
109
+ const spanTotal = field.spans.length / SPAN_STRIDE;
110
+ const regionOf = new Int32Array(spanTotal).fill(NONE);
111
+ if (spanTotal === 0) {
112
+ return { regionOf, count: 0, links: new Int32Array(0), linkCount: 0 };
113
+ }
114
+
115
+ /* Which column each span belongs to, so a span index alone is enough to find its neighbours. */
116
+ const columnOf = new Int32Array(spanTotal);
117
+ for (let column = 0; column < field.width * field.depth; column += 1) {
118
+ const from = (field.columnStart[column] as number) / SPAN_STRIDE;
119
+ const to = (field.columnStart[column + 1] as number) / SPAN_STRIDE;
120
+ for (let at = from; at < to; at += 1) columnOf[at] = column;
121
+ }
122
+
123
+ const walkable: number[] = [];
124
+ for (let span = 0; span < spanTotal; span += 1) {
125
+ if (field.spans[span * SPAN_STRIDE + 2] === 1) walkable.push(span);
126
+ }
127
+ if (walkable.length === 0) {
128
+ return { regionOf, count: 0, links: new Int32Array(0), linkCount: 0 };
129
+ }
130
+
131
+ const distance = distanceField(field, walkable, columnOf, settings.maxStep, spanTotal);
132
+
133
+ /*
134
+ * Flood downhill, level by level. At each distance, spans already touching a region join it and
135
+ * spans touching none start their own — which is what makes a local maximum a seed without
136
+ * anybody having to find the maxima first.
137
+ */
138
+ let count = 0;
139
+ const scratch: number[] = [];
140
+ const levels = [...new Set(walkable.map((span) => distance[span] as number))].sort(
141
+ (a, b) => b - a,
142
+ );
143
+ for (const level of levels) {
144
+ const here = walkable.filter((span) => distance[span] === level);
145
+ /* Repeat until nothing more joins, so a whole plateau flows from one seed rather than many. */
146
+ for (let changed = true; changed;) {
147
+ changed = false;
148
+ for (const span of here) {
149
+ if (regionOf[span] !== NONE) continue;
150
+ neighboursOf(field, span, columnOf, settings.maxStep, scratch);
151
+ let found = NONE;
152
+ for (const other of scratch) {
153
+ const region = regionOf[other] as number;
154
+ if (region !== NONE && (found === NONE || region < found)) found = region;
155
+ }
156
+ if (found === NONE) continue;
157
+ regionOf[span] = found;
158
+ changed = true;
159
+ }
160
+ }
161
+ for (const span of here) {
162
+ if (regionOf[span] !== NONE) continue;
163
+ regionOf[span] = count;
164
+ count += 1;
165
+ /* Spread the new region across its own plateau before the next one starts. */
166
+ for (let changed = true; changed;) {
167
+ changed = false;
168
+ for (const other of here) {
169
+ if (regionOf[other] !== NONE) continue;
170
+ neighboursOf(field, other, columnOf, settings.maxStep, scratch);
171
+ if (!scratch.some((n) => regionOf[n] === count - 1)) continue;
172
+ regionOf[other] = count - 1;
173
+ changed = true;
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ count = mergeSmall(field, regionOf, columnOf, walkable, count, settings, scratch);
180
+ return linksOf(field, regionOf, columnOf, walkable, count, settings, scratch);
181
+ }
182
+
183
+ /**
184
+ * How far each walkable span is from the nearest edge, in steps.
185
+ *
186
+ * A multi-source breadth-first search from every span that has a side leading nowhere. One is what
187
+ * makes a corridor shallow and a room deep, which is the whole basis of the split.
188
+ */
189
+ function distanceField(
190
+ field: VoxelField,
191
+ walkable: readonly number[],
192
+ columnOf: Int32Array,
193
+ maxStep: number,
194
+ spanTotal: number,
195
+ ): Int32Array {
196
+ const distance = new Int32Array(spanTotal).fill(-1);
197
+ const scratch: number[] = [];
198
+ const queue: number[] = [];
199
+ for (const span of walkable) {
200
+ if (!borders(field, span, columnOf, maxStep, scratch)) continue;
201
+ distance[span] = 1;
202
+ queue.push(span);
203
+ }
204
+ for (let head = 0; head < queue.length; head += 1) {
205
+ const span = queue[head] as number;
206
+ neighboursOf(field, span, columnOf, maxStep, scratch);
207
+ for (const other of scratch) {
208
+ if (distance[other] !== -1) continue;
209
+ distance[other] = (distance[span] as number) + 1;
210
+ queue.push(other);
211
+ }
212
+ }
213
+ /* A span with no border anywhere in its component — a closed torus — is as deep as it gets. */
214
+ for (const span of walkable) {
215
+ if (distance[span] === -1) distance[span] = 1;
216
+ }
217
+ return distance;
218
+ }
219
+
220
+ /** Merge every region below the threshold into whichever neighbour it shares the most edge with. */
221
+ function mergeSmall(
222
+ field: VoxelField,
223
+ regionOf: Int32Array,
224
+ columnOf: Int32Array,
225
+ walkable: readonly number[],
226
+ count: number,
227
+ settings: RegionSettings,
228
+ scratch: number[],
229
+ ): number {
230
+ const sizes = new Int32Array(count);
231
+ for (const span of walkable) sizes[regionOf[span] as number] += 1;
232
+
233
+ for (let region = 0; region < count; region += 1) {
234
+ if ((sizes[region] as number) === 0 || (sizes[region] as number) >= settings.minRegionSpans) {
235
+ continue;
236
+ }
237
+ const shared = new Map<number, number>();
238
+ for (const span of walkable) {
239
+ if (regionOf[span] !== region) continue;
240
+ neighboursOf(field, span, columnOf, settings.maxStep, scratch);
241
+ for (const other of scratch) {
242
+ const theirs = regionOf[other] as number;
243
+ if (theirs === region || theirs === NONE) continue;
244
+ shared.set(theirs, (shared.get(theirs) ?? 0) + 1);
245
+ }
246
+ }
247
+ /* Most shared edge wins; the lower identifier breaks a tie, so this is reproducible. */
248
+ let best = NONE;
249
+ let bestShare = 0;
250
+ for (const [other, share] of [...shared].sort(([a], [b]) => a - b)) {
251
+ if (share <= bestShare) continue;
252
+ best = other;
253
+ bestShare = share;
254
+ }
255
+ if (best === NONE) continue;
256
+ for (const span of walkable) {
257
+ if (regionOf[span] === region) regionOf[span] = best;
258
+ }
259
+ sizes[best] = (sizes[best] as number) + (sizes[region] as number);
260
+ sizes[region] = 0;
261
+ }
262
+
263
+ /* Close the gaps the merges left, so identifiers stay `0..count - 1`. */
264
+ const remap = new Int32Array(count).fill(NONE);
265
+ let next = 0;
266
+ for (const span of walkable) {
267
+ const region = regionOf[span] as number;
268
+ if (remap[region] === NONE) {
269
+ remap[region] = next;
270
+ next += 1;
271
+ }
272
+ regionOf[span] = remap[region] as number;
273
+ }
274
+ return next;
275
+ }
276
+
277
+ function linksOf(
278
+ field: VoxelField,
279
+ regionOf: Int32Array,
280
+ columnOf: Int32Array,
281
+ walkable: readonly number[],
282
+ count: number,
283
+ settings: RegionSettings,
284
+ scratch: number[],
285
+ ): RegionField {
286
+ const seen = new Set<number>();
287
+ const pairs: number[] = [];
288
+ for (const span of walkable) {
289
+ const mine = regionOf[span] as number;
290
+ neighboursOf(field, span, columnOf, settings.maxStep, scratch);
291
+ for (const other of scratch) {
292
+ const theirs = regionOf[other] as number;
293
+ if (theirs === mine || theirs === NONE) continue;
294
+ const lo = Math.min(mine, theirs);
295
+ const hi = Math.max(mine, theirs);
296
+ const key = lo * count + hi;
297
+ if (seen.has(key)) continue;
298
+ seen.add(key);
299
+ pairs.push(lo, hi);
300
+ }
301
+ }
302
+ return {
303
+ regionOf,
304
+ count,
305
+ links: Int32Array.from(pairs),
306
+ linkCount: pairs.length / 2,
307
+ };
308
+ }