@volter/threejs-runtime 0.5.65

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.
Files changed (70) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +12 -0
  3. package/package.json +62 -0
  4. package/src/adapter/body-marks.ts +69 -0
  5. package/src/adapter/constraint.ts +78 -0
  6. package/src/adapter/first-party-navigation-system.ts +48 -0
  7. package/src/adapter/hierarchy-marks.ts +156 -0
  8. package/src/adapter/ingest/scene-capture.ts +865 -0
  9. package/src/adapter/ingest/structural-ids.ts +139 -0
  10. package/src/adapter/ingest/visible-capture-window.ts +302 -0
  11. package/src/adapter/object3d-authoring-subject.ts +50 -0
  12. package/src/adapter/rapier-physics-adapter.ts +226 -0
  13. package/src/adapter/reflection-probe.ts +75 -0
  14. package/src/adapter/renderer-config.ts +116 -0
  15. package/src/adapter/trigger-volume.ts +29 -0
  16. package/src/ai/navigation.ts +627 -0
  17. package/src/animation/animation-clock.ts +479 -0
  18. package/src/animation/runtime-inspection.ts +45 -0
  19. package/src/animation/xstate-animation-binding.ts +655 -0
  20. package/src/animation/xstate-animation-meta.ts +360 -0
  21. package/src/asset-formats/camera.ts +18 -0
  22. package/src/asset-formats/collider.ts +66 -0
  23. package/src/asset-formats/index.ts +45 -0
  24. package/src/asset-formats/light.ts +50 -0
  25. package/src/asset-formats/material.ts +93 -0
  26. package/src/asset-formats/mesh.ts +78 -0
  27. package/src/asset-formats/particles.ts +426 -0
  28. package/src/asset-formats/render-env.ts +318 -0
  29. package/src/asset-formats/tuples.ts +21 -0
  30. package/src/asset-loaders.ts +242 -0
  31. package/src/asset-parse-error.ts +29 -0
  32. package/src/assets.ts +68 -0
  33. package/src/behavior/xstate-inspection.ts +51 -0
  34. package/src/defaults.ts +135 -0
  35. package/src/ecs/object-marks.ts +75 -0
  36. package/src/ecs/scene-index.ts +439 -0
  37. package/src/ecs/scene-query.ts +43 -0
  38. package/src/ecs/user-data.ts +272 -0
  39. package/src/loader.ts +134 -0
  40. package/src/physics/collider-dimensions.ts +121 -0
  41. package/src/physics/collision-system.ts +76 -0
  42. package/src/physics/physics-registry.ts +83 -0
  43. package/src/physics/transform-writer.ts +41 -0
  44. package/src/render/directional-shadow-fit.ts +158 -0
  45. package/src/render/environment-capture.ts +133 -0
  46. package/src/render/ibl-override-material.ts +202 -0
  47. package/src/render/light-camera-factory.ts +103 -0
  48. package/src/render/matcap-texture.ts +92 -0
  49. package/src/render/material-factory.ts +201 -0
  50. package/src/render/mesh-shadow.ts +18 -0
  51. package/src/render/particles-factory.ts +589 -0
  52. package/src/render/render-batch-system.ts +260 -0
  53. package/src/render/render-features.ts +128 -0
  54. package/src/render/render-settings.ts +71 -0
  55. package/src/render/soft-particle-depth.ts +360 -0
  56. package/src/render/spark-renderer-lifecycle.ts +64 -0
  57. package/src/render/structural-signature.ts +231 -0
  58. package/src/render/viewport-shading.ts +163 -0
  59. package/src/render/voxel-surface.ts +358 -0
  60. package/src/setup/setup-particles.ts +32 -0
  61. package/src/setup/setup-physics.ts +67 -0
  62. package/src/setup/setup-renderer.ts +524 -0
  63. package/src/types-n8ao.d.ts +37 -0
  64. package/src/types-realism-effects.d.ts +61 -0
  65. package/vendor/realism-effects/LICENSE.md +21 -0
  66. package/vendor/realism-effects/UPSTREAM.md +19 -0
  67. package/vendor/realism-effects/dist/index.cjs +3447 -0
  68. package/vendor/realism-effects/dist/index.d.ts +59 -0
  69. package/vendor/realism-effects/dist/index.js +3434 -0
  70. package/vendor/realism-effects/package.json +23 -0
