import * as THREE from 'three'; import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js'; import type {GLTF} from 'three/addons/loaders/GLTFLoader.js'; import {SimulatorOptions} from '../../SimulatorOptions'; import {ResolvedSimulatorSceneManifest} from '../../scene/SimulatorEnvironmentManifest'; const DEFAULT_ZONE_ID = 'simulator'; const RANDOM_PATH_SAMPLE_ATTEMPTS = 8; const PATHFINDING_MODULE_SPECIFIER = 'three-pathfinding'; interface PathfindingNode { vertexIds: number[]; } type PathfindingZone = { groups: PathfindingNode[][]; vertices: THREE.Vector3[]; }; interface PathfindingInstance { setZoneData(zoneId: string, zone: PathfindingZone): void; getGroup(zoneId: string, position: THREE.Vector3): number | null; getClosestNode( position: THREE.Vector3, zoneId: string, groupId: number, checkPolygon?: boolean ): PathfindingNode | null; clampStep( start: THREE.Vector3, end: THREE.Vector3, node: PathfindingNode, zoneId: string, groupId: number, endTarget: THREE.Vector3 ): PathfindingNode; findPath( start: THREE.Vector3, target: THREE.Vector3, zoneId: string, groupId: number ): THREE.Vector3[] | null; } type PathfindingConstructor = { new (): PathfindingInstance; createZone: ( geometry: THREE.BufferGeometry, tolerance?: number ) => PathfindingZone; }; interface PathfindingModule { Pathfinding: PathfindingConstructor; } interface SimulatorNavMeshPath { target: THREE.Vector3; path: THREE.Vector3[]; } interface PreparedSimulatorNavMesh { enabled: boolean; eyeHeight: number; pathfinding?: PathfindingInstance; zone?: PathfindingZone; debugGeometry?: THREE.BufferGeometry; } const desiredGroundPosition = new THREE.Vector3(); const startGroundPosition = new THREE.Vector3(); const clampedGroundPosition = new THREE.Vector3(); const environmentMatrix = new THREE.Matrix4(); const targetWorldPosition = new THREE.Vector3(); const randomTriangleA = new THREE.Vector3(); const randomTriangleB = new THREE.Vector3(); const randomTriangleC = new THREE.Vector3(); const randomTriangleAB = new THREE.Vector3(); const randomTriangleAC = new THREE.Vector3(); export class SimulatorNavMesh { enabled = false; ready = false; readonly debugVisualization = new THREE.Group(); private Pathfinding?: PathfindingConstructor; private pathfinding?: PathfindingInstance; private zone?: PathfindingZone; private zoneId = DEFAULT_ZONE_ID; private groupId: number | null = null; private currentNode: PathfindingNode | null = null; private eyeHeight = 1.5; private debugVisualizationVisible = false; constructor() { this.debugVisualization.name = 'Simulator Navmesh Visualization'; this.debugVisualization.raycast = () => {}; } get constrained() { return this.enabled && this.ready; } get debugVisualizationsVisible() { return this.debugVisualizationVisible; } showDebugVisualizations(visible = true) { this.debugVisualizationVisible = visible; this.debugVisualization.visible = visible; } async prepareEnvironment( manifest: ResolvedSimulatorSceneManifest, options: SimulatorOptions ): Promise { const prepared: PreparedSimulatorNavMesh = { enabled: options.navMesh.enabled, eyeHeight: options.navMesh.eyeHeight, }; const shouldLoad = prepared.enabled || options.navMesh.showDebugVisualizations; if (!shouldLoad) return prepared; if (!manifest.navMeshPath) { console.warn( 'SimulatorNavMesh: navmesh is enabled or visualized, but the active environment has no navMeshPath.' ); return prepared; } try { environmentMatrix.compose( new THREE.Vector3().fromArray(manifest.position ?? [0, 0, 0]), new THREE.Quaternion().fromArray(manifest.quaternion ?? [0, 0, 0, 1]), new THREE.Vector3().fromArray(manifest.scale ?? [1, 1, 1]) ); const geometry = await this.loadGeometry( manifest.navMeshPath, environmentMatrix ); try { prepared.debugGeometry = geometry.clone(); if (prepared.enabled) { const Pathfinding = await this.loadPathfinding(); const zone = Pathfinding.createZone(geometry) as PathfindingZone; const pathfinding = new Pathfinding(); pathfinding.setZoneData(this.zoneId, zone); prepared.zone = zone; prepared.pathfinding = pathfinding; } } finally { geometry.dispose(); } } catch (error) { prepared.debugGeometry?.dispose(); throw new Error( `SimulatorNavMesh: failed to load navmesh at ${manifest.navMeshPath}.`, {cause: error} ); } return prepared; } commitEnvironment(prepared: PreparedSimulatorNavMesh) { this.enabled = prepared.enabled; this.eyeHeight = prepared.eyeHeight; this.pathfinding = prepared.pathfinding; this.zone = prepared.zone; this.ready = !!prepared.pathfinding; this.groupId = null; this.currentNode = null; this.setDebugGeometry(prepared.debugGeometry); } dispose() { this.setDebugGeometry(); this.pathfinding = undefined; this.zone = undefined; this.ready = false; this.groupId = null; this.currentNode = null; } private setDebugGeometry(geometry?: THREE.BufferGeometry) { for (const child of [...this.debugVisualization.children]) { const mesh = child as THREE.LineSegments; mesh.geometry.dispose(); const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; for (const material of materials) material.dispose(); child.removeFromParent(); } if (!geometry) return; const wireframe = new THREE.LineSegments( new THREE.EdgesGeometry(geometry, 1), new THREE.LineBasicMaterial({ color: 0x00e5ff, transparent: true, opacity: 0.9, depthTest: false, }) ); wireframe.renderOrder = 1000; wireframe.raycast = () => {}; this.debugVisualization.add(wireframe); geometry.dispose(); } async setGeometry(geometry: THREE.BufferGeometry) { const Pathfinding = await this.loadPathfinding(); const zone = Pathfinding.createZone(geometry) as PathfindingZone; this.pathfinding = new Pathfinding(); this.pathfinding.setZoneData(this.zoneId, zone); this.zone = zone; this.ready = true; this.groupId = null; this.currentNode = null; } applyUserMovement( camera: THREE.Camera, desiredCameraPosition: THREE.Vector3 ) { if (!this.constrained || !this.pathfinding) { camera.position.copy(desiredCameraPosition); return; } startGroundPosition.copy(camera.position); startGroundPosition.y -= this.eyeHeight; desiredGroundPosition.copy(desiredCameraPosition); desiredGroundPosition.y -= this.eyeHeight; if (this.groupId === null || this.currentNode === null) { this.groupId = this.pathfinding.getGroup( this.zoneId, startGroundPosition ) as number | null; if (this.groupId === null) { camera.position.copy(desiredCameraPosition); return; } this.currentNode = this.pathfinding.getClosestNode( startGroundPosition, this.zoneId, this.groupId, true ); this.currentNode ??= this.pathfinding.getClosestNode( startGroundPosition, this.zoneId, this.groupId, false ); } if (!this.currentNode || this.groupId === null) { camera.position.copy(desiredCameraPosition); return; } this.currentNode = this.pathfinding.clampStep( startGroundPosition, desiredGroundPosition, this.currentNode, this.zoneId, this.groupId, clampedGroundPosition ); camera.position.set( clampedGroundPosition.x, clampedGroundPosition.y + this.eyeHeight, clampedGroundPosition.z ); } findPathTo( startCameraPosition: THREE.Vector3, targetGroundPosition: THREE.Vector3 ) { if (!this.constrained || !this.pathfinding) return null; const start = startGroundPosition.copy(startCameraPosition); start.y -= this.eyeHeight; const groupId = this.getGroup(start); if (groupId === null) return null; return this.pathfinding.findPath( start, targetGroundPosition, this.zoneId, groupId ); } findRandomPathFrom( startCameraPosition: THREE.Vector3 ): SimulatorNavMeshPath | null { if (!this.constrained || !this.pathfinding || !this.zone) return null; const start = startGroundPosition.copy(startCameraPosition); start.y -= this.eyeHeight; const groupId = this.getGroup(start); if (groupId === null) return null; for (let i = 0; i < RANDOM_PATH_SAMPLE_ATTEMPTS; i++) { const target = this.getRandomPointInGroup(groupId); if (!target) continue; const path = this.pathfinding.findPath( start, target, this.zoneId, groupId ); if (path) return {target, path}; } return null; } isGroundPositionReachable( startCameraPosition: THREE.Vector3, targetGroundPosition: THREE.Vector3 ) { return this.isLocationReachable(startCameraPosition, targetGroundPosition); } isLocationReachable( startCameraPosition: THREE.Vector3, targetGroundPosition: THREE.Vector3 ) { return this.findPathTo(startCameraPosition, targetGroundPosition) !== null; } isObjectReachable( startCameraPosition: THREE.Vector3, object: THREE.Object3D ) { object.getWorldPosition(targetWorldPosition); return this.isGroundPositionReachable( startCameraPosition, targetWorldPosition ); } private getGroup(position: THREE.Vector3) { if (!this.pathfinding) return null; return this.pathfinding.getGroup(this.zoneId, position) as number | null; } private getRandomPointInGroup(groupId: number) { const group = this.zone?.groups[groupId]; if (!group) return null; let totalArea = 0; const areas = group.map((node) => { const area = this.getNodeArea(node); totalArea += area; return totalArea; }); if (totalArea <= 0) return null; const targetArea = Math.random() * totalArea; const nodeIndex = areas.findIndex((area) => area >= targetArea); const node = group[nodeIndex === -1 ? group.length - 1 : nodeIndex]; return this.sampleNode(node); } private getNodeArea(node: PathfindingNode) { if (!node || !this.zone) return 0; const [a, b, c] = node.vertexIds.map((id) => this.zone!.vertices[id]); randomTriangleAB.subVectors(b, a); randomTriangleAC.subVectors(c, a); return randomTriangleAB.cross(randomTriangleAC).length() * 0.5; } private sampleNode(node: PathfindingNode) { if (!node || !this.zone) return null; const [a, b, c] = node.vertexIds.map((id) => this.zone!.vertices[id]); randomTriangleA.copy(a); randomTriangleB.copy(b); randomTriangleC.copy(c); let u = Math.random(); let v = Math.random(); if (u + v > 1) { u = 1 - u; v = 1 - v; } return new THREE.Vector3() .copy(randomTriangleA) .addScaledVector( randomTriangleAB.subVectors(randomTriangleB, randomTriangleA), u ) .addScaledVector( randomTriangleAC.subVectors(randomTriangleC, randomTriangleA), v ); } private async loadGeometry( path: string, transform: THREE.Matrix4 ): Promise { const loader = new GLTFLoader(); const gltf = await loader.loadAsync(path); try { gltf.scene.applyMatrix4(transform); gltf.scene.updateMatrixWorld(true); const navMesh = this.findFirstMesh(gltf.scene); if (!navMesh) { throw new Error('No mesh found in navmesh glTF/GLB.'); } const geometry = navMesh.geometry.clone(); geometry.applyMatrix4(navMesh.matrixWorld); return geometry; } finally { this.disposeGLTFResources(gltf); } } private disposeGLTFResources(gltf: GLTF) { gltf.scene.traverse((object) => { const mesh = object as THREE.Mesh; if (!mesh.isMesh) return; mesh.geometry?.dispose(); const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; for (const material of materials) { this.disposeMaterial(material); } }); } private disposeMaterial(material?: THREE.Material) { if (!material) return; for (const value of Object.values( material as unknown as Record )) { if (value instanceof THREE.Texture) { value.dispose(); } } material.dispose(); } private findFirstMesh(root: THREE.Object3D): THREE.Mesh | null { const queue = [root]; while (queue.length > 0) { const object = queue.shift()!; const mesh = object as THREE.Mesh; if (mesh.isMesh && mesh.geometry) { return mesh; } queue.push(...object.children); } return null; } private async loadPathfinding() { if (!this.Pathfinding) { const module = (await import( PATHFINDING_MODULE_SPECIFIER )) as unknown as PathfindingModule; this.Pathfinding = module.Pathfinding; } return this.Pathfinding; } }