@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/polymesh.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
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
|
+
|
|
18
|
+
const NO_VERTEX = -1;
|
|
19
|
+
export const NO_NEIGHBOUR = -1;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where a mesh stands in the world: a vertex at cell `(i, j)` is at `originX + i * cellSize`,
|
|
23
|
+
* `originZ + j * cellSize`. A `VoxelField` is one, which is what `buildPolyMesh` is meant to be
|
|
24
|
+
* handed.
|
|
25
|
+
*/
|
|
26
|
+
export interface NavPlacement {
|
|
27
|
+
/** x, y, z of the field's corner; y is not used, since a polygon mesh has none. */
|
|
28
|
+
readonly origin: ArrayLike<number>;
|
|
29
|
+
readonly cellSize: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PolyMesh {
|
|
33
|
+
/** x,z per vertex, in cell units. `originX`, `originZ` and `cellSize` turn them into metres. */
|
|
34
|
+
readonly vertices: Int32Array;
|
|
35
|
+
readonly vertexCount: number;
|
|
36
|
+
/** `maxVertsPerPoly` slots per polygon, padded with `-1`. */
|
|
37
|
+
readonly polys: Int32Array;
|
|
38
|
+
/** The polygon across each edge, or `-1`. Same layout as `polys`: edge `i` leaves vertex `i`. */
|
|
39
|
+
readonly neighbours: Int32Array;
|
|
40
|
+
readonly polyCount: number;
|
|
41
|
+
readonly maxVertsPerPoly: number;
|
|
42
|
+
/** Which region each polygon came from. */
|
|
43
|
+
readonly polyRegion: Int32Array;
|
|
44
|
+
/**
|
|
45
|
+
* **Where the mesh stands, kept with it.** The field it was built from knew and the mesh used to
|
|
46
|
+
* drop it, so a query given positions in metres compared them with cell indices and answered in
|
|
47
|
+
* a mixture of the two — invisible to every test that built its field at the origin with one-unit
|
|
48
|
+
* cells, where the two are the same number.
|
|
49
|
+
*/
|
|
50
|
+
readonly originX: number;
|
|
51
|
+
readonly originZ: number;
|
|
52
|
+
readonly cellSize: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function polyVertexCount(mesh: PolyMesh, poly: number): number {
|
|
56
|
+
let count = 0;
|
|
57
|
+
for (let at = 0; at < mesh.maxVertsPerPoly; at += 1) {
|
|
58
|
+
if (mesh.polys[poly * mesh.maxVertsPerPoly + at] !== NO_VERTEX) count += 1;
|
|
59
|
+
}
|
|
60
|
+
return count;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function polyNeighbour(mesh: PolyMesh, poly: number, edge: number): number {
|
|
64
|
+
return mesh.neighbours[poly * mesh.maxVertsPerPoly + edge] ?? NO_NEIGHBOUR;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The shoelace area, which is positive for the winding this builds and zero for a degenerate. */
|
|
68
|
+
export function polyArea(mesh: PolyMesh, poly: number): number {
|
|
69
|
+
const count = polyVertexCount(mesh, poly);
|
|
70
|
+
let twice = 0;
|
|
71
|
+
for (let at = 0; at < count; at += 1) {
|
|
72
|
+
const a = mesh.polys[poly * mesh.maxVertsPerPoly + at] as number;
|
|
73
|
+
const b = mesh.polys[poly * mesh.maxVertsPerPoly + ((at + 1) % count)] as number;
|
|
74
|
+
twice +=
|
|
75
|
+
(mesh.vertices[a * 2] as number) * (mesh.vertices[b * 2 + 1] as number) -
|
|
76
|
+
(mesh.vertices[b * 2] as number) * (mesh.vertices[a * 2 + 1] as number);
|
|
77
|
+
}
|
|
78
|
+
return Math.abs(twice) / 2;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function polyIsConvex(mesh: PolyMesh, poly: number): boolean {
|
|
82
|
+
const count = polyVertexCount(mesh, poly);
|
|
83
|
+
if (count < 3) return false;
|
|
84
|
+
const indices: number[] = [];
|
|
85
|
+
for (let at = 0; at < count; at += 1) {
|
|
86
|
+
indices.push(mesh.polys[poly * mesh.maxVertsPerPoly + at] as number);
|
|
87
|
+
}
|
|
88
|
+
return isConvexLoop(indices, mesh.vertices);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cross(vertices: Int32Array, a: number, b: number, c: number): number {
|
|
92
|
+
return (
|
|
93
|
+
((vertices[b * 2] as number) - (vertices[a * 2] as number)) *
|
|
94
|
+
((vertices[c * 2 + 1] as number) - (vertices[a * 2 + 1] as number)) -
|
|
95
|
+
((vertices[b * 2 + 1] as number) - (vertices[a * 2 + 1] as number)) *
|
|
96
|
+
((vertices[c * 2] as number) - (vertices[a * 2] as number))
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every turn the same way round, treating a straight run as no turn at all. */
|
|
101
|
+
function isConvexLoop(loop: readonly number[], vertices: Int32Array): boolean {
|
|
102
|
+
let sign = 0;
|
|
103
|
+
for (let at = 0; at < loop.length; at += 1) {
|
|
104
|
+
const a = loop[at] as number;
|
|
105
|
+
const b = loop[(at + 1) % loop.length] as number;
|
|
106
|
+
const c = loop[(at + 2) % loop.length] as number;
|
|
107
|
+
const turn = Math.sign(cross(vertices, a, b, c));
|
|
108
|
+
if (turn === 0) continue;
|
|
109
|
+
if (sign === 0) sign = turn;
|
|
110
|
+
else if (turn !== sign) return false;
|
|
111
|
+
}
|
|
112
|
+
return sign !== 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether `p` is inside or on triangle `a b c`, used to reject an ear that swallows a vertex. */
|
|
116
|
+
function inTriangle(vertices: Int32Array, a: number, b: number, c: number, p: number): boolean {
|
|
117
|
+
const d1 = cross(vertices, a, b, p);
|
|
118
|
+
const d2 = cross(vertices, b, c, p);
|
|
119
|
+
const d3 = cross(vertices, c, a, p);
|
|
120
|
+
const negative = d1 < 0 || d2 < 0 || d3 < 0;
|
|
121
|
+
const positive = d1 > 0 || d2 > 0 || d3 > 0;
|
|
122
|
+
return !(negative && positive);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Ear clipping. The loop is counter-clockwise by the time it gets here. */
|
|
126
|
+
function triangulate(loop: number[], vertices: Int32Array): number[][] {
|
|
127
|
+
const out: number[][] = [];
|
|
128
|
+
const working = [...loop];
|
|
129
|
+
let guard = working.length * working.length + 8;
|
|
130
|
+
|
|
131
|
+
while (working.length > 3 && guard > 0) {
|
|
132
|
+
guard -= 1;
|
|
133
|
+
let clipped = false;
|
|
134
|
+
for (let at = 0; at < working.length; at += 1) {
|
|
135
|
+
const a = working[(at + working.length - 1) % working.length] as number;
|
|
136
|
+
const b = working[at] as number;
|
|
137
|
+
const c = working[(at + 1) % working.length] as number;
|
|
138
|
+
if (cross(vertices, a, b, c) <= 0) continue;
|
|
139
|
+
|
|
140
|
+
let swallows = false;
|
|
141
|
+
for (const other of working) {
|
|
142
|
+
if (other === a || other === b || other === c) continue;
|
|
143
|
+
if (inTriangle(vertices, a, b, c, other)) {
|
|
144
|
+
swallows = true;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (swallows) continue;
|
|
149
|
+
|
|
150
|
+
out.push([a, b, c]);
|
|
151
|
+
working.splice(at, 1);
|
|
152
|
+
clipped = true;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
/* A loop with no ear at all is degenerate; stopping beats spinning inside a bake. */
|
|
156
|
+
if (!clipped) break;
|
|
157
|
+
}
|
|
158
|
+
if (working.length === 3) out.push([...working]);
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Merge neighbours while the result stays convex and inside the vertex budget. */
|
|
163
|
+
function mergeConvex(polys: number[][], vertices: Int32Array, maxVerts: number): number[][] {
|
|
164
|
+
const live = polys.map((poly) => [...poly]);
|
|
165
|
+
for (let changed = true; changed;) {
|
|
166
|
+
changed = false;
|
|
167
|
+
for (let a = 0; a < live.length && !changed; a += 1) {
|
|
168
|
+
for (let b = a + 1; b < live.length && !changed; b += 1) {
|
|
169
|
+
const merged = mergeIfConvex(live[a] as number[], live[b] as number[], vertices, maxVerts);
|
|
170
|
+
if (merged === null) continue;
|
|
171
|
+
live[a] = merged;
|
|
172
|
+
live.splice(b, 1);
|
|
173
|
+
changed = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return live;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The two polygons as one, or null where they do not share an edge or the result is not convex. */
|
|
181
|
+
function mergeIfConvex(
|
|
182
|
+
a: readonly number[],
|
|
183
|
+
b: readonly number[],
|
|
184
|
+
vertices: Int32Array,
|
|
185
|
+
maxVerts: number,
|
|
186
|
+
): number[] | null {
|
|
187
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
188
|
+
const a0 = a[i] as number;
|
|
189
|
+
const a1 = a[(i + 1) % a.length] as number;
|
|
190
|
+
for (let j = 0; j < b.length; j += 1) {
|
|
191
|
+
const b0 = b[j] as number;
|
|
192
|
+
const b1 = b[(j + 1) % b.length] as number;
|
|
193
|
+
/* A shared edge runs the other way round in the neighbour, which is what makes them fit. */
|
|
194
|
+
if (a0 !== b1 || a1 !== b0) continue;
|
|
195
|
+
|
|
196
|
+
const loop: number[] = [];
|
|
197
|
+
for (let k = 1; k < a.length; k += 1) loop.push(a[(i + k) % a.length] as number);
|
|
198
|
+
for (let k = 1; k < b.length; k += 1) loop.push(b[(j + k) % b.length] as number);
|
|
199
|
+
if (loop.length > maxVerts) return null;
|
|
200
|
+
return isConvexLoop(loop, vertices) ? loop : null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function buildPolyMesh(
|
|
207
|
+
contours: readonly Contour[],
|
|
208
|
+
maxVertsPerPoly: number,
|
|
209
|
+
placement: NavPlacement,
|
|
210
|
+
): PolyMesh {
|
|
211
|
+
const maxVerts = Math.max(3, maxVertsPerPoly);
|
|
212
|
+
const vertexKeys = new Map<string, number>();
|
|
213
|
+
const vertexList: number[] = [];
|
|
214
|
+
const polys: number[][] = [];
|
|
215
|
+
const regions: number[] = [];
|
|
216
|
+
|
|
217
|
+
const intern = (x: number, z: number): number => {
|
|
218
|
+
const key = `${x},${z}`;
|
|
219
|
+
const found = vertexKeys.get(key);
|
|
220
|
+
if (found !== undefined) return found;
|
|
221
|
+
const index = vertexList.length / 2;
|
|
222
|
+
vertexList.push(x, z);
|
|
223
|
+
vertexKeys.set(key, index);
|
|
224
|
+
return index;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
for (const contour of contours) {
|
|
228
|
+
const loop: number[] = [];
|
|
229
|
+
for (let at = 0; at < contour.points.length; at += 2) {
|
|
230
|
+
loop.push(intern(contour.points[at] as number, contour.points[at + 1] as number));
|
|
231
|
+
}
|
|
232
|
+
if (loop.length < 3) continue;
|
|
233
|
+
|
|
234
|
+
const vertices = Int32Array.from(vertexList);
|
|
235
|
+
/* Counter-clockwise, because ear clipping tests a positive turn and the walk may go either way. */
|
|
236
|
+
if (signedArea(loop, vertices) < 0) loop.reverse();
|
|
237
|
+
|
|
238
|
+
const pieces = mergeConvex(triangulate(loop, vertices), vertices, maxVerts);
|
|
239
|
+
for (const piece of pieces) {
|
|
240
|
+
polys.push(piece);
|
|
241
|
+
regions.push(contour.region);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const vertices = Int32Array.from(vertexList);
|
|
246
|
+
const polyCount = polys.length;
|
|
247
|
+
const table = new Int32Array(polyCount * maxVerts).fill(NO_VERTEX);
|
|
248
|
+
const neighbours = new Int32Array(polyCount * maxVerts).fill(NO_NEIGHBOUR);
|
|
249
|
+
for (let poly = 0; poly < polyCount; poly += 1) {
|
|
250
|
+
const piece = polys[poly] as number[];
|
|
251
|
+
for (let at = 0; at < piece.length && at < maxVerts; at += 1) {
|
|
252
|
+
table[poly * maxVerts + at] = piece[at] as number;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/*
|
|
257
|
+
* Both directions in one pass. An edge `a → b` in one polygon is `b → a` in its neighbour, so
|
|
258
|
+
* recording the pair the moment it is found makes the relation symmetric by construction rather
|
|
259
|
+
* than by a second loop somebody could forget.
|
|
260
|
+
*/
|
|
261
|
+
const edgeOwner = new Map<string, [number, number]>();
|
|
262
|
+
for (let poly = 0; poly < polyCount; poly += 1) {
|
|
263
|
+
const piece = polys[poly] as number[];
|
|
264
|
+
for (let at = 0; at < piece.length; at += 1) {
|
|
265
|
+
const from = piece[at] as number;
|
|
266
|
+
const to = piece[(at + 1) % piece.length] as number;
|
|
267
|
+
const reverse = edgeOwner.get(`${to},${from}`);
|
|
268
|
+
if (reverse === undefined) {
|
|
269
|
+
edgeOwner.set(`${from},${to}`, [poly, at]);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
neighbours[poly * maxVerts + at] = reverse[0];
|
|
273
|
+
neighbours[reverse[0] * maxVerts + reverse[1]] = poly;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
vertices,
|
|
279
|
+
vertexCount: vertices.length / 2,
|
|
280
|
+
polys: table,
|
|
281
|
+
neighbours,
|
|
282
|
+
polyCount,
|
|
283
|
+
maxVertsPerPoly: maxVerts,
|
|
284
|
+
polyRegion: Int32Array.from(regions),
|
|
285
|
+
originX: placement.origin[0] as number,
|
|
286
|
+
originZ: placement.origin[2] as number,
|
|
287
|
+
cellSize: placement.cellSize,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function signedArea(loop: readonly number[], vertices: Int32Array): number {
|
|
292
|
+
let twice = 0;
|
|
293
|
+
for (let at = 0; at < loop.length; at += 1) {
|
|
294
|
+
const a = loop[at] as number;
|
|
295
|
+
const b = loop[(at + 1) % loop.length] as number;
|
|
296
|
+
twice +=
|
|
297
|
+
(vertices[a * 2] as number) * (vertices[b * 2 + 1] as number) -
|
|
298
|
+
(vertices[b * 2] as number) * (vertices[a * 2 + 1] as number);
|
|
299
|
+
}
|
|
300
|
+
return twice / 2;
|
|
301
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mesh as a graph over **portals**, which is what makes a path the shortest one.
|
|
3
|
+
*
|
|
4
|
+
* **A* over polygon centres optimises a quantity no agent walks.** A polygon path is a corridor and
|
|
5
|
+
* the funnel is optimal inside whatever corridor it is handed — so if the corridor is not the one
|
|
6
|
+
* holding the shortest path, the funnel cannot recover it. Measured on `query.test.ts`'s wall:
|
|
7
|
+
* **10.75 against an ideal 9.81**, for as long as the nodes were centroids.
|
|
8
|
+
*
|
|
9
|
+
* **And a cost on the edge between two polygons cannot fix it**, which is worth knowing before
|
|
10
|
+
* trying: what crossing a polygon costs depends on *which portal the path entered by*, and one
|
|
11
|
+
* number between two polygons cannot carry that. The midpoint-to-midpoint distance across a shared
|
|
12
|
+
* edge is zero, because it is one edge.
|
|
13
|
+
*
|
|
14
|
+
* **So the node is the portal.** An edge joins two portals of the same polygon and costs the
|
|
15
|
+
* distance between their midpoints, which is a distance something walks. The start and the goal are
|
|
16
|
+
* two more nodes, joined to the portals of the polygons holding them. What the search returns is
|
|
17
|
+
* then the portal sequence itself, so the funnel is handed its corridor directly rather than
|
|
18
|
+
* reconstructing it from a polygon path.
|
|
19
|
+
*
|
|
20
|
+
* **And a portal is where two polygons *overlap*, not where they share an edge — which is where the
|
|
21
|
+
* tenth actually went.** `buildPolyMesh` records a neighbour only for edges with the same two
|
|
22
|
+
* vertices, and two regions simplified independently do not agree about where a vertex goes: on the
|
|
23
|
+
* wall map, one polygon's edge from (6,3) to (6,4) is the middle of another's from (6,4) to (6,0),
|
|
24
|
+
* so the two are adjacent on the ground and strangers in the table. The path went the long way
|
|
25
|
+
* round because **the short corridor was not in the graph at all**, which is a different fault from
|
|
26
|
+
* the one the record had — see `docs/IMPROVEMENTS.md`, where the diagnosis was the search.
|
|
27
|
+
*
|
|
28
|
+
* Found by overlap in **exact integer arithmetic**: a mesh's vertices are cell indices, so two
|
|
29
|
+
* edges are on one line when their reduced directions and offsets are equal, and their overlap is
|
|
30
|
+
* an interval with endpoints drawn from their own four vertices. No tolerance is chosen, and none
|
|
31
|
+
* is needed.
|
|
32
|
+
*
|
|
33
|
+
* **What it gives up**: the corridor is optimal *between portal midpoints*, which is still not
|
|
34
|
+
* exactly the continuous shortest path — a route that should cross a portal near one end pays for
|
|
35
|
+
* the midpoint. On the maps here that is under a thousandth; what would make it wrong is a mesh
|
|
36
|
+
* with very long portals, where the fix is to keep the funnel and search over portal *intervals*.
|
|
37
|
+
*
|
|
38
|
+
* **Nothing here allocates per query.** The graph is built once; the start and the goal are written
|
|
39
|
+
* into edge slots reserved for them at build time, which is why `attachStart` and `attachGoal` take
|
|
40
|
+
* a graph and write to it rather than returning one.
|
|
41
|
+
*/
|
|
42
|
+
import { buildNavGraph } from '@driftengine/core';
|
|
43
|
+
import type { NavEdge, NavGraph } from '@driftengine/core';
|
|
44
|
+
|
|
45
|
+
import { polyNeighbour, polyVertexCount, type PolyMesh } from './polymesh.ts';
|
|
46
|
+
|
|
47
|
+
export interface PortalGraph {
|
|
48
|
+
/** Nodes: one per portal, then the start, then the goal. */
|
|
49
|
+
readonly graph: NavGraph;
|
|
50
|
+
readonly portalCount: number;
|
|
51
|
+
/** The two polygons each portal joins, near first: 2 per portal. */
|
|
52
|
+
readonly sides: Int32Array;
|
|
53
|
+
/**
|
|
54
|
+
* The portal's two vertices as **each** of its polygons stores them: 4 per portal, near's pair
|
|
55
|
+
* then far's.
|
|
56
|
+
*
|
|
57
|
+
* Both, rather than one pair flipped when the path crosses the other way. The funnel needs the
|
|
58
|
+
* left end and the right end *for the direction of travel*, and which vertex is which depends on
|
|
59
|
+
* the winding of the polygon being left — a mesh whose two polygons do not store a shared edge
|
|
60
|
+
* in opposite orders would silently get the funnel's two sides swapped, which is a legal path and
|
|
61
|
+
* a visibly worse one. Read at build time from both sides, so nothing assumes.
|
|
62
|
+
*/
|
|
63
|
+
readonly ends: Int32Array;
|
|
64
|
+
/** Which portals a polygon has: `polyStart[p]` to `polyStart[p + 1]` into `polyPortals`. */
|
|
65
|
+
readonly polyStart: Uint32Array;
|
|
66
|
+
readonly polyPortals: Uint32Array;
|
|
67
|
+
readonly startNode: number;
|
|
68
|
+
readonly goalNode: number;
|
|
69
|
+
/** The edge slot each portal keeps for the goal, and the range the start node's slots occupy. */
|
|
70
|
+
readonly goalSlot: Uint32Array;
|
|
71
|
+
readonly startSlots: Uint32Array;
|
|
72
|
+
/** The polygon whose portals currently point at the goal, or −1. */
|
|
73
|
+
attachedGoal: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One portal: the two polygons, and the overlap's two ends as each of them stores its direction. */
|
|
77
|
+
interface Portal {
|
|
78
|
+
readonly near: number;
|
|
79
|
+
readonly far: number;
|
|
80
|
+
readonly nearFrom: number;
|
|
81
|
+
readonly nearTo: number;
|
|
82
|
+
readonly farFrom: number;
|
|
83
|
+
readonly farTo: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Side {
|
|
87
|
+
readonly poly: number;
|
|
88
|
+
/** The edge's own two vertices, in the order its polygon stores them. */
|
|
89
|
+
readonly from: number;
|
|
90
|
+
readonly to: number;
|
|
91
|
+
/** Where each end sits along the line, as an exact integer. */
|
|
92
|
+
readonly fromAt: number;
|
|
93
|
+
readonly toAt: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function gcd(a: number, b: number): number {
|
|
97
|
+
let x = Math.abs(a);
|
|
98
|
+
let y = Math.abs(b);
|
|
99
|
+
while (y !== 0) {
|
|
100
|
+
const held = y;
|
|
101
|
+
y = x % y;
|
|
102
|
+
x = held;
|
|
103
|
+
}
|
|
104
|
+
return x === 0 ? 1 : x;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Every pair of polygon edges that lie on one line and overlap in more than a point.
|
|
109
|
+
*
|
|
110
|
+
* **Bucketed by the line itself**, so this is one pass over the edges and a small comparison inside
|
|
111
|
+
* each bucket rather than every edge against every other. The line's key is its direction reduced
|
|
112
|
+
* by the greatest common divisor, signed one way, and its offset — all integers, because a mesh's
|
|
113
|
+
* vertices are cell indices, so two edges are on one line exactly or not at all.
|
|
114
|
+
*/
|
|
115
|
+
function overlappingEdges(mesh: PolyMesh): Portal[] {
|
|
116
|
+
const lines = new Map<string, Side[]>();
|
|
117
|
+
for (let poly = 0; poly < mesh.polyCount; poly += 1) {
|
|
118
|
+
const verts = polyVertexCount(mesh, poly);
|
|
119
|
+
for (let edge = 0; edge < verts; edge += 1) {
|
|
120
|
+
const from = mesh.polys[poly * mesh.maxVertsPerPoly + edge] as number;
|
|
121
|
+
const to = mesh.polys[poly * mesh.maxVertsPerPoly + ((edge + 1) % verts)] as number;
|
|
122
|
+
const ax = mesh.vertices[from * 2] as number;
|
|
123
|
+
const az = mesh.vertices[from * 2 + 1] as number;
|
|
124
|
+
const dx = (mesh.vertices[to * 2] as number) - ax;
|
|
125
|
+
const dz = (mesh.vertices[to * 2 + 1] as number) - az;
|
|
126
|
+
if (dx === 0 && dz === 0) continue;
|
|
127
|
+
const divisor = gcd(dx, dz);
|
|
128
|
+
let ux = dx / divisor;
|
|
129
|
+
let uz = dz / divisor;
|
|
130
|
+
/* One sign per line, so an edge and its reverse land in the same bucket. */
|
|
131
|
+
if (ux < 0 || (ux === 0 && uz < 0)) {
|
|
132
|
+
ux = -ux;
|
|
133
|
+
uz = -uz;
|
|
134
|
+
}
|
|
135
|
+
/* The cross of the direction with a point on it: constant along the line, so it names it. */
|
|
136
|
+
const offset = ux * az - uz * ax;
|
|
137
|
+
const key = `${ux},${uz},${offset}`;
|
|
138
|
+
/* Along the line, measured on whichever axis the direction actually moves in. */
|
|
139
|
+
const along = (vertex: number): number =>
|
|
140
|
+
ux !== 0
|
|
141
|
+
? (mesh.vertices[vertex * 2] as number)
|
|
142
|
+
: (mesh.vertices[vertex * 2 + 1] as number);
|
|
143
|
+
const side: Side = { poly, from, to, fromAt: along(from), toAt: along(to) };
|
|
144
|
+
const held = lines.get(key);
|
|
145
|
+
if (held === undefined) lines.set(key, [side]);
|
|
146
|
+
else held.push(side);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const out: Portal[] = [];
|
|
151
|
+
for (const sides of lines.values()) {
|
|
152
|
+
for (let i = 0; i < sides.length; i += 1) {
|
|
153
|
+
for (let j = i + 1; j < sides.length; j += 1) {
|
|
154
|
+
const a = sides[i] as Side;
|
|
155
|
+
const b = sides[j] as Side;
|
|
156
|
+
if (a.poly === b.poly) continue;
|
|
157
|
+
const low = Math.max(Math.min(a.fromAt, a.toAt), Math.min(b.fromAt, b.toAt));
|
|
158
|
+
const high = Math.min(Math.max(a.fromAt, a.toAt), Math.max(b.fromAt, b.toAt));
|
|
159
|
+
/* Touching at a point is not a way through, and a zero-length portal breaks the funnel. */
|
|
160
|
+
if (!(high > low)) continue;
|
|
161
|
+
const atLow = (side: Side): number => (side.fromAt === low ? side.from : side.to);
|
|
162
|
+
const atHigh = (side: Side): number => (side.fromAt === high ? side.from : side.to);
|
|
163
|
+
const lowVertex = a.fromAt === low || a.toAt === low ? atLow(a) : atLow(b);
|
|
164
|
+
const highVertex = a.fromAt === high || a.toAt === high ? atHigh(a) : atHigh(b);
|
|
165
|
+
/*
|
|
166
|
+
* Each side names the overlap in **its own** direction of travel: the funnel wants a left
|
|
167
|
+
* end and a right end for the polygon being left, and swapping them is a legal path that
|
|
168
|
+
* bulges away from every corner instead of hugging it.
|
|
169
|
+
*/
|
|
170
|
+
const forward = (side: Side): boolean => side.toAt > side.fromAt;
|
|
171
|
+
out.push({
|
|
172
|
+
near: a.poly,
|
|
173
|
+
far: b.poly,
|
|
174
|
+
nearFrom: forward(a) ? lowVertex : highVertex,
|
|
175
|
+
nearTo: forward(a) ? highVertex : lowVertex,
|
|
176
|
+
farFrom: forward(b) ? lowVertex : highVertex,
|
|
177
|
+
farTo: forward(b) ? highVertex : lowVertex,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A portal's midpoint is its graph node's position, and the graph is in metres as a path is. */
|
|
186
|
+
export function buildPortalGraph(mesh: PolyMesh): PortalGraph {
|
|
187
|
+
const slots = mesh.maxVertsPerPoly;
|
|
188
|
+
const found = overlappingEdges(mesh);
|
|
189
|
+
const portalCount = found.length;
|
|
190
|
+
|
|
191
|
+
const startNode = portalCount;
|
|
192
|
+
const goalNode = portalCount + 1;
|
|
193
|
+
const positions = new Float32Array((portalCount + 2) * 3);
|
|
194
|
+
const sides = new Int32Array(portalCount * 2);
|
|
195
|
+
const ends = new Int32Array(portalCount * 4);
|
|
196
|
+
const degree = new Uint32Array(mesh.polyCount);
|
|
197
|
+
|
|
198
|
+
for (let at = 0; at < portalCount; at += 1) {
|
|
199
|
+
const portal = found[at] as Portal;
|
|
200
|
+
sides[at * 2] = portal.near;
|
|
201
|
+
sides[at * 2 + 1] = portal.far;
|
|
202
|
+
ends[at * 4] = portal.nearFrom;
|
|
203
|
+
ends[at * 4 + 1] = portal.nearTo;
|
|
204
|
+
ends[at * 4 + 2] = portal.farFrom;
|
|
205
|
+
ends[at * 4 + 3] = portal.farTo;
|
|
206
|
+
positions[at * 3] = midpoint(mesh, portal.nearFrom, portal.nearTo, 0);
|
|
207
|
+
positions[at * 3 + 2] = midpoint(mesh, portal.nearFrom, portal.nearTo, 1);
|
|
208
|
+
degree[portal.near] = (degree[portal.near] as number) + 1;
|
|
209
|
+
degree[portal.far] = (degree[portal.far] as number) + 1;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* Which portals each polygon has, as a CSR the attach step walks. */
|
|
213
|
+
const polyStart = new Uint32Array(mesh.polyCount + 1);
|
|
214
|
+
for (let poly = 0; poly < mesh.polyCount; poly += 1) {
|
|
215
|
+
polyStart[poly + 1] = (polyStart[poly] as number) + (degree[poly] as number);
|
|
216
|
+
}
|
|
217
|
+
const cursor = Uint32Array.from(polyStart.subarray(0, mesh.polyCount));
|
|
218
|
+
const polyPortals = new Uint32Array(polyStart[mesh.polyCount] as number);
|
|
219
|
+
for (let portal = 0; portal < portalCount; portal += 1) {
|
|
220
|
+
for (let side = 0; side < 2; side += 1) {
|
|
221
|
+
const poly = sides[portal * 2 + side] as number;
|
|
222
|
+
polyPortals[cursor[poly] as number] = portal;
|
|
223
|
+
cursor[poly] = (cursor[poly] as number) + 1;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/*
|
|
228
|
+
* An edge between every two portals of one polygon, both ways. `buildNavGraph` costs an edge at
|
|
229
|
+
* the distance between its ends, which here is midpoint to midpoint — a distance something
|
|
230
|
+
* walks, and the straight line A*'s heuristic needs it never to undercut.
|
|
231
|
+
*/
|
|
232
|
+
const edges: NavEdge[] = [];
|
|
233
|
+
for (let poly = 0; poly < mesh.polyCount; poly += 1) {
|
|
234
|
+
const from = polyStart[poly] as number;
|
|
235
|
+
const to = polyStart[poly + 1] as number;
|
|
236
|
+
for (let i = from; i < to; i += 1) {
|
|
237
|
+
for (let j = from; j < to; j += 1) {
|
|
238
|
+
if (i !== j) {
|
|
239
|
+
edges.push({ from: polyPortals[i] as number, to: polyPortals[j] as number });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/*
|
|
246
|
+
* **A slot per portal for the goal, and `maxVertsPerPoly` for the start**, written as self-loops
|
|
247
|
+
* and rewritten per query. A self-loop is inert in a search with no negative costs — reaching a
|
|
248
|
+
* node from itself can never improve what it already cost to get there — so a slot nobody is
|
|
249
|
+
* using is a slot that does nothing, rather than a route with a large number on it that a
|
|
250
|
+
* "no path" query would return as a path.
|
|
251
|
+
*/
|
|
252
|
+
for (let portal = 0; portal < portalCount; portal += 1) edges.push({ from: portal, to: portal });
|
|
253
|
+
for (let slot = 0; slot < slots; slot += 1) edges.push({ from: startNode, to: startNode });
|
|
254
|
+
|
|
255
|
+
const graph = buildNavGraph(positions, edges);
|
|
256
|
+
const goalSlot = new Uint32Array(portalCount);
|
|
257
|
+
for (let portal = 0; portal < portalCount; portal += 1) {
|
|
258
|
+
goalSlot[portal] = selfLoopOf(graph, portal);
|
|
259
|
+
}
|
|
260
|
+
const startSlots = new Uint32Array(slots);
|
|
261
|
+
for (let slot = 0; slot < slots; slot += 1) {
|
|
262
|
+
startSlots[slot] = (graph.edgeStart[startNode] as number) + slot;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
graph,
|
|
267
|
+
portalCount,
|
|
268
|
+
sides,
|
|
269
|
+
ends,
|
|
270
|
+
polyStart,
|
|
271
|
+
polyPortals,
|
|
272
|
+
startNode,
|
|
273
|
+
goalNode,
|
|
274
|
+
goalSlot,
|
|
275
|
+
startSlots,
|
|
276
|
+
attachedGoal: -1,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Where the reserved slot landed, found rather than computed.
|
|
282
|
+
*
|
|
283
|
+
* `buildNavGraph` groups edges by their `from`, and the order inside a group is the order they were
|
|
284
|
+
* given in — so the reserved one is last. Searching for it instead costs one pass at build time and
|
|
285
|
+
* keeps this file from depending on that.
|
|
286
|
+
*/
|
|
287
|
+
function selfLoopOf(graph: NavGraph, node: number): number {
|
|
288
|
+
for (
|
|
289
|
+
let edge = graph.edgeStart[node] as number;
|
|
290
|
+
edge < (graph.edgeStart[node + 1] as number);
|
|
291
|
+
edge += 1
|
|
292
|
+
) {
|
|
293
|
+
if ((graph.edgeTarget[edge] as number) === node) return edge;
|
|
294
|
+
}
|
|
295
|
+
throw new Error(`nav: node ${node} has no reserved slot`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function midpoint(mesh: PolyMesh, a: number, b: number, axis: number): number {
|
|
299
|
+
const origin = axis === 0 ? mesh.originX : mesh.originZ;
|
|
300
|
+
const first = mesh.vertices[a * 2 + axis] as number;
|
|
301
|
+
const second = mesh.vertices[b * 2 + axis] as number;
|
|
302
|
+
return origin + ((first + second) / 2) * mesh.cellSize;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Point the start node at the portals of the polygon holding it. */
|
|
306
|
+
export function attachStart(portals: PortalGraph, poly: number, x: number, z: number): void {
|
|
307
|
+
const { graph, startNode } = portals;
|
|
308
|
+
graph.positions[startNode * 3] = x;
|
|
309
|
+
graph.positions[startNode * 3 + 1] = 0;
|
|
310
|
+
graph.positions[startNode * 3 + 2] = z;
|
|
311
|
+
|
|
312
|
+
const from = portals.polyStart[poly] as number;
|
|
313
|
+
const to = portals.polyStart[poly + 1] as number;
|
|
314
|
+
for (let slot = 0; slot < portals.startSlots.length; slot += 1) {
|
|
315
|
+
const edge = portals.startSlots[slot] as number;
|
|
316
|
+
const held = from + slot < to ? (portals.polyPortals[from + slot] as number) : -1;
|
|
317
|
+
graph.edgeTarget[edge] = held < 0 ? startNode : held;
|
|
318
|
+
graph.edgeCost[edge] = held < 0 ? 0 : reach(graph, held, x, z);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Point the portals of the goal's polygon at the goal node, and let the previous ones go. */
|
|
323
|
+
export function attachGoal(portals: PortalGraph, poly: number, x: number, z: number): void {
|
|
324
|
+
const { graph, goalNode } = portals;
|
|
325
|
+
graph.positions[goalNode * 3] = x;
|
|
326
|
+
graph.positions[goalNode * 3 + 1] = 0;
|
|
327
|
+
graph.positions[goalNode * 3 + 2] = z;
|
|
328
|
+
|
|
329
|
+
const previous = portals.attachedGoal;
|
|
330
|
+
if (previous >= 0) {
|
|
331
|
+
for (
|
|
332
|
+
let at = portals.polyStart[previous] as number;
|
|
333
|
+
at < (portals.polyStart[previous + 1] as number);
|
|
334
|
+
at += 1
|
|
335
|
+
) {
|
|
336
|
+
const portal = portals.polyPortals[at] as number;
|
|
337
|
+
const edge = portals.goalSlot[portal] as number;
|
|
338
|
+
graph.edgeTarget[edge] = portal;
|
|
339
|
+
graph.edgeCost[edge] = 0;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
for (
|
|
343
|
+
let at = portals.polyStart[poly] as number;
|
|
344
|
+
at < (portals.polyStart[poly + 1] as number);
|
|
345
|
+
at += 1
|
|
346
|
+
) {
|
|
347
|
+
const portal = portals.polyPortals[at] as number;
|
|
348
|
+
const edge = portals.goalSlot[portal] as number;
|
|
349
|
+
graph.edgeTarget[edge] = goalNode;
|
|
350
|
+
graph.edgeCost[edge] = reach(graph, portal, x, z);
|
|
351
|
+
}
|
|
352
|
+
portals.attachedGoal = poly;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function reach(graph: NavGraph, portal: number, x: number, z: number): number {
|
|
356
|
+
const dx = (graph.positions[portal * 3] as number) - x;
|
|
357
|
+
const dz = (graph.positions[portal * 3 + 2] as number) - z;
|
|
358
|
+
return Math.sqrt(dx * dx + dz * dz);
|
|
359
|
+
}
|