@@ -0,0 +1,627 @@
1
+ /**
2
+ * NavMeshManager — wraps recast-navigation for navmesh generation + crowd pathfinding.
3
+ *
4
+ * Usage:
5
+ * await init(); // WASM init (call once per app)
6
+ * const nav = new NavMeshManager();
7
+ * await nav.buildFromMeshes(meshes);
8
+ * const path = nav.findPath(start, end);
9
+ * const crowd = nav.createCrowd(10);
10
+ * // In game loop: nav.updateCrowd(dt);
11
+ */
12
+
13
+ import { NavMeshHelper, threeToSoloNavMesh, threeToTiledNavMesh } from '@recast-navigation/three';
14
+ import type { CrowdAgent, NavMesh } from 'recast-navigation';
15
+ import { Crowd, exportNavMesh, importNavMesh, init, NavMeshQuery } from 'recast-navigation';
16
+ import type { BufferGeometry, Scene } from 'three';
17
+ import { InstancedMesh, Matrix4, Mesh } from 'three';
18
+ import { DEFAULTS } from '../defaults';
19
+
20
+ export type { CrowdAgent };
21
+
22
+ export interface Vector3Like {
23
+ x: number;
24
+ y: number;
25
+ z: number;
26
+ }
27
+
28
+ /** One crowd agent's live state — a plain-data snapshot safe to hand across
29
+ * the adapter seam (no WASM handles), for editor debug draw. */
30
+ export interface CrowdAgentSnapshot {
31
+ position: Vector3Like;
32
+ velocity: Vector3Like;
33
+ radius: number;
34
+ height: number;
35
+ /** Detour's own agent state. `invalid` is the crowd's expressible FAILURE —
36
+ * the agent is not on the navmesh (a bad spawn or teleport) and will not
37
+ * move until re-placed; `offmesh` is an off-mesh connection traversal. */
38
+ state: 'invalid' | 'walking' | 'offmesh';
39
+ /** The agent's current move target — detour's own answer, reported as the
40
+ * fact it is. Whether a request is ACTIVE is what {@link corners} says. */
41
+ target: Vector3Like;
42
+ /** The local path-corridor corners toward the target — the REQUESTED PATH
43
+ * the crowd is steering along right now (detour keeps a local corridor, not
44
+ * the whole route). Empty when the agent has nowhere to go. */
45
+ corners: Vector3Like[];
46
+ }
47
+
48
+ /** Detour `CrowdAgentState` values, named. */
49
+ const AGENT_STATES = ['invalid', 'walking', 'offmesh'] as const;
50
+
51
+ export interface NavMeshBuildParams {
52
+ /** Force bounded tiled generation for imported large-world scenes whose instanced render
53
+ * census cannot be inferred from the source BufferGeometry counts alone. */
54
+ tiled?: boolean | undefined;
55
+ /** Recast tile width/height in voxels. Unity's NavMeshSurface default is 256; keeping tiles at
56
+ * that scale also avoids thousands of empty-tile allocations across sparse imported worlds. */
57
+ tileSize?: number | undefined;
58
+ cellSize?: number | undefined;
59
+ cellHeight?: number | undefined;
60
+ walkableSlopeAngle?: number | undefined;
61
+ walkableHeight?: number | undefined;
62
+ walkableClimb?: number | undefined;
63
+ walkableRadius?: number | undefined;
64
+ maxEdgeLen?: number | undefined;
65
+ maxSimplificationError?: number | undefined;
66
+ minRegionArea?: number | undefined;
67
+ mergeRegionArea?: number | undefined;
68
+ }
69
+
70
+ /** One authored Detour polygon. Vertex order is clockwise in the engine's XZ world basis. */
71
+ export interface NavMeshPolygon {
72
+ readonly vertices: readonly number[];
73
+ readonly flags?: number | undefined;
74
+ readonly area?: number | undefined;
75
+ }
76
+
77
+ /** Detail-row offsets/counts for the polygon at the same index. */
78
+ export interface NavMeshDetailMesh {
79
+ readonly vertexBase: number;
80
+ readonly triangleBase: number;
81
+ readonly vertexCount: number;
82
+ readonly triangleCount: number;
83
+ }
84
+
85
+ /**
86
+ * An already-authored polygon mesh, before Detour's ABI-specific tile serialization.
87
+ *
88
+ * This is the direct-data sibling of {@link buildFromMeshes}: use it when a source ecosystem has
89
+ * already baked navigation. Re-rasterizing an editor bake is lossy—the polygon borders and detail
90
+ * heights are the output, not another input mesh.
91
+ */
92
+ export interface NavMeshPolygonData {
93
+ readonly vertices: readonly (readonly [number, number, number])[];
94
+ readonly polygons: readonly NavMeshPolygon[];
95
+ readonly detailMeshes: readonly NavMeshDetailMesh[];
96
+ readonly detailVertices: readonly (readonly [number, number, number])[];
97
+ /** Three local vertex indices plus Detour edge flags; all four values are uint8. */
98
+ readonly detailTriangles: readonly (readonly [number, number, number, number])[];
99
+ }
100
+
101
+ let wasmInitialized = false;
102
+
103
+ export async function initNavigation(): Promise<void> {
104
+ if (wasmInitialized) return;
105
+ await init();
106
+ wasmInitialized = true;
107
+ }
108
+
109
+ /**
110
+ * `@recast-navigation/three` applies each mesh's world matrix to its positions but copies the
111
+ * source indices unchanged. A mirrored world matrix therefore reverses every triangle and can
112
+ * make a visibly upward floor entirely unwalkable. Seat a winding-corrected geometry on an
113
+ * equivalent root mesh for the bake; the game keeps its own native mesh untouched.
114
+ */
115
+ function reverseTriangleWinding(geometry: BufferGeometry): void {
116
+ const index = geometry.getIndex();
117
+ if (index === null) {
118
+ const position = geometry.getAttribute('position').clone();
119
+ for (let vertex = 0; vertex + 2 < position.count; vertex += 3) {
120
+ for (let component = 0; component < position.itemSize; component += 1) {
121
+ const middle = position.getComponent(vertex + 1, component);
122
+ position.setComponent(vertex + 1, component, position.getComponent(vertex + 2, component));
123
+ position.setComponent(vertex + 2, component, middle);
124
+ }
125
+ }
126
+ geometry.setAttribute('position', position);
127
+ return;
128
+ }
129
+ for (let triangle = 0; triangle + 2 < index.count; triangle += 3) {
130
+ const middle = index.getX(triangle + 1);
131
+ index.setX(triangle + 1, index.getX(triangle + 2));
132
+ index.setX(triangle + 2, middle);
133
+ }
134
+ index.needsUpdate = true;
135
+ }
136
+
137
+ /** Signed world-up area across a geometry's triangles. Closed obstacle meshes cancel to zero;
138
+ * broad walkable surfaces retain the winding Recast uses for its slope test. */
139
+ function upwardTriangleArea(geometry: BufferGeometry): number {
140
+ const position = geometry.getAttribute('position');
141
+ const index = geometry.getIndex();
142
+ const vertexAt = (offset: number): number => index?.getX(offset) ?? offset;
143
+ let area = 0;
144
+ const count = index?.count ?? position.count;
145
+ for (let triangle = 0; triangle + 2 < count; triangle += 3) {
146
+ const a = vertexAt(triangle);
147
+ const b = vertexAt(triangle + 1);
148
+ const c = vertexAt(triangle + 2);
149
+ const ux = position.getX(b) - position.getX(a);
150
+ const uz = position.getZ(b) - position.getZ(a);
151
+ const vx = position.getX(c) - position.getX(a);
152
+ const vz = position.getZ(c) - position.getZ(a);
153
+ area += uz * vx - ux * vz;
154
+ }
155
+ return area;
156
+ }
157
+
158
+ function recastInputMeshes(meshes: Mesh[]): {
159
+ readonly meshes: Mesh[];
160
+ readonly temporaryGeometries: BufferGeometry[];
161
+ } {
162
+ const temporaryGeometries: BufferGeometry[] = [];
163
+ const proxyAt = (geometry: BufferGeometry, matrix: Matrix4): Mesh => {
164
+ if (matrix.determinant() < 0) {
165
+ // Bake the complete mirrored transform into this clone. Passing a negative-determinant
166
+ // matrix through `@recast-navigation/three` after reversing the source indices still lets
167
+ // its geometry merge recalculate the triangle orientation from that matrix. An identity
168
+ // proxy over already-world-space vertices has exactly one winding decision.
169
+ const transformed = geometry.clone().applyMatrix4(matrix);
170
+ // A negative determinant alone is insufficient: reflecting across a plane's normal axis
171
+ // leaves every vertex in place, while reflecting an in-plane axis reverses its triangles.
172
+ // Inspect the transformed walkable area instead of guessing from the 3D transform.
173
+ if (upwardTriangleArea(transformed) < 0) reverseTriangleWinding(transformed);
174
+ temporaryGeometries.push(transformed);
175
+ return new Mesh(transformed);
176
+ }
177
+ const proxy = new Mesh(geometry);
178
+ // The proxy has no parent, so its local matrix is the source mesh's complete world matrix.
179
+ // The recast bridge calls updateMatrixWorld() itself; disabling auto-update preserves it.
180
+ proxy.matrixAutoUpdate = false;
181
+ proxy.matrix.copy(matrix);
182
+ proxy.matrixWorldNeedsUpdate = true;
183
+ return proxy;
184
+ };
185
+ const corrected: Mesh[] = [];
186
+ const instanceMatrix = new Matrix4();
187
+ const worldMatrix = new Matrix4();
188
+ for (const mesh of meshes) {
189
+ mesh.updateWorldMatrix(true, false);
190
+ if (mesh instanceof InstancedMesh) {
191
+ for (let instance = 0; instance < mesh.count; instance += 1) {
192
+ mesh.getMatrixAt(instance, instanceMatrix);
193
+ worldMatrix.multiplyMatrices(mesh.matrixWorld, instanceMatrix);
194
+ corrected.push(proxyAt(mesh.geometry, worldMatrix));
195
+ }
196
+ continue;
197
+ }
198
+ corrected.push(
199
+ mesh.matrixWorld.determinant() >= 0 ? mesh : proxyAt(mesh.geometry, mesh.matrixWorld),
200
+ );
201
+ }
202
+ return { meshes: corrected, temporaryGeometries };
203
+ }
204
+
205
+ const NAV_MESH_SET_MAGIC = 0x4d53_4554;
206
+ const NAV_MESH_SET_VERSION = 1;
207
+ const DETOUR_NAV_MESH_MAGIC = 0x444e_4156;
208
+ const DETOUR_NAV_MESH_VERSION = 7;
209
+
210
+ function nextPowerOfTwo(value: number): number {
211
+ let result = 1;
212
+ while (result < value) result *= 2;
213
+ return result;
214
+ }
215
+
216
+ /** Serialize portable polygon/detail arrays as one ordinary Detour tile. */
217
+ function polygonNavMeshData(data: NavMeshPolygonData, params?: NavMeshBuildParams): Uint8Array {
218
+ const vertexCount = data.vertices.length;
219
+ const polygonCount = data.polygons.length;
220
+ if (vertexCount === 0 || polygonCount === 0) {
221
+ throw new Error('NavMeshManager.buildFromPolygons: vertices and polygons must be non-empty');
222
+ }
223
+ if (vertexCount > 0xffff) {
224
+ throw new Error(
225
+ `NavMeshManager.buildFromPolygons: ${vertexCount} vertices exceed Detour's uint16 tile index`,
226
+ );
227
+ }
228
+ if (data.detailMeshes.length !== polygonCount) {
229
+ throw new Error(
230
+ `NavMeshManager.buildFromPolygons: ${polygonCount} polygons require the same number of detail rows`,
231
+ );
232
+ }
233
+
234
+ const neighbours = data.polygons.map((polygon) => polygon.vertices.map(() => 0));
235
+ const edges = new Map<string, readonly [number, number]>();
236
+ let maxLinkCount = 0;
237
+ for (let polygonIndex = 0; polygonIndex < polygonCount; polygonIndex += 1) {
238
+ const polygon = data.polygons[polygonIndex] as NavMeshPolygon;
239
+ if (polygon.vertices.length < 3 || polygon.vertices.length > 6) {
240
+ throw new Error(
241
+ `NavMeshManager.buildFromPolygons: polygon ${polygonIndex} has ` +
242
+ `${polygon.vertices.length} vertices; Detour permits 3..6`,
243
+ );
244
+ }
245
+ maxLinkCount += polygon.vertices.length;
246
+ for (let edgeIndex = 0; edgeIndex < polygon.vertices.length; edgeIndex += 1) {
247
+ const from = polygon.vertices[edgeIndex] as number;
248
+ const to = polygon.vertices[(edgeIndex + 1) % polygon.vertices.length] as number;
249
+ if (
250
+ !Number.isInteger(from) ||
251
+ !Number.isInteger(to) ||
252
+ from < 0 ||
253
+ to < 0 ||
254
+ from >= vertexCount ||
255
+ to >= vertexCount
256
+ ) {
257
+ throw new Error(
258
+ `NavMeshManager.buildFromPolygons: polygon ${polygonIndex} references an invalid vertex`,
259
+ );
260
+ }
261
+ const directed = `${from},${to}`;
262
+ if (edges.has(directed)) {
263
+ throw new Error(
264
+ `NavMeshManager.buildFromPolygons: directed edge ${directed} occurs more than once`,
265
+ );
266
+ }
267
+ const reverse = edges.get(`${to},${from}`);
268
+ if (reverse === undefined) {
269
+ edges.set(directed, [polygonIndex, edgeIndex]);
270
+ } else {
271
+ const [otherPolygon, otherEdge] = reverse;
272
+ neighbours[polygonIndex]![edgeIndex] = otherPolygon + 1;
273
+ neighbours[otherPolygon]![otherEdge] = polygonIndex + 1;
274
+ }
275
+ }
276
+ }
277
+
278
+ for (let polygonIndex = 0; polygonIndex < polygonCount; polygonIndex += 1) {
279
+ const row = data.detailMeshes[polygonIndex] as NavMeshDetailMesh;
280
+ if (
281
+ row.vertexBase < 0 ||
282
+ row.vertexCount < 0 ||
283
+ row.vertexBase + row.vertexCount > data.detailVertices.length ||
284
+ row.triangleBase < 0 ||
285
+ row.triangleCount < 0 ||
286
+ row.triangleBase + row.triangleCount > data.detailTriangles.length ||
287
+ row.vertexCount > 0xff ||
288
+ row.triangleCount > 0xff
289
+ ) {
290
+ throw new Error(
291
+ `NavMeshManager.buildFromPolygons: polygon ${polygonIndex} has an invalid detail row`,
292
+ );
293
+ }
294
+ }
295
+ for (const triangle of data.detailTriangles) {
296
+ if (triangle.some((value) => !Number.isInteger(value) || value < 0 || value > 0xff)) {
297
+ throw new Error(
298
+ 'NavMeshManager.buildFromPolygons: detail triangles must contain uint8 values',
299
+ );
300
+ }
301
+ }
302
+
303
+ const boundsMin = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
304
+ const boundsMax = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
305
+ for (const vertex of [...data.vertices, ...data.detailVertices]) {
306
+ for (let component = 0; component < 3; component += 1) {
307
+ const value = vertex[component] as number;
308
+ if (!Number.isFinite(value)) {
309
+ throw new Error('NavMeshManager.buildFromPolygons: every vertex component must be finite');
310
+ }
311
+ boundsMin[component] = Math.min(boundsMin[component] as number, value);
312
+ boundsMax[component] = Math.max(boundsMax[component] as number, value);
313
+ }
314
+ }
315
+
316
+ // Detour's in-memory structs are emitted in its stable 32-bit serialized layout. `importNavMesh`
317
+ // owns the ABI boundary and recreates live WASM objects from these ordinary bytes.
318
+ const headerBytes = 100;
319
+ const polygonBytes = 32;
320
+ const linkBytes = 12;
321
+ const detailMeshBytes = 12;
322
+ const detailTriangleBytes = 4;
323
+ const tileBytes =
324
+ headerBytes +
325
+ vertexCount * 12 +
326
+ polygonCount * polygonBytes +
327
+ maxLinkCount * linkBytes +
328
+ polygonCount * detailMeshBytes +
329
+ data.detailVertices.length * 12 +
330
+ data.detailTriangles.length * detailTriangleBytes;
331
+ const bytes = new Uint8Array(48 + tileBytes);
332
+ const view = new DataView(bytes.buffer);
333
+ let offset = 0;
334
+ const uint8 = (value: number): void => {
335
+ view.setUint8(offset, value);
336
+ offset += 1;
337
+ };
338
+ const uint16 = (value: number): void => {
339
+ view.setUint16(offset, value, true);
340
+ offset += 2;
341
+ };
342
+ const uint32 = (value: number): void => {
343
+ view.setUint32(offset, value, true);
344
+ offset += 4;
345
+ };
346
+ const int32 = (value: number): void => {
347
+ view.setInt32(offset, value, true);
348
+ offset += 4;
349
+ };
350
+ const float32 = (value: number): void => {
351
+ view.setFloat32(offset, value, true);
352
+ offset += 4;
353
+ };
354
+
355
+ const maxPolygons = nextPowerOfTwo(polygonCount);
356
+ uint32(NAV_MESH_SET_MAGIC);
357
+ uint32(NAV_MESH_SET_VERSION);
358
+ uint32(1);
359
+ for (const value of boundsMin) float32(value);
360
+ float32(Math.max((boundsMax[0] as number) - (boundsMin[0] as number), Number.EPSILON));
361
+ float32(Math.max((boundsMax[2] as number) - (boundsMin[2] as number), Number.EPSILON));
362
+ uint32(1);
363
+ uint32(maxPolygons);
364
+ // With one tile, Detour's first valid salt occupies the bits above maxPolygons.
365
+ uint32(maxPolygons);
366
+ uint32(tileBytes);
367
+
368
+ uint32(DETOUR_NAV_MESH_MAGIC);
369
+ uint32(DETOUR_NAV_MESH_VERSION);
370
+ int32(0);
371
+ int32(0);
372
+ int32(0);
373
+ uint32(0);
374
+ uint32(polygonCount);
375
+ uint32(vertexCount);
376
+ uint32(maxLinkCount);
377
+ uint32(polygonCount);
378
+ uint32(data.detailVertices.length);
379
+ uint32(data.detailTriangles.length);
380
+ uint32(0);
381
+ uint32(0);
382
+ uint32(polygonCount);
383
+ const defaults = DEFAULTS.navigation;
384
+ float32(params?.walkableHeight ?? defaults.walkableHeight);
385
+ float32(params?.walkableRadius ?? defaults.walkableRadius);
386
+ float32(params?.walkableClimb ?? defaults.walkableClimb);
387
+ for (const value of boundsMin) float32(value);
388
+ for (const value of boundsMax) float32(value);
389
+ float32(1 / (params?.cellSize ?? defaults.cellSize));
390
+
391
+ for (const vertex of data.vertices) for (const value of vertex) float32(value);
392
+ for (let polygonIndex = 0; polygonIndex < polygonCount; polygonIndex += 1) {
393
+ const polygon = data.polygons[polygonIndex] as NavMeshPolygon;
394
+ uint32(0xffff_ffff);
395
+ for (let index = 0; index < 6; index += 1) uint16(polygon.vertices[index] ?? 0);
396
+ for (let index = 0; index < 6; index += 1) {
397
+ uint16(neighbours[polygonIndex]?.[index] ?? 0);
398
+ }
399
+ uint16(polygon.flags ?? 1);
400
+ uint8(polygon.vertices.length);
401
+ uint8((polygon.area ?? 0) & 0x3f);
402
+ }
403
+ // `dtNavMesh::addTile` turns this zeroed capacity into its free-list, then populates the
404
+ // internal links from the polygon neighbour indices above.
405
+ offset += maxLinkCount * linkBytes;
406
+ for (const row of data.detailMeshes) {
407
+ uint32(row.vertexBase);
408
+ uint32(row.triangleBase);
409
+ uint8(row.vertexCount);
410
+ uint8(row.triangleCount);
411
+ uint16(0);
412
+ }
413
+ for (const vertex of data.detailVertices) for (const value of vertex) float32(value);
414
+ for (const triangle of data.detailTriangles) for (const value of triangle) uint8(value);
415
+ if (offset !== bytes.length) {
416
+ throw new Error(
417
+ `NavMeshManager.buildFromPolygons: encoded ${offset} bytes into a ${bytes.length}-byte allocation`,
418
+ );
419
+ }
420
+ return bytes;
421
+ }
422
+
423
+ export class NavMeshManager {
424
+ private navMesh: NavMesh | null = null;
425
+ private query: NavMeshQuery | null = null;
426
+ private crowd: Crowd | null = null;
427
+ private helper: NavMeshHelper | null = null;
428
+
429
+ /** Build navmesh from Three.js Mesh objects (grounds, obstacles, etc.). */
430
+ buildFromMeshes(meshes: Mesh[], params?: NavMeshBuildParams): boolean {
431
+ const d = DEFAULTS.navigation;
432
+ const input = recastInputMeshes(meshes);
433
+ const triangleCount = input.meshes.reduce((total, mesh) => {
434
+ const index = mesh.geometry.getIndex();
435
+ const vertices = mesh.geometry.getAttribute('position').count;
436
+ return total + Math.floor((index?.count ?? vertices) / 3);
437
+ }, 0);
438
+ const config = {
439
+ cs: params?.cellSize ?? d.cellSize,
440
+ ch: params?.cellHeight ?? d.cellHeight,
441
+ walkableSlopeAngle: params?.walkableSlopeAngle ?? d.walkableSlopeAngle,
442
+ walkableHeight: params?.walkableHeight ?? d.walkableHeight,
443
+ walkableClimb: params?.walkableClimb ?? d.walkableClimb,
444
+ walkableRadius: params?.walkableRadius ?? d.walkableRadius,
445
+ maxEdgeLen: params?.maxEdgeLen ?? d.maxEdgeLen,
446
+ maxSimplificationError: params?.maxSimplificationError ?? d.maxSimplificationError,
447
+ minRegionArea: params?.minRegionArea ?? d.minRegionArea,
448
+ mergeRegionArea: params?.mergeRegionArea ?? d.mergeRegionArea,
449
+ // Recast tuning knobs with no DEFAULTS counterpart (not modeled there).
450
+ maxVertsPerPoly: 6,
451
+ detailSampleDist: 6,
452
+ detailSampleMaxError: 1,
453
+ };
454
+ // A solo heightfield allocates for the whole scene at once. Large imported Unity scenes can
455
+ // exceed Recast's WASM address space during rasterization even though every individual mesh
456
+ // is ordinary-sized. Tiled generation is the library's native large-world path and keeps the
457
+ // same NavMesh result/API while bounding each rasterization allocation.
458
+ let result: ReturnType<typeof threeToSoloNavMesh> | ReturnType<typeof threeToTiledNavMesh>;
459
+ try {
460
+ result =
461
+ params?.tiled === true || input.meshes.length > 256 || triangleCount > 10_000
462
+ ? threeToTiledNavMesh(input.meshes, {
463
+ ...config,
464
+ tileSize: params?.tileSize ?? 256,
465
+ })
466
+ : threeToSoloNavMesh(input.meshes, config);
467
+ } finally {
468
+ for (const geometry of input.temporaryGeometries) geometry.dispose();
469
+ }
470
+
471
+ if (!result.success || !result.navMesh) return false;
472
+
473
+ // Free the previous recast objects before replacing them (WASM heap leak).
474
+ this.destroyRecast();
475
+ this.navMesh = result.navMesh;
476
+ this.query = new NavMeshQuery(this.navMesh);
477
+ return true;
478
+ }
479
+
480
+ /** Load an authored polygon/detail mesh directly, without a second rasterization. */
481
+ buildFromPolygons(data: NavMeshPolygonData, params?: NavMeshBuildParams): boolean {
482
+ try {
483
+ this.loadFromData(polygonNavMeshData(data, params));
484
+ return true;
485
+ } catch {
486
+ return false;
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Destroy all live recast handles (crowd, query, navmesh) and dispose the
492
+ * debug helper. Recast objects live on the WASM heap and are NOT GC'd —
493
+ * they must be explicitly `.destroy()`ed or they leak.
494
+ */
495
+ private destroyRecast(): void {
496
+ if (this.helper) {
497
+ this.helper.parent?.remove(this.helper);
498
+ (this.helper as unknown as { dispose?: () => void }).dispose?.();
499
+ this.helper = null;
500
+ }
501
+ if (this.crowd) {
502
+ this.crowd.destroy();
503
+ this.crowd = null;
504
+ }
505
+ if (this.query) {
506
+ this.query.destroy();
507
+ this.query = null;
508
+ }
509
+ if (this.navMesh) {
510
+ this.navMesh.destroy();
511
+ this.navMesh = null;
512
+ }
513
+ }
514
+
515
+ /** True once a navmesh has been built (for the NavigationAdapter to gate on). */
516
+ hasNavMesh(): boolean {
517
+ return this.navMesh !== null;
518
+ }
519
+
520
+ /**
521
+ * Find a straight path between two world positions.
522
+ *
523
+ * THREE OUTCOMES, and only one of them is an empty array:
524
+ *
525
+ * - **No pathfinder.** No navmesh has been built, so there is nothing to
526
+ * query. THROWS, the same way {@link createCrowd} does — `[]` here would
527
+ * say "I searched and there is no route between these points", which is a
528
+ * claim about the level rather than about this manager's state. Callers
529
+ * that can be in this position gate on {@link hasNavMesh} first.
530
+ * - **The query failed.** recast answered `success: false` WITH an error
531
+ * (bad half-extents, no polygon near an endpoint, a status code). THROWS,
532
+ * naming recast's own error — a failed search is not a searched-and-empty
533
+ * one, and the editor's probe already surfaces this separately from "no
534
+ * path found".
535
+ * - **Genuinely no route.** The query ran and found nothing. `[]`.
536
+ */
537
+ findPath(start: Vector3Like, end: Vector3Like): Vector3Like[] {
538
+ if (!this.query) {
539
+ throw new Error(
540
+ 'NavMeshManager.findPath: no navmesh has been built, so no path can be queried. ' +
541
+ 'Build one first (buildFromMeshes/importNavMesh) — gate on hasNavMesh() if the ' +
542
+ 'caller can run before a bake.',
543
+ );
544
+ }
545
+ const result = this.query.computePath(start, end);
546
+ if (!result.success && result.error) {
547
+ throw new Error(
548
+ `NavMeshManager.findPath: recast could not complete the query (${result.error.name}` +
549
+ `${result.error.status === undefined ? '' : `, status ${result.error.status}`}).`,
550
+ );
551
+ }
552
+ return result.path;
553
+ }
554
+
555
+ /** Create a crowd with up to maxAgents agents. */
556
+ createCrowd(maxAgents: number): Crowd {
557
+ if (!this.navMesh) throw new Error('Build navmesh first');
558
+ this.crowd = new Crowd(this.navMesh, { maxAgents, maxAgentRadius: 0.6 });
559
+ return this.crowd;
560
+ }
561
+
562
+ /** Step the crowd simulation. Call once per frame. */
563
+ updateCrowd(dt: number): void {
564
+ this.crowd?.update(dt);
565
+ }
566
+
567
+ /**
568
+ * Plain-data snapshots of every active crowd agent (interpolated position,
569
+ * velocity, radius, height) — the introspection feed for the editor's
570
+ * play-mode crowd debug draw. Empty when no crowd exists.
571
+ */
572
+ getCrowdAgents(): CrowdAgentSnapshot[] {
573
+ if (!this.crowd) return [];
574
+ return this.crowd.getAgents().map((a) => {
575
+ const p = a.interpolatedPosition;
576
+ const v = a.velocity();
577
+ const t = a.target();
578
+ // An invalid agent has no corridor, and detour's accessors are not
579
+ // guaranteed meaningful off the mesh — report the failure state with an
580
+ // empty path rather than reading through it.
581
+ const state = AGENT_STATES[a.state()] ?? 'invalid';
582
+ const corners =
583
+ state === 'invalid' ? [] : a.corners().map((c) => ({ x: c.x, y: c.y, z: c.z }));
584
+ return {
585
+ position: { x: p.x, y: p.y, z: p.z },
586
+ velocity: { x: v.x, y: v.y, z: v.z },
587
+ radius: a.radius,
588
+ height: a.height,
589
+ state,
590
+ target: { x: t.x, y: t.y, z: t.z },
591
+ corners,
592
+ };
593
+ });
594
+ }
595
+
596
+ /** Get or create the wireframe debug mesh. Add to scene once, call update() after rebuilding. */
597
+ getDebugMesh(scene: Scene): NavMeshHelper | null {
598
+ if (!this.navMesh) return null;
599
+ if (!this.helper) {
600
+ this.helper = new NavMeshHelper(this.navMesh);
601
+ scene.add(this.helper);
602
+ }
603
+ return this.helper;
604
+ }
605
+
606
+ /** Serialize the current navmesh to a binary blob. */
607
+ exportData(): Uint8Array {
608
+ if (!this.navMesh) throw new Error('No navmesh to export');
609
+ return exportNavMesh(this.navMesh);
610
+ }
611
+
612
+ /** Load a navmesh from a previously exported binary blob. */
613
+ loadFromData(data: Uint8Array): void {
614
+ const { navMesh } = importNavMesh(data);
615
+ // Free the previous recast objects before replacing them.
616
+ this.destroyRecast();
617
+ this.navMesh = navMesh;
618
+ this.query = new NavMeshQuery(this.navMesh);
619
+ }
620
+
621
+ dispose(scene: Scene): void {
622
+ // Ensure the helper is removed from the passed scene as well, then destroy
623
+ // every recast handle (crowd/query/navmesh) so nothing leaks on the WASM heap.
624
+ if (this.helper) scene.remove(this.helper);
625
+ this.destroyRecast();
626
+ }
627
+ }