@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.
@@ -0,0 +1,516 @@
1
+ const SPAN_STRIDE = 3;
2
+ /** The four sides of a cell, as the directed edge each contributes when its neighbour is absent. */
3
+ const SIDES = [
4
+ /* dx, dz, from x, from z, to x, to z — all in cell-corner units, relative to the cell. */
5
+ [-1, 0, 0, 1, 0, 0],
6
+ [1, 0, 1, 0, 1, 1],
7
+ [0, -1, 0, 0, 1, 0],
8
+ [0, 1, 1, 1, 0, 1],
9
+ ];
10
+ /** Which region a column's first walkable span belongs to, or `-1`. */
11
+ function regionAtColumn(field, regions, x, z) {
12
+ if (x < 0 || z < 0 || x >= field.width || z >= field.depth)
13
+ return -1;
14
+ const column = x + z * field.width;
15
+ const from = field.columnStart[column] / SPAN_STRIDE;
16
+ const to = field.columnStart[column + 1] / SPAN_STRIDE;
17
+ for (let at = from; at < to; at += 1) {
18
+ if (field.spans[at * SPAN_STRIDE + 2] === 1)
19
+ return regions.regionOf[at];
20
+ }
21
+ return -1;
22
+ }
23
+ /**
24
+ * One closed loop per region, holes bridged into it, simplified.
25
+ *
26
+ * **This took the longest loop and dropped the rest until 2026-09-16, and the limitation was worse
27
+ * than it was written down as.** It was stated as losing a pillar — an agent walking through a
28
+ * column in the middle of a room. What it actually did was *claim ground*: a ring's outer loop is
29
+ * the room's whole perimeter, so simplifying it gives a rectangle covering the pillar and whatever
30
+ * else the watershed carved out of the middle. Measured on
31
+ * `['..........', '....##....', '....##....', '..........']`, the ring's polygon came out as the
32
+ * entire ten-by-four map, area 40 where the region is 32 — a navigation mesh asserting that a wall
33
+ * and a neighbouring region are both walkable by the same agent.
34
+ *
35
+ * **Holes are bridged rather than emitted separately.** A hole as its own contour would become its
36
+ * own walkable polygon, which is the same defect pointing the other way. A bridge joins the inner
37
+ * loop to the outer along a segment traversed once each way, so the pair becomes **one closed loop
38
+ * with a zero-width slit** — weakly simple rather than simple, which is what ear clipping wants and
39
+ * what `segmentsCross` tolerates, since it requires a *strict* crossing and the two bridge edges
40
+ * are coincident.
41
+ */
42
+ export function buildContours(field, regions, maxDeviation) {
43
+ const out = [];
44
+ for (let region = 0; region < regions.count; region += 1) {
45
+ const edges = [];
46
+ for (let z = 0; z < field.depth; z += 1) {
47
+ for (let x = 0; x < field.width; x += 1) {
48
+ if (regionAtColumn(field, regions, x, z) !== region)
49
+ continue;
50
+ for (const [dx, dz, fx, fz, tx, tz] of SIDES) {
51
+ if (regionAtColumn(field, regions, x + dx, z + dz) === region)
52
+ continue;
53
+ edges.push({ fx: x + fx, fz: z + fz, tx: x + tx, tz: z + tz, used: false });
54
+ }
55
+ }
56
+ }
57
+ if (edges.length === 0)
58
+ continue;
59
+ const loops = chainLoops(edges);
60
+ if (loops.length === 0)
61
+ continue;
62
+ /*
63
+ * **Bridge first, then simplify with the bridge vertices held fixed**, and both halves of that
64
+ * were arrived at by getting them wrong.
65
+ *
66
+ * Simplifying the bridged loop *freely* destroys the slit: Douglas–Peucker has no idea the two
67
+ * traversals of a bridge have to stay coincident, so it moves them independently and what was
68
+ * weakly simple becomes genuinely self-overlapping. Ear clipping then produces a mesh that does
69
+ * not cover its own contour — 67.5 against 82 on one of 150 random blobs, and 89 against 88 on
70
+ * another.
71
+ *
72
+ * Simplifying each loop *before* bridging fixes that and breaks something else: a hole and the
73
+ * boundary containing it then move independently, by up to the deviation each, so a hole can
74
+ * poke through its own container and the contour crosses itself with no bridge involved.
75
+ *
76
+ * What works is holding the bridge's vertices — two endpoints, each appearing twice — and
77
+ * simplifying the chains between them. A bridge has no interior vertices, so fixing its ends
78
+ * fixes it exactly.
79
+ */
80
+ const bridged = bridgeHoles(loops);
81
+ const raw = Int32Array.from(bridged.loop);
82
+ out.push({ region, points: simplify(raw, maxDeviation, bridged.anchors), raw });
83
+ }
84
+ return out;
85
+ }
86
+ /**
87
+ * Follow the directed edges into closed loops.
88
+ *
89
+ * **A vertex may have two outgoing edges, and the first version of this kept one of them.** Where a
90
+ * region pinches — a one-cell isthmus, or a ring closing on itself — the boundary passes through
91
+ * one corner twice, so a map from vertex to edge silently drops half the loop. Over 23,636 contours
92
+ * from random blobs that produced 46 self-intersecting results, every one of which the guard in
93
+ * `simplify` could not fix because the *raw* boundary was already wrong.
94
+ *
95
+ * The fix is to keep every edge. **Which of two edges is taken at a fork does not matter**, and a
96
+ * sharpest-turn rule was written, measured and removed: every vertex has as many edges in as out,
97
+ * so any choice decomposes the boundary into closed walks along cell edges, and a closed walk along
98
+ * cell edges cannot cross itself whichever walk it is. Across 106,000 contours at six fill
99
+ * densities the rule changed nothing, so it is not here.
100
+ *
101
+ * What the choice *does* decide is how a pinched boundary splits between two loops, and this takes
102
+ * the longest — the same heuristic, and the same limitation, as a region with a hole.
103
+ */
104
+ function chainLoops(edges) {
105
+ const outgoing = new Map();
106
+ for (const edge of edges) {
107
+ const key = `${edge.fx},${edge.fz}`;
108
+ const list = outgoing.get(key);
109
+ if (list === undefined)
110
+ outgoing.set(key, [edge]);
111
+ else
112
+ list.push(edge);
113
+ }
114
+ const loops = [];
115
+ /* Sorted, so a region with two loops always yields them in the same order. */
116
+ for (const start of [...outgoing.keys()].sort()) {
117
+ for (;;) {
118
+ const first = (outgoing.get(start) ?? []).find((edge) => !edge.used);
119
+ if (first === undefined)
120
+ break;
121
+ const loop = [];
122
+ let edge = first;
123
+ while (edge !== undefined && !edge.used) {
124
+ edge.used = true;
125
+ loop.push(edge.tx, edge.tz);
126
+ edge = (outgoing.get(`${edge.tx},${edge.tz}`) ?? []).find((next) => !next.used);
127
+ }
128
+ if (loop.length >= 6)
129
+ loops.push(loop);
130
+ }
131
+ }
132
+ return loops;
133
+ }
134
+ /** Twice the signed area of a closed loop, which is the sign of its winding and the size of it. */
135
+ function twiceSignedArea(loop) {
136
+ let total = 0;
137
+ const count = loop.length / 2;
138
+ for (let at = 0; at < count; at += 1) {
139
+ const next = (at + 1) % count;
140
+ total +=
141
+ loop[at * 2] * loop[next * 2 + 1] -
142
+ loop[next * 2] * loop[at * 2 + 1];
143
+ }
144
+ return total;
145
+ }
146
+ /** Whether every loop visits each vertex once and no two loops share one. */
147
+ function loopsAreDisjointAndSimple(loops) {
148
+ const all = new Set();
149
+ for (const loop of loops) {
150
+ const mine = new Set();
151
+ for (let at = 0; at < loop.length / 2; at += 1) {
152
+ const key = `${loop[at * 2]},${loop[at * 2 + 1]}`;
153
+ if (mine.has(key) || all.has(key))
154
+ return false;
155
+ mine.add(key);
156
+ }
157
+ for (const key of mine)
158
+ all.add(key);
159
+ }
160
+ return true;
161
+ }
162
+ /**
163
+ * One loop out of several, with every hole cut into the outer one by a bridge.
164
+ *
165
+ * **The outer loop is the one with the largest area, not the most points.** A hole traced round a
166
+ * long thin slot can carry more vertices than the boundary containing it, and picking by length
167
+ * then makes the hole the region — the old heuristic failing in the one direction nobody looks.
168
+ *
169
+ * **Each hole is wound against the outer before it is spliced**, because that is what subtracts it.
170
+ * A hole wound the same way *adds* its area, and every test about which corners are on the boundary
171
+ * passes either way: the loop visits the same points, and only the shoelace sum can tell.
172
+ *
173
+ * **The bridge is the closest pair that nothing gets in the way of.** Every pair of vertices is
174
+ * tried in order of distance and the first whose segment crosses no edge — of the outer loop, of
175
+ * this hole, or of any hole still to be cut — is taken. That is quadratic in a region's boundary
176
+ * and this is a bake: `buildContours` runs when a level is built, never in a tick, and the
177
+ * alternative is the ray-casting construction whose correctness argument is three pages long.
178
+ *
179
+ * **A pinched boundary already visits a corner twice, and a bridge into one cannot be told from
180
+ * the pinch.** `chainLoops` records the case: where a region narrows to a one-cell isthmus the walk
181
+ * along cell edges passes through the same corner twice, so the loop is not simple before anything
182
+ * is spliced into it. Bridging such a loop produces a vertex visited three times, which
183
+ * `segmentsCross` tolerates — three edges meeting at a point never strictly cross — and which ear
184
+ * clipping resolves by covering part of the region twice. Measured on a 12x12 blob whose region 7
185
+ * is pinched *and* holed: a mesh of 83 against a contour of 82, a navigation mesh claiming ground
186
+ * that is not walkable, which is the defect this whole change exists to remove. So a region whose
187
+ * loops are not individually simple, or which share a vertex, keeps exactly what it had before —
188
+ * the outer loop alone. **The limitation is now the narrow one it was always described as**, a
189
+ * pillar in a room that also pinches, rather than every pillar in every room.
190
+ */
191
+ function bridgeHoles(loops) {
192
+ if (loops.length === 1)
193
+ return { loop: [...loops[0]], anchors: [] };
194
+ const areas = loops.map(twiceSignedArea);
195
+ let outerAt = 0;
196
+ for (let at = 1; at < loops.length; at += 1) {
197
+ if (Math.abs(areas[at]) > Math.abs(areas[outerAt]))
198
+ outerAt = at;
199
+ }
200
+ if (!loopsAreDisjointAndSimple(loops)) {
201
+ return { loop: [...loops[outerAt]], anchors: [] };
202
+ }
203
+ const outerSign = Math.sign(areas[outerAt]);
204
+ let merged = [...loops[outerAt]];
205
+ const holes = [];
206
+ for (let at = 0; at < loops.length; at += 1) {
207
+ if (at === outerAt)
208
+ continue;
209
+ const hole = [...loops[at]];
210
+ if (Math.sign(areas[at]) === outerSign)
211
+ reverseLoop(hole);
212
+ holes.push(hole);
213
+ }
214
+ /* Deterministic order, so two builds of one level produce the same mesh. */
215
+ holes.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
216
+ let anchors = [];
217
+ /* The coordinates every bridge placed so far lands on. See `spliceHole` for why they are barred. */
218
+ const taken = new Set();
219
+ for (let at = 0; at < holes.length; at += 1) {
220
+ const spliced = spliceHole(merged, holes[at], holes.slice(at + 1), taken);
221
+ /* Anchors recorded earlier sit before the splice, or shift by the hole's length after it. */
222
+ const grew = holes[at].length / 2 + 1;
223
+ anchors = [
224
+ ...anchors.map((index) => (index <= spliced.insertedAfter ? index : index + grew)),
225
+ ...spliced.anchors,
226
+ ];
227
+ merged = spliced.loop;
228
+ }
229
+ anchors.sort((a, b) => a - b);
230
+ return { loop: merged, anchors };
231
+ }
232
+ /** A loop the other way round, in place. */
233
+ function reverseLoop(loop) {
234
+ const count = loop.length / 2;
235
+ for (let at = 0; at < Math.floor(count / 2); at += 1) {
236
+ const other = count - 1 - at;
237
+ const x = loop[at * 2];
238
+ const z = loop[at * 2 + 1];
239
+ loop[at * 2] = loop[other * 2];
240
+ loop[at * 2 + 1] = loop[other * 2 + 1];
241
+ loop[other * 2] = x;
242
+ loop[other * 2 + 1] = z;
243
+ }
244
+ }
245
+ /** Cut one hole into a loop along the shortest segment that crosses nothing. */
246
+ function spliceHole(outer, hole, pending, taken) {
247
+ const outerCount = outer.length / 2;
248
+ const holeCount = hole.length / 2;
249
+ const pairs = [];
250
+ for (let o = 0; o < outerCount; o += 1) {
251
+ for (let h = 0; h < holeCount; h += 1) {
252
+ const dx = outer[o * 2] - hole[h * 2];
253
+ const dz = outer[o * 2 + 1] - hole[h * 2 + 1];
254
+ pairs.push({ outerAt: o, holeAt: h, distance: dx * dx + dz * dz });
255
+ }
256
+ }
257
+ /* Ties broken by index, so the choice does not depend on the sort's stability. */
258
+ pairs.sort((a, b) => a.distance - b.distance || a.outerAt - b.outerAt || a.holeAt - b.holeAt);
259
+ for (const pair of pairs) {
260
+ const ax = outer[pair.outerAt * 2];
261
+ const az = outer[pair.outerAt * 2 + 1];
262
+ const bx = hole[pair.holeAt * 2];
263
+ const bz = hole[pair.holeAt * 2 + 1];
264
+ if (ax === bx && az === bz)
265
+ continue;
266
+ /*
267
+ * **No two bridges may land on the same vertex**, and **no bridge may cross an edge.** Sharing a
268
+ * vertex gives a loop that visits it three times, which is a tangle rather than a slit — two
269
+ * bridges meeting at a point never *strictly* cross, so `segmentsCross` allows it and ear
270
+ * clipping resolves it by covering part of the region twice. A bridge across an edge gives a
271
+ * polygon that is not even weakly simple.
272
+ *
273
+ * **Neither guard has been reached by a test, and both are kept.** The disjoint-and-simple
274
+ * check above turns away the shapes that produced tangles, and across 150 random blobs, three
275
+ * hand-drawn pillar rooms and a spiral corridor, every hole's closest vertex pair was already
276
+ * clear and unclaimed. They are guards against a case the check above has not been *proved* to
277
+ * exclude, which is a different thing from one it has — a hole tucked inside a concavity has a
278
+ * blocked closest pair by construction, and nothing here says a region cannot have one.
279
+ * Perturbing either changes no test; that is recorded rather than papered over.
280
+ */
281
+ if (taken.has(`${ax},${az}`) || taken.has(`${bx},${bz}`))
282
+ continue;
283
+ if (bridgeBlocked(ax, az, bx, bz, outer, hole, pending))
284
+ continue;
285
+ taken.add(`${ax},${az}`);
286
+ taken.add(`${bx},${bz}`);
287
+ const out = [];
288
+ for (let at = 0; at <= pair.outerAt; at += 1) {
289
+ out.push(outer[at * 2], outer[at * 2 + 1]);
290
+ }
291
+ for (let step = 0; step <= holeCount; step += 1) {
292
+ const at = (pair.holeAt + step) % holeCount;
293
+ out.push(hole[at * 2], hole[at * 2 + 1]);
294
+ }
295
+ for (let at = pair.outerAt; at < outerCount; at += 1) {
296
+ out.push(outer[at * 2], outer[at * 2 + 1]);
297
+ }
298
+ /*
299
+ * The four vertices the bridge is made of: the outer vertex either side of the slit, and the
300
+ * hole vertex at each end of the loop round the hole. Holding these is what keeps the two
301
+ * traversals of the bridge exactly on top of each other through simplification.
302
+ */
303
+ const holeStart = pair.outerAt + 1;
304
+ return {
305
+ loop: out,
306
+ anchors: [pair.outerAt, holeStart, holeStart + holeCount, holeStart + holeCount + 1],
307
+ insertedAfter: pair.outerAt,
308
+ };
309
+ }
310
+ /*
311
+ * **Nothing could be joined, so the hole is left out rather than joined wrongly.** A bridge that
312
+ * crossed an edge would produce a polygon that is not even weakly simple, which is worse than the
313
+ * limitation this replaces.
314
+ */
315
+ return { loop: [...outer], anchors: [], insertedAfter: outerCount - 1 };
316
+ }
317
+ /** Whether a proposed bridge strictly crosses any edge of any loop it has to pass. */
318
+ function bridgeBlocked(ax, az, bx, bz, outer, hole, pending) {
319
+ for (const loop of [outer, hole, ...pending]) {
320
+ const count = loop.length / 2;
321
+ for (let at = 0; at < count; at += 1) {
322
+ const next = (at + 1) % count;
323
+ if (segmentsCross(ax, az, bx, bz, loop[at * 2], loop[at * 2 + 1], loop[next * 2], loop[next * 2 + 1])) {
324
+ return true;
325
+ }
326
+ }
327
+ }
328
+ return false;
329
+ }
330
+ /** Perpendicular distance from `p` to the segment `a`–`b`, or to `a` where the segment is a point. */
331
+ function distanceToSegment(px, pz, ax, az, bx, bz) {
332
+ const dx = bx - ax;
333
+ const dz = bz - az;
334
+ const lengthSq = dx * dx + dz * dz;
335
+ // determinism: build-time — contour simplification is a bake, never a tick
336
+ if (lengthSq === 0)
337
+ return Math.hypot(px - ax, pz - az);
338
+ let t = ((px - ax) * dx + (pz - az) * dz) / lengthSq;
339
+ t = Math.max(0, Math.min(1, t));
340
+ // determinism: build-time — contour simplification is a bake, never a tick
341
+ return Math.hypot(px - (ax + t * dx), pz - (az + t * dz));
342
+ }
343
+ /** The furthest any raw point sits from the simplified loop. What the deviation bound means. */
344
+ export function maxDeviationOf(raw, simplified) {
345
+ const count = simplified.length / 2;
346
+ if (count < 2)
347
+ return Infinity;
348
+ let worst = 0;
349
+ for (let at = 0; at < raw.length; at += 2) {
350
+ let best = Infinity;
351
+ for (let seg = 0; seg < count; seg += 1) {
352
+ const next = (seg + 1) % count;
353
+ best = Math.min(best, distanceToSegment(raw[at], raw[at + 1], simplified[seg * 2], simplified[seg * 2 + 1], simplified[next * 2], simplified[next * 2 + 1]));
354
+ }
355
+ worst = Math.max(worst, best);
356
+ }
357
+ return worst;
358
+ }
359
+ /**
360
+ * Whether any two non-adjacent segments of a closed loop cross.
361
+ *
362
+ * Adjacent segments share an endpoint by construction and are skipped; everything else is a proper
363
+ * crossing test, so a loop that merely touches itself at a vertex is not reported — that is a
364
+ * pinch rather than a crossing, and a polygon built from it is still walkable.
365
+ */
366
+ export function contourSelfIntersects(points) {
367
+ const count = points.length / 2;
368
+ if (count < 4)
369
+ return false;
370
+ for (let a = 0; a < count; a += 1) {
371
+ const a2 = (a + 1) % count;
372
+ for (let b = a + 1; b < count; b += 1) {
373
+ const b2 = (b + 1) % count;
374
+ if (a === b || a2 === b || b2 === a)
375
+ continue;
376
+ if (segmentsCross(points[a * 2], points[a * 2 + 1], points[a2 * 2], points[a2 * 2 + 1], points[b * 2], points[b * 2 + 1], points[b2 * 2], points[b2 * 2 + 1])) {
377
+ return true;
378
+ }
379
+ }
380
+ }
381
+ return false;
382
+ }
383
+ function side(ax, az, bx, bz, px, pz) {
384
+ return Math.sign((bx - ax) * (pz - az) - (bz - az) * (px - ax));
385
+ }
386
+ function segmentsCross(ax, az, bx, bz, cx, cz, dx, dz) {
387
+ const d1 = side(ax, az, bx, bz, cx, cz);
388
+ const d2 = side(ax, az, bx, bz, dx, dz);
389
+ const d3 = side(cx, cz, dx, dz, ax, az);
390
+ const d4 = side(cx, cz, dx, dz, bx, bz);
391
+ return d1 !== d2 && d3 !== d4 && d1 !== 0 && d2 !== 0 && d3 !== 0 && d4 !== 0;
392
+ }
393
+ /**
394
+ * Douglas–Peucker around a closed loop, halving the deviation until the result is simple.
395
+ *
396
+ * The raw boundary is a walk along cell edges and cannot cross itself, so the loop terminates at
397
+ * something valid however hostile the shape. The halving is the honest version of "this usually
398
+ * works": it costs a few extra passes on a shape that needs them and nothing at all on one that
399
+ * does not.
400
+ */
401
+ function simplify(raw, maxDeviation, anchors = []) {
402
+ for (let deviation = maxDeviation; deviation > 1e-4; deviation /= 2) {
403
+ const candidate = simplifyOnce(raw, deviation, anchors);
404
+ if (!contourSelfIntersects(candidate))
405
+ return candidate;
406
+ }
407
+ return raw;
408
+ }
409
+ function simplifyOnce(raw, maxDeviation, anchors) {
410
+ const count = raw.length / 2;
411
+ if (count < 4)
412
+ return raw;
413
+ const keep = new Uint8Array(count);
414
+ const bridged = anchors.length >= 2;
415
+ let fixed;
416
+ if (bridged) {
417
+ /*
418
+ * **A bridged loop's anchors are the bridge, and they are not negotiable.** Every chain between
419
+ * two of them is an ordinary open polyline Douglas–Peucker may do as it likes with; the anchors
420
+ * are what keep the two traversals of each bridge coincident, which is the whole of what makes
421
+ * the polygon weakly simple rather than self-overlapping.
422
+ */
423
+ fixed = [...anchors].sort((a, b) => a - b);
424
+ }
425
+ else {
426
+ /* Two anchors, so the loop becomes two open chains: the first point and the one furthest. */
427
+ let far = 0;
428
+ let farDistance = -1;
429
+ for (let at = 1; at < count; at += 1) {
430
+ // determinism: build-time — contour simplification is a bake, never a tick
431
+ const d = Math.hypot(raw[at * 2] - raw[0], raw[at * 2 + 1] - raw[1]);
432
+ if (d > farDistance) {
433
+ farDistance = d;
434
+ far = at;
435
+ }
436
+ }
437
+ fixed = [0, far];
438
+ }
439
+ for (const at of fixed)
440
+ keep[at] = 1;
441
+ for (let at = 0; at < fixed.length; at += 1) {
442
+ const from = fixed[at];
443
+ const to = at + 1 < fixed.length ? fixed[at + 1] : count;
444
+ chain(raw, from, to, maxDeviation, keep);
445
+ }
446
+ /*
447
+ * **Only a *bridge* anchor is protected from the collinear pass, never the arbitrary pair.** The
448
+ * two anchors of an unbridged loop are wherever the trace happened to start and whatever was
449
+ * furthest from it, so one is routinely a point in the middle of a straight edge — and
450
+ * `dropCollinear` exists precisely to remove that, which is what keeps an L six-cornered.
451
+ */
452
+ const out = [];
453
+ const protectedOut = [];
454
+ for (let at = 0; at < count; at += 1) {
455
+ if (keep[at] !== 1)
456
+ continue;
457
+ if (bridged && fixed.includes(at))
458
+ protectedOut.push(out.length / 2);
459
+ out.push(raw[at * 2], raw[at * 2 + 1]);
460
+ }
461
+ return dropCollinear(Int32Array.from(out), protectedOut);
462
+ }
463
+ /**
464
+ * Remove any point that lies on the line between its neighbours.
465
+ *
466
+ * **The anchors are arbitrary and one of them is always kept**, so without this a loop that happens
467
+ * to start halfway along an edge carries a seventh point through a six-cornered shape — a vertex
468
+ * that is not a corner, in a mesh whose polygons are built from these. Removing a collinear point
469
+ * cannot affect the deviation bound, because a point on the line between its neighbours is at
470
+ * distance zero from the segment that replaces it.
471
+ */
472
+ function dropCollinear(points, protect = []) {
473
+ const count = points.length / 2;
474
+ if (count < 4)
475
+ return points;
476
+ const out = [];
477
+ for (let at = 0; at < count; at += 1) {
478
+ /* A bridge endpoint is collinear with its neighbours as often as not, and removing one opens
479
+ the slit into the rest of the polygon. */
480
+ if (protect.includes(at)) {
481
+ out.push(points[at * 2], points[at * 2 + 1]);
482
+ continue;
483
+ }
484
+ const prev = (at + count - 1) % count;
485
+ const next = (at + 1) % count;
486
+ const cross = (points[at * 2] - points[prev * 2]) *
487
+ (points[next * 2 + 1] - points[prev * 2 + 1]) -
488
+ (points[at * 2 + 1] - points[prev * 2 + 1]) *
489
+ (points[next * 2] - points[prev * 2]);
490
+ if (cross === 0)
491
+ continue;
492
+ out.push(points[at * 2], points[at * 2 + 1]);
493
+ }
494
+ return out.length >= 6 ? Int32Array.from(out) : points;
495
+ }
496
+ /** Mark the points of `raw[from..to]` that have to stay. `to` may be one past the end, meaning wrap. */
497
+ function chain(raw, from, to, maxDeviation, keep) {
498
+ const count = raw.length / 2;
499
+ const endIndex = to % count;
500
+ if (to - from < 2)
501
+ return;
502
+ let worst = -1;
503
+ let worstAt = -1;
504
+ for (let at = from + 1; at < to; at += 1) {
505
+ const d = distanceToSegment(raw[at * 2], raw[at * 2 + 1], raw[from * 2], raw[from * 2 + 1], raw[endIndex * 2], raw[endIndex * 2 + 1]);
506
+ if (d > worst) {
507
+ worst = d;
508
+ worstAt = at;
509
+ }
510
+ }
511
+ if (worst <= maxDeviation || worstAt < 0)
512
+ return;
513
+ keep[worstAt] = 1;
514
+ chain(raw, from, worstAt, maxDeviation, keep);
515
+ chain(raw, worstAt, to, maxDeviation, keep);
516
+ }
@@ -0,0 +1,30 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * `@driftengine/nav` — a navigation mesh.
4
+ *
5
+ * **This reverses a refusal, and the refused module named its own trigger.**
6
+ * `core/src/nav/navGraph.ts` says it is a graph rather than a mesh, that this is a decision rather
7
+ * than a first step, and that *"if a world's walkable space genuinely is a region, this is the
8
+ * wrong structure and a mesh is the row that is still open."* A streamed open world is that world.
9
+ *
10
+ * **The graph is not replaced.** It stays right for the case it was built for — roads, corridors,
11
+ * rails, docking lanes — which is what consumers kept arriving with, and a mesh would have asked
12
+ * them to throw that away. Two structures, two shapes of world, one search: the query here builds a
13
+ * `NavGraph` over polygons and hands it to core's `NavSearch`, so there is one A* in this
14
+ * repository and not two.
15
+ *
16
+ * **Steering is still refused.** §6.5 reverses the refusal of a navigation *mesh*, which is a
17
+ * function from geometry to a graph. What an agent does with a path stays a game's decision, and
18
+ * `ai/src/entities/context.ts`'s refusal is upheld on exactly that line.
19
+ */
20
+ export { columnAt, spanCount, spanFloor, spanWalkable, voxeliseWalkable } from './voxelise.ts';
21
+ export type { NavGeometry, VoxelField, VoxeliseSettings } from './voxelise.ts';
22
+ export { buildRegions, regionOfSpan, regionsLinked, spanIndexAt } from './regions.ts';
23
+ export type { RegionField, RegionSettings } from './regions.ts';
24
+ export { buildContours, contourSelfIntersects, maxDeviationOf } from './contour.ts';
25
+ export type { Contour } from './contour.ts';
26
+ export { NO_NEIGHBOUR, buildPolyMesh, polyArea, polyIsConvex, polyNeighbour, polyVertexCount, } from './polymesh.ts';
27
+ export type { NavPlacement, PolyMesh } from './polymesh.ts';
28
+ export { NavMeshQuery, nearestPoly } from './query.ts';
29
+ export { attachGoal, attachStart, buildPortalGraph } from './portalGraph.ts';
30
+ export type { PortalGraph } from './portalGraph.ts';
package/dist/index.js ADDED
@@ -0,0 +1,31 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * `@driftengine/nav` — a navigation mesh.
4
+ *
5
+ * **This reverses a refusal, and the refused module named its own trigger.**
6
+ * `core/src/nav/navGraph.ts` says it is a graph rather than a mesh, that this is a decision rather
7
+ * than a first step, and that *"if a world's walkable space genuinely is a region, this is the
8
+ * wrong structure and a mesh is the row that is still open."* A streamed open world is that world.
9
+ *
10
+ * **The graph is not replaced.** It stays right for the case it was built for — roads, corridors,
11
+ * rails, docking lanes — which is what consumers kept arriving with, and a mesh would have asked
12
+ * them to throw that away. Two structures, two shapes of world, one search: the query here builds a
13
+ * `NavGraph` over polygons and hands it to core's `NavSearch`, so there is one A* in this
14
+ * repository and not two.
15
+ *
16
+ * **Steering is still refused.** §6.5 reverses the refusal of a navigation *mesh*, which is a
17
+ * function from geometry to a graph. What an agent does with a path stays a game's decision, and
18
+ * `ai/src/entities/context.ts`'s refusal is upheld on exactly that line.
19
+ */
20
+ export { columnAt, spanCount, spanFloor, spanWalkable, voxeliseWalkable } from './voxelise.js';
21
+ export { buildRegions, regionOfSpan, regionsLinked, spanIndexAt } from './regions.js';
22
+ export { buildContours, contourSelfIntersects, maxDeviationOf } from './contour.js';
23
+ export { NO_NEIGHBOUR, buildPolyMesh, polyArea, polyIsConvex, polyNeighbour, polyVertexCount, } from './polymesh.js';
24
+ export { NavMeshQuery, nearestPoly } from './query.js';
25
+ /*
26
+ * **The graph is over portals, not polygons**, which is what makes a path the shortest one rather
27
+ * than one about a tenth longer. `portalGraph.ts` carries the argument and the measurement that
28
+ * opened it; `buildPolyGraph` is gone, because a node per polygon optimises a quantity no agent
29
+ * walks and there is no reason to keep a second graph that does.
30
+ */
31
+ export { attachGoal, attachStart, buildPortalGraph } from './portalGraph.js';
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Contours to convex polygons that tile the region and border each other both ways.
3
+ *
4
+ * **Convex, because the funnel algorithm is what makes a path a straight line** and it walks a
5
+ * corridor of convex cells. A concave polygon has an interior point the straight line between two
6
+ * of its own vertices leaves, so the funnel would produce a path through a wall.
7
+ *
8
+ * **Adjacency is built from shared edges in both directions at once**, because a one-directional
9
+ * build breaks silently: what it produces is a mesh where a path from A to B exists and the same
10
+ * path from B to A does not, which reads as a pathfinding bug and is a mesh-building one.
11
+ *
12
+ * Ear clipping first, then merge neighbours while the result stays convex. Triangles are convex by
13
+ * definition, so the merge can only ever improve the mesh and never make it invalid — which is why
14
+ * it can be a plain greedy pass with no backtracking.
15
+ */
16
+ import type { Contour } from './contour.ts';
17
+ export declare const NO_NEIGHBOUR = -1;
18
+ /**
19
+ * Where a mesh stands in the world: a vertex at cell `(i, j)` is at `originX + i * cellSize`,
20
+ * `originZ + j * cellSize`. A `VoxelField` is one, which is what `buildPolyMesh` is meant to be
21
+ * handed.
22
+ */
23
+ export interface NavPlacement {
24
+ /** x, y, z of the field's corner; y is not used, since a polygon mesh has none. */
25
+ readonly origin: ArrayLike<number>;
26
+ readonly cellSize: number;
27
+ }
28
+ export interface PolyMesh {
29
+ /** x,z per vertex, in cell units. `originX`, `originZ` and `cellSize` turn them into metres. */
30
+ readonly vertices: Int32Array;
31
+ readonly vertexCount: number;
32
+ /** `maxVertsPerPoly` slots per polygon, padded with `-1`. */
33
+ readonly polys: Int32Array;
34
+ /** The polygon across each edge, or `-1`. Same layout as `polys`: edge `i` leaves vertex `i`. */
35
+ readonly neighbours: Int32Array;
36
+ readonly polyCount: number;
37
+ readonly maxVertsPerPoly: number;
38
+ /** Which region each polygon came from. */
39
+ readonly polyRegion: Int32Array;
40
+ /**
41
+ * **Where the mesh stands, kept with it.** The field it was built from knew and the mesh used to
42
+ * drop it, so a query given positions in metres compared them with cell indices and answered in
43
+ * a mixture of the two — invisible to every test that built its field at the origin with one-unit
44
+ * cells, where the two are the same number.
45
+ */
46
+ readonly originX: number;
47
+ readonly originZ: number;
48
+ readonly cellSize: number;
49
+ }
50
+ export declare function polyVertexCount(mesh: PolyMesh, poly: number): number;
51
+ export declare function polyNeighbour(mesh: PolyMesh, poly: number, edge: number): number;
52
+ /** The shoelace area, which is positive for the winding this builds and zero for a degenerate. */
53
+ export declare function polyArea(mesh: PolyMesh, poly: number): number;
54
+ export declare function polyIsConvex(mesh: PolyMesh, poly: number): boolean;
55
+ export declare function buildPolyMesh(contours: readonly Contour[], maxVertsPerPoly: number, placement: NavPlacement): PolyMesh;