@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,234 @@
1
+ const NO_VERTEX = -1;
2
+ export const NO_NEIGHBOUR = -1;
3
+ export function polyVertexCount(mesh, poly) {
4
+ let count = 0;
5
+ for (let at = 0; at < mesh.maxVertsPerPoly; at += 1) {
6
+ if (mesh.polys[poly * mesh.maxVertsPerPoly + at] !== NO_VERTEX)
7
+ count += 1;
8
+ }
9
+ return count;
10
+ }
11
+ export function polyNeighbour(mesh, poly, edge) {
12
+ return mesh.neighbours[poly * mesh.maxVertsPerPoly + edge] ?? NO_NEIGHBOUR;
13
+ }
14
+ /** The shoelace area, which is positive for the winding this builds and zero for a degenerate. */
15
+ export function polyArea(mesh, poly) {
16
+ const count = polyVertexCount(mesh, poly);
17
+ let twice = 0;
18
+ for (let at = 0; at < count; at += 1) {
19
+ const a = mesh.polys[poly * mesh.maxVertsPerPoly + at];
20
+ const b = mesh.polys[poly * mesh.maxVertsPerPoly + ((at + 1) % count)];
21
+ twice +=
22
+ mesh.vertices[a * 2] * mesh.vertices[b * 2 + 1] -
23
+ mesh.vertices[b * 2] * mesh.vertices[a * 2 + 1];
24
+ }
25
+ return Math.abs(twice) / 2;
26
+ }
27
+ export function polyIsConvex(mesh, poly) {
28
+ const count = polyVertexCount(mesh, poly);
29
+ if (count < 3)
30
+ return false;
31
+ const indices = [];
32
+ for (let at = 0; at < count; at += 1) {
33
+ indices.push(mesh.polys[poly * mesh.maxVertsPerPoly + at]);
34
+ }
35
+ return isConvexLoop(indices, mesh.vertices);
36
+ }
37
+ function cross(vertices, a, b, c) {
38
+ return ((vertices[b * 2] - vertices[a * 2]) *
39
+ (vertices[c * 2 + 1] - vertices[a * 2 + 1]) -
40
+ (vertices[b * 2 + 1] - vertices[a * 2 + 1]) *
41
+ (vertices[c * 2] - vertices[a * 2]));
42
+ }
43
+ /** Every turn the same way round, treating a straight run as no turn at all. */
44
+ function isConvexLoop(loop, vertices) {
45
+ let sign = 0;
46
+ for (let at = 0; at < loop.length; at += 1) {
47
+ const a = loop[at];
48
+ const b = loop[(at + 1) % loop.length];
49
+ const c = loop[(at + 2) % loop.length];
50
+ const turn = Math.sign(cross(vertices, a, b, c));
51
+ if (turn === 0)
52
+ continue;
53
+ if (sign === 0)
54
+ sign = turn;
55
+ else if (turn !== sign)
56
+ return false;
57
+ }
58
+ return sign !== 0;
59
+ }
60
+ /** Whether `p` is inside or on triangle `a b c`, used to reject an ear that swallows a vertex. */
61
+ function inTriangle(vertices, a, b, c, p) {
62
+ const d1 = cross(vertices, a, b, p);
63
+ const d2 = cross(vertices, b, c, p);
64
+ const d3 = cross(vertices, c, a, p);
65
+ const negative = d1 < 0 || d2 < 0 || d3 < 0;
66
+ const positive = d1 > 0 || d2 > 0 || d3 > 0;
67
+ return !(negative && positive);
68
+ }
69
+ /** Ear clipping. The loop is counter-clockwise by the time it gets here. */
70
+ function triangulate(loop, vertices) {
71
+ const out = [];
72
+ const working = [...loop];
73
+ let guard = working.length * working.length + 8;
74
+ while (working.length > 3 && guard > 0) {
75
+ guard -= 1;
76
+ let clipped = false;
77
+ for (let at = 0; at < working.length; at += 1) {
78
+ const a = working[(at + working.length - 1) % working.length];
79
+ const b = working[at];
80
+ const c = working[(at + 1) % working.length];
81
+ if (cross(vertices, a, b, c) <= 0)
82
+ continue;
83
+ let swallows = false;
84
+ for (const other of working) {
85
+ if (other === a || other === b || other === c)
86
+ continue;
87
+ if (inTriangle(vertices, a, b, c, other)) {
88
+ swallows = true;
89
+ break;
90
+ }
91
+ }
92
+ if (swallows)
93
+ continue;
94
+ out.push([a, b, c]);
95
+ working.splice(at, 1);
96
+ clipped = true;
97
+ break;
98
+ }
99
+ /* A loop with no ear at all is degenerate; stopping beats spinning inside a bake. */
100
+ if (!clipped)
101
+ break;
102
+ }
103
+ if (working.length === 3)
104
+ out.push([...working]);
105
+ return out;
106
+ }
107
+ /** Merge neighbours while the result stays convex and inside the vertex budget. */
108
+ function mergeConvex(polys, vertices, maxVerts) {
109
+ const live = polys.map((poly) => [...poly]);
110
+ for (let changed = true; changed;) {
111
+ changed = false;
112
+ for (let a = 0; a < live.length && !changed; a += 1) {
113
+ for (let b = a + 1; b < live.length && !changed; b += 1) {
114
+ const merged = mergeIfConvex(live[a], live[b], vertices, maxVerts);
115
+ if (merged === null)
116
+ continue;
117
+ live[a] = merged;
118
+ live.splice(b, 1);
119
+ changed = true;
120
+ }
121
+ }
122
+ }
123
+ return live;
124
+ }
125
+ /** The two polygons as one, or null where they do not share an edge or the result is not convex. */
126
+ function mergeIfConvex(a, b, vertices, maxVerts) {
127
+ for (let i = 0; i < a.length; i += 1) {
128
+ const a0 = a[i];
129
+ const a1 = a[(i + 1) % a.length];
130
+ for (let j = 0; j < b.length; j += 1) {
131
+ const b0 = b[j];
132
+ const b1 = b[(j + 1) % b.length];
133
+ /* A shared edge runs the other way round in the neighbour, which is what makes them fit. */
134
+ if (a0 !== b1 || a1 !== b0)
135
+ continue;
136
+ const loop = [];
137
+ for (let k = 1; k < a.length; k += 1)
138
+ loop.push(a[(i + k) % a.length]);
139
+ for (let k = 1; k < b.length; k += 1)
140
+ loop.push(b[(j + k) % b.length]);
141
+ if (loop.length > maxVerts)
142
+ return null;
143
+ return isConvexLoop(loop, vertices) ? loop : null;
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+ export function buildPolyMesh(contours, maxVertsPerPoly, placement) {
149
+ const maxVerts = Math.max(3, maxVertsPerPoly);
150
+ const vertexKeys = new Map();
151
+ const vertexList = [];
152
+ const polys = [];
153
+ const regions = [];
154
+ const intern = (x, z) => {
155
+ const key = `${x},${z}`;
156
+ const found = vertexKeys.get(key);
157
+ if (found !== undefined)
158
+ return found;
159
+ const index = vertexList.length / 2;
160
+ vertexList.push(x, z);
161
+ vertexKeys.set(key, index);
162
+ return index;
163
+ };
164
+ for (const contour of contours) {
165
+ const loop = [];
166
+ for (let at = 0; at < contour.points.length; at += 2) {
167
+ loop.push(intern(contour.points[at], contour.points[at + 1]));
168
+ }
169
+ if (loop.length < 3)
170
+ continue;
171
+ const vertices = Int32Array.from(vertexList);
172
+ /* Counter-clockwise, because ear clipping tests a positive turn and the walk may go either way. */
173
+ if (signedArea(loop, vertices) < 0)
174
+ loop.reverse();
175
+ const pieces = mergeConvex(triangulate(loop, vertices), vertices, maxVerts);
176
+ for (const piece of pieces) {
177
+ polys.push(piece);
178
+ regions.push(contour.region);
179
+ }
180
+ }
181
+ const vertices = Int32Array.from(vertexList);
182
+ const polyCount = polys.length;
183
+ const table = new Int32Array(polyCount * maxVerts).fill(NO_VERTEX);
184
+ const neighbours = new Int32Array(polyCount * maxVerts).fill(NO_NEIGHBOUR);
185
+ for (let poly = 0; poly < polyCount; poly += 1) {
186
+ const piece = polys[poly];
187
+ for (let at = 0; at < piece.length && at < maxVerts; at += 1) {
188
+ table[poly * maxVerts + at] = piece[at];
189
+ }
190
+ }
191
+ /*
192
+ * Both directions in one pass. An edge `a → b` in one polygon is `b → a` in its neighbour, so
193
+ * recording the pair the moment it is found makes the relation symmetric by construction rather
194
+ * than by a second loop somebody could forget.
195
+ */
196
+ const edgeOwner = new Map();
197
+ for (let poly = 0; poly < polyCount; poly += 1) {
198
+ const piece = polys[poly];
199
+ for (let at = 0; at < piece.length; at += 1) {
200
+ const from = piece[at];
201
+ const to = piece[(at + 1) % piece.length];
202
+ const reverse = edgeOwner.get(`${to},${from}`);
203
+ if (reverse === undefined) {
204
+ edgeOwner.set(`${from},${to}`, [poly, at]);
205
+ continue;
206
+ }
207
+ neighbours[poly * maxVerts + at] = reverse[0];
208
+ neighbours[reverse[0] * maxVerts + reverse[1]] = poly;
209
+ }
210
+ }
211
+ return {
212
+ vertices,
213
+ vertexCount: vertices.length / 2,
214
+ polys: table,
215
+ neighbours,
216
+ polyCount,
217
+ maxVertsPerPoly: maxVerts,
218
+ polyRegion: Int32Array.from(regions),
219
+ originX: placement.origin[0],
220
+ originZ: placement.origin[2],
221
+ cellSize: placement.cellSize,
222
+ };
223
+ }
224
+ function signedArea(loop, vertices) {
225
+ let twice = 0;
226
+ for (let at = 0; at < loop.length; at += 1) {
227
+ const a = loop[at];
228
+ const b = loop[(at + 1) % loop.length];
229
+ twice +=
230
+ vertices[a * 2] * vertices[b * 2 + 1] -
231
+ vertices[b * 2] * vertices[a * 2 + 1];
232
+ }
233
+ return twice / 2;
234
+ }
@@ -0,0 +1,36 @@
1
+ import type { NavGraph } from '@driftengine/core';
2
+ import { type PolyMesh } from './polymesh.ts';
3
+ export interface PortalGraph {
4
+ /** Nodes: one per portal, then the start, then the goal. */
5
+ readonly graph: NavGraph;
6
+ readonly portalCount: number;
7
+ /** The two polygons each portal joins, near first: 2 per portal. */
8
+ readonly sides: Int32Array;
9
+ /**
10
+ * The portal's two vertices as **each** of its polygons stores them: 4 per portal, near's pair
11
+ * then far's.
12
+ *
13
+ * Both, rather than one pair flipped when the path crosses the other way. The funnel needs the
14
+ * left end and the right end *for the direction of travel*, and which vertex is which depends on
15
+ * the winding of the polygon being left — a mesh whose two polygons do not store a shared edge
16
+ * in opposite orders would silently get the funnel's two sides swapped, which is a legal path and
17
+ * a visibly worse one. Read at build time from both sides, so nothing assumes.
18
+ */
19
+ readonly ends: Int32Array;
20
+ /** Which portals a polygon has: `polyStart[p]` to `polyStart[p + 1]` into `polyPortals`. */
21
+ readonly polyStart: Uint32Array;
22
+ readonly polyPortals: Uint32Array;
23
+ readonly startNode: number;
24
+ readonly goalNode: number;
25
+ /** The edge slot each portal keeps for the goal, and the range the start node's slots occupy. */
26
+ readonly goalSlot: Uint32Array;
27
+ readonly startSlots: Uint32Array;
28
+ /** The polygon whose portals currently point at the goal, or −1. */
29
+ attachedGoal: number;
30
+ }
31
+ /** A portal's midpoint is its graph node's position, and the graph is in metres as a path is. */
32
+ export declare function buildPortalGraph(mesh: PolyMesh): PortalGraph;
33
+ /** Point the start node at the portals of the polygon holding it. */
34
+ export declare function attachStart(portals: PortalGraph, poly: number, x: number, z: number): void;
35
+ /** Point the portals of the goal's polygon at the goal node, and let the previous ones go. */
36
+ export declare function attachGoal(portals: PortalGraph, poly: number, x: number, z: number): void;
@@ -0,0 +1,285 @@
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 { polyNeighbour, polyVertexCount } from './polymesh.js';
44
+ function gcd(a, b) {
45
+ let x = Math.abs(a);
46
+ let y = Math.abs(b);
47
+ while (y !== 0) {
48
+ const held = y;
49
+ y = x % y;
50
+ x = held;
51
+ }
52
+ return x === 0 ? 1 : x;
53
+ }
54
+ /**
55
+ * Every pair of polygon edges that lie on one line and overlap in more than a point.
56
+ *
57
+ * **Bucketed by the line itself**, so this is one pass over the edges and a small comparison inside
58
+ * each bucket rather than every edge against every other. The line's key is its direction reduced
59
+ * by the greatest common divisor, signed one way, and its offset — all integers, because a mesh's
60
+ * vertices are cell indices, so two edges are on one line exactly or not at all.
61
+ */
62
+ function overlappingEdges(mesh) {
63
+ const lines = new Map();
64
+ for (let poly = 0; poly < mesh.polyCount; poly += 1) {
65
+ const verts = polyVertexCount(mesh, poly);
66
+ for (let edge = 0; edge < verts; edge += 1) {
67
+ const from = mesh.polys[poly * mesh.maxVertsPerPoly + edge];
68
+ const to = mesh.polys[poly * mesh.maxVertsPerPoly + ((edge + 1) % verts)];
69
+ const ax = mesh.vertices[from * 2];
70
+ const az = mesh.vertices[from * 2 + 1];
71
+ const dx = mesh.vertices[to * 2] - ax;
72
+ const dz = mesh.vertices[to * 2 + 1] - az;
73
+ if (dx === 0 && dz === 0)
74
+ continue;
75
+ const divisor = gcd(dx, dz);
76
+ let ux = dx / divisor;
77
+ let uz = dz / divisor;
78
+ /* One sign per line, so an edge and its reverse land in the same bucket. */
79
+ if (ux < 0 || (ux === 0 && uz < 0)) {
80
+ ux = -ux;
81
+ uz = -uz;
82
+ }
83
+ /* The cross of the direction with a point on it: constant along the line, so it names it. */
84
+ const offset = ux * az - uz * ax;
85
+ const key = `${ux},${uz},${offset}`;
86
+ /* Along the line, measured on whichever axis the direction actually moves in. */
87
+ const along = (vertex) => ux !== 0
88
+ ? mesh.vertices[vertex * 2]
89
+ : mesh.vertices[vertex * 2 + 1];
90
+ const side = { poly, from, to, fromAt: along(from), toAt: along(to) };
91
+ const held = lines.get(key);
92
+ if (held === undefined)
93
+ lines.set(key, [side]);
94
+ else
95
+ held.push(side);
96
+ }
97
+ }
98
+ const out = [];
99
+ for (const sides of lines.values()) {
100
+ for (let i = 0; i < sides.length; i += 1) {
101
+ for (let j = i + 1; j < sides.length; j += 1) {
102
+ const a = sides[i];
103
+ const b = sides[j];
104
+ if (a.poly === b.poly)
105
+ continue;
106
+ const low = Math.max(Math.min(a.fromAt, a.toAt), Math.min(b.fromAt, b.toAt));
107
+ const high = Math.min(Math.max(a.fromAt, a.toAt), Math.max(b.fromAt, b.toAt));
108
+ /* Touching at a point is not a way through, and a zero-length portal breaks the funnel. */
109
+ if (!(high > low))
110
+ continue;
111
+ const atLow = (side) => (side.fromAt === low ? side.from : side.to);
112
+ const atHigh = (side) => (side.fromAt === high ? side.from : side.to);
113
+ const lowVertex = a.fromAt === low || a.toAt === low ? atLow(a) : atLow(b);
114
+ const highVertex = a.fromAt === high || a.toAt === high ? atHigh(a) : atHigh(b);
115
+ /*
116
+ * Each side names the overlap in **its own** direction of travel: the funnel wants a left
117
+ * end and a right end for the polygon being left, and swapping them is a legal path that
118
+ * bulges away from every corner instead of hugging it.
119
+ */
120
+ const forward = (side) => side.toAt > side.fromAt;
121
+ out.push({
122
+ near: a.poly,
123
+ far: b.poly,
124
+ nearFrom: forward(a) ? lowVertex : highVertex,
125
+ nearTo: forward(a) ? highVertex : lowVertex,
126
+ farFrom: forward(b) ? lowVertex : highVertex,
127
+ farTo: forward(b) ? highVertex : lowVertex,
128
+ });
129
+ }
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+ /** A portal's midpoint is its graph node's position, and the graph is in metres as a path is. */
135
+ export function buildPortalGraph(mesh) {
136
+ const slots = mesh.maxVertsPerPoly;
137
+ const found = overlappingEdges(mesh);
138
+ const portalCount = found.length;
139
+ const startNode = portalCount;
140
+ const goalNode = portalCount + 1;
141
+ const positions = new Float32Array((portalCount + 2) * 3);
142
+ const sides = new Int32Array(portalCount * 2);
143
+ const ends = new Int32Array(portalCount * 4);
144
+ const degree = new Uint32Array(mesh.polyCount);
145
+ for (let at = 0; at < portalCount; at += 1) {
146
+ const portal = found[at];
147
+ sides[at * 2] = portal.near;
148
+ sides[at * 2 + 1] = portal.far;
149
+ ends[at * 4] = portal.nearFrom;
150
+ ends[at * 4 + 1] = portal.nearTo;
151
+ ends[at * 4 + 2] = portal.farFrom;
152
+ ends[at * 4 + 3] = portal.farTo;
153
+ positions[at * 3] = midpoint(mesh, portal.nearFrom, portal.nearTo, 0);
154
+ positions[at * 3 + 2] = midpoint(mesh, portal.nearFrom, portal.nearTo, 1);
155
+ degree[portal.near] = degree[portal.near] + 1;
156
+ degree[portal.far] = degree[portal.far] + 1;
157
+ }
158
+ /* Which portals each polygon has, as a CSR the attach step walks. */
159
+ const polyStart = new Uint32Array(mesh.polyCount + 1);
160
+ for (let poly = 0; poly < mesh.polyCount; poly += 1) {
161
+ polyStart[poly + 1] = polyStart[poly] + degree[poly];
162
+ }
163
+ const cursor = Uint32Array.from(polyStart.subarray(0, mesh.polyCount));
164
+ const polyPortals = new Uint32Array(polyStart[mesh.polyCount]);
165
+ for (let portal = 0; portal < portalCount; portal += 1) {
166
+ for (let side = 0; side < 2; side += 1) {
167
+ const poly = sides[portal * 2 + side];
168
+ polyPortals[cursor[poly]] = portal;
169
+ cursor[poly] = cursor[poly] + 1;
170
+ }
171
+ }
172
+ /*
173
+ * An edge between every two portals of one polygon, both ways. `buildNavGraph` costs an edge at
174
+ * the distance between its ends, which here is midpoint to midpoint — a distance something
175
+ * walks, and the straight line A*'s heuristic needs it never to undercut.
176
+ */
177
+ const edges = [];
178
+ for (let poly = 0; poly < mesh.polyCount; poly += 1) {
179
+ const from = polyStart[poly];
180
+ const to = polyStart[poly + 1];
181
+ for (let i = from; i < to; i += 1) {
182
+ for (let j = from; j < to; j += 1) {
183
+ if (i !== j) {
184
+ edges.push({ from: polyPortals[i], to: polyPortals[j] });
185
+ }
186
+ }
187
+ }
188
+ }
189
+ /*
190
+ * **A slot per portal for the goal, and `maxVertsPerPoly` for the start**, written as self-loops
191
+ * and rewritten per query. A self-loop is inert in a search with no negative costs — reaching a
192
+ * node from itself can never improve what it already cost to get there — so a slot nobody is
193
+ * using is a slot that does nothing, rather than a route with a large number on it that a
194
+ * "no path" query would return as a path.
195
+ */
196
+ for (let portal = 0; portal < portalCount; portal += 1)
197
+ edges.push({ from: portal, to: portal });
198
+ for (let slot = 0; slot < slots; slot += 1)
199
+ edges.push({ from: startNode, to: startNode });
200
+ const graph = buildNavGraph(positions, edges);
201
+ const goalSlot = new Uint32Array(portalCount);
202
+ for (let portal = 0; portal < portalCount; portal += 1) {
203
+ goalSlot[portal] = selfLoopOf(graph, portal);
204
+ }
205
+ const startSlots = new Uint32Array(slots);
206
+ for (let slot = 0; slot < slots; slot += 1) {
207
+ startSlots[slot] = graph.edgeStart[startNode] + slot;
208
+ }
209
+ return {
210
+ graph,
211
+ portalCount,
212
+ sides,
213
+ ends,
214
+ polyStart,
215
+ polyPortals,
216
+ startNode,
217
+ goalNode,
218
+ goalSlot,
219
+ startSlots,
220
+ attachedGoal: -1,
221
+ };
222
+ }
223
+ /**
224
+ * Where the reserved slot landed, found rather than computed.
225
+ *
226
+ * `buildNavGraph` groups edges by their `from`, and the order inside a group is the order they were
227
+ * given in — so the reserved one is last. Searching for it instead costs one pass at build time and
228
+ * keeps this file from depending on that.
229
+ */
230
+ function selfLoopOf(graph, node) {
231
+ for (let edge = graph.edgeStart[node]; edge < graph.edgeStart[node + 1]; edge += 1) {
232
+ if (graph.edgeTarget[edge] === node)
233
+ return edge;
234
+ }
235
+ throw new Error(`nav: node ${node} has no reserved slot`);
236
+ }
237
+ function midpoint(mesh, a, b, axis) {
238
+ const origin = axis === 0 ? mesh.originX : mesh.originZ;
239
+ const first = mesh.vertices[a * 2 + axis];
240
+ const second = mesh.vertices[b * 2 + axis];
241
+ return origin + ((first + second) / 2) * mesh.cellSize;
242
+ }
243
+ /** Point the start node at the portals of the polygon holding it. */
244
+ export function attachStart(portals, poly, x, z) {
245
+ const { graph, startNode } = portals;
246
+ graph.positions[startNode * 3] = x;
247
+ graph.positions[startNode * 3 + 1] = 0;
248
+ graph.positions[startNode * 3 + 2] = z;
249
+ const from = portals.polyStart[poly];
250
+ const to = portals.polyStart[poly + 1];
251
+ for (let slot = 0; slot < portals.startSlots.length; slot += 1) {
252
+ const edge = portals.startSlots[slot];
253
+ const held = from + slot < to ? portals.polyPortals[from + slot] : -1;
254
+ graph.edgeTarget[edge] = held < 0 ? startNode : held;
255
+ graph.edgeCost[edge] = held < 0 ? 0 : reach(graph, held, x, z);
256
+ }
257
+ }
258
+ /** Point the portals of the goal's polygon at the goal node, and let the previous ones go. */
259
+ export function attachGoal(portals, poly, x, z) {
260
+ const { graph, goalNode } = portals;
261
+ graph.positions[goalNode * 3] = x;
262
+ graph.positions[goalNode * 3 + 1] = 0;
263
+ graph.positions[goalNode * 3 + 2] = z;
264
+ const previous = portals.attachedGoal;
265
+ if (previous >= 0) {
266
+ for (let at = portals.polyStart[previous]; at < portals.polyStart[previous + 1]; at += 1) {
267
+ const portal = portals.polyPortals[at];
268
+ const edge = portals.goalSlot[portal];
269
+ graph.edgeTarget[edge] = portal;
270
+ graph.edgeCost[edge] = 0;
271
+ }
272
+ }
273
+ for (let at = portals.polyStart[poly]; at < portals.polyStart[poly + 1]; at += 1) {
274
+ const portal = portals.polyPortals[at];
275
+ const edge = portals.goalSlot[portal];
276
+ graph.edgeTarget[edge] = goalNode;
277
+ graph.edgeCost[edge] = reach(graph, portal, x, z);
278
+ }
279
+ portals.attachedGoal = poly;
280
+ }
281
+ function reach(graph, portal, x, z) {
282
+ const dx = graph.positions[portal * 3] - x;
283
+ const dz = graph.positions[portal * 3 + 2] - z;
284
+ return Math.sqrt(dx * dx + dz * dz);
285
+ }
@@ -0,0 +1,59 @@
1
+ import { type PolyMesh } from './polymesh.ts';
2
+ import { type PortalGraph } from './portalGraph.ts';
3
+ /**
4
+ * The polygon a point is in, or the nearest within `extent`. `-1` beyond it. The point and the
5
+ * extent are in metres, which the mesh's placement turns into its cells.
6
+ *
7
+ * **`-1` rather than the nearest whatever the distance**, because an agent asked to walk to a
8
+ * place that is not on the mesh has to be told. Snapping across the map produces a path to
9
+ * somewhere nobody asked for, which is worse than no path at all.
10
+ */
11
+ export declare function nearestPoly(mesh: PolyMesh, x: number, z: number, extent: number): number;
12
+ /**
13
+ * A query, holding everything a search needs so that running one allocates nothing.
14
+ *
15
+ * One per agent, or one shared by a caller that queries serially. The scratch is sized by the mesh
16
+ * because a polygon path cannot be longer than the mesh has polygons.
17
+ */
18
+ export declare class NavMeshQuery {
19
+ readonly mesh: PolyMesh;
20
+ readonly portals: PortalGraph;
21
+ private readonly search;
22
+ /** The nodes a search returns: the start, the portals it crosses, then the goal. */
23
+ private readonly nodePath;
24
+ /** Portal ends, two points per portal: left x, left z, right x, right z. */
25
+ private readonly corridor;
26
+ private readonly centre;
27
+ constructor(mesh: PolyMesh);
28
+ /**
29
+ * The polygons from `from` to `to`, written into `out`. Zero where there is no route.
30
+ *
31
+ * Asked between the two polygons' centres, because a polygon is a region and a search over
32
+ * portals needs a point. What comes back is the corridor a path between those two points would
33
+ * be funnelled through, which is what a caller asking this question wants.
34
+ */
35
+ findPolyPath(from: number, to: number, out: Uint32Array): number;
36
+ /**
37
+ * A path from one point to another, as world positions in `out`, x and z per step.
38
+ *
39
+ * Returns how many points were written, or zero for no route — which is a real answer, not an
40
+ * error: a mesh with two components is a world with a gap in it, and an agent that asks for the
41
+ * other side has to be told rather than thrown at inside a frame.
42
+ */
43
+ findPath(fromX: number, fromZ: number, toX: number, toZ: number, extent: number, out: Float64Array): number;
44
+ /** The search itself: how many portals the path crosses, or −1 for no route. */
45
+ private search_;
46
+ /**
47
+ * The portals the path crosses, as the funnel wants them: a left end and a right end **for the
48
+ * direction of travel**, in metres.
49
+ *
50
+ * Swapping the two is a legal path and a visibly worse one — the funnel tightens the wrong way
51
+ * and bulges away from every corner instead of hugging it. Measured on three maps rather than
52
+ * reasoned about, because the convention depends on the winding, on which way `z` runs and on the
53
+ * order `buildPolyMesh` emits an edge in: 10.75 against 18.30 on a wall, 8.75 against 12.65 on a
54
+ * corner, 8.60 against 24.70 on a zigzag.
55
+ */
56
+ private fillCorridor;
57
+ /** The polygons a portal path passes through, which is the corridor named the other way. */
58
+ private walkPolygons;
59
+ }