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