diff --git a/apps/editor/public/items/pascal-truck/model.glb b/apps/editor/public/items/pascal-truck/model.glb new file mode 100644 index 000000000..5e30d916a Binary files /dev/null and b/apps/editor/public/items/pascal-truck/model.glb differ diff --git a/apps/editor/public/items/pascal-truck/thumbnail.png b/apps/editor/public/items/pascal-truck/thumbnail.png new file mode 100644 index 000000000..33d4113c8 Binary files /dev/null and b/apps/editor/public/items/pascal-truck/thumbnail.png differ diff --git a/apps/editor/public/meshes/scifi-shield/mesh_shield_1.obj b/apps/editor/public/meshes/scifi-shield/mesh_shield_1.obj new file mode 100644 index 000000000..913b43055 --- /dev/null +++ b/apps/editor/public/meshes/scifi-shield/mesh_shield_1.obj @@ -0,0 +1,373 @@ +# Blender 4.4.1 +# www.blender.org +o Icosphere +v 0.041858 -0.978633 0.128824 +v -0.109585 -0.978633 0.079618 +v 0.923163 0.175849 -0.293984 +v 0.873592 -0.083717 -0.446551 +v 0.873960 0.092132 -0.440706 +v 0.780548 0.184265 -0.567098 +v 0.785625 -0.167434 -0.570785 +v 0.694654 -0.083717 -0.692838 +v 0.689208 0.092132 -0.694996 +v 0.564871 0.175850 -0.787131 +v 0.005672 0.175850 -0.968826 +v -0.154744 -0.083717 -0.968826 +v -0.149072 0.092132 -0.967371 +v -0.298143 0.184265 -0.917587 +v -0.300079 -0.167435 -0.923556 +v -0.444270 -0.083718 -0.874754 +v -0.448006 0.092132 -0.870241 +v -0.574053 0.175850 -0.780460 +v -0.919656 0.175849 -0.304776 +v -0.969228 -0.083716 -0.152210 +v -0.966090 0.092132 -0.157157 +v -0.964809 0.184264 -0.000000 +v -0.971084 -0.167433 0.000000 +v -0.969228 -0.083716 0.152210 +v -0.966090 0.092132 0.157157 +v -0.919656 0.175849 0.304776 +v -0.574053 0.175850 0.780460 +v -0.444270 -0.083718 0.874754 +v -0.448006 0.092132 0.870241 +v -0.298144 0.184264 0.917587 +v -0.300080 -0.167435 0.923555 +v -0.154744 -0.083717 0.968826 +v -0.149072 0.092132 0.967371 +v 0.005672 0.175849 0.968826 +v 0.564871 0.175849 0.787131 +v 0.694654 -0.083718 0.692838 +v 0.689208 0.092131 0.694997 +v 0.780548 0.184263 0.567099 +v 0.785625 -0.167435 0.570785 +v 0.873591 -0.083718 0.446551 +v 0.873960 0.092131 0.440706 +v 0.923163 0.175848 0.293985 +v 0.444270 0.083717 -0.874754 +v 0.574053 -0.175850 -0.780460 +v 0.300080 0.167435 -0.923555 +v 0.154744 0.083717 -0.968826 +v 0.448006 -0.092133 -0.870241 +v 0.298144 -0.184265 -0.917587 +v 0.149072 -0.092133 -0.967370 +v -0.005672 -0.175850 -0.968826 +v -0.694654 0.083717 -0.692838 +v -0.564870 -0.175850 -0.787132 +v -0.785625 0.167434 -0.570785 +v -0.873591 0.083717 -0.446551 +v -0.689208 -0.092132 -0.694997 +v -0.780547 -0.184265 -0.567099 +v -0.873960 -0.092132 -0.440707 +v -0.923162 -0.175849 -0.293985 +v -0.873591 0.083717 0.446551 +v -0.923162 -0.175849 0.293985 +v -0.785625 0.167434 0.570785 +v -0.694654 0.083717 0.692838 +v -0.873960 -0.092132 0.440707 +v -0.780547 -0.184265 0.567099 +v -0.689208 -0.092132 0.694997 +v -0.564870 -0.175850 0.787132 +v 0.154744 0.083717 0.968826 +v -0.005672 -0.175850 0.968826 +v 0.300080 0.167435 0.923555 +v 0.444270 0.083717 0.874754 +v 0.149072 -0.092133 0.967370 +v 0.298144 -0.184265 0.917587 +v 0.448006 -0.092133 0.870240 +v 0.574053 -0.175850 0.780460 +v 0.969228 0.083716 0.152210 +v 0.919656 -0.175849 0.304776 +v 0.971084 0.167433 -0.000000 +v 0.969228 0.083716 -0.152210 +v 0.966090 -0.092132 0.157157 +v 0.964809 -0.184264 0.000000 +v 0.966090 -0.092132 -0.157157 +v 0.919656 -0.175849 -0.304776 +v 0.087924 -0.943443 0.270597 +v 0.041858 -0.978633 0.128824 +v 0.923163 0.175849 -0.293984 +v 0.923163 0.175849 -0.293984 +v 0.873592 -0.083717 -0.446551 +v 0.873592 -0.083717 -0.446551 +v 0.873960 0.092132 -0.440706 +v 0.873960 0.092132 -0.440706 +v 0.780548 0.184265 -0.567098 +v 0.780548 0.184265 -0.567098 +v 0.785625 -0.167434 -0.570785 +v 0.785625 -0.167434 -0.570785 +v 0.694654 -0.083717 -0.692838 +v 0.694654 -0.083717 -0.692838 +v 0.689208 0.092132 -0.694996 +v 0.689208 0.092132 -0.694996 +v 0.564871 0.175850 -0.787131 +v 0.564871 0.175850 -0.787131 +v 0.005672 0.175850 -0.968826 +v -0.154744 -0.083717 -0.968826 +v -0.154744 -0.083717 -0.968826 +v -0.149072 0.092132 -0.967371 +v -0.149072 0.092132 -0.967371 +v -0.298143 0.184265 -0.917587 +v -0.298143 0.184265 -0.917587 +v -0.300079 -0.167435 -0.923556 +v -0.300079 -0.167435 -0.923556 +v -0.444270 -0.083718 -0.874754 +v -0.444270 -0.083718 -0.874754 +v -0.448006 0.092132 -0.870241 +v -0.448006 0.092132 -0.870241 +v -0.574053 0.175850 -0.780460 +v -0.574053 0.175850 -0.780460 +v -0.919656 0.175849 -0.304776 +v -0.919656 0.175849 -0.304776 +v -0.969228 -0.083716 -0.152210 +v -0.969228 -0.083716 -0.152210 +v -0.966090 0.092132 -0.157157 +v -0.966090 0.092132 -0.157157 +v -0.964809 0.184264 -0.000000 +v -0.964809 0.184264 -0.000000 +v -0.971084 -0.167433 0.000000 +v -0.971084 -0.167433 0.000000 +v -0.969228 -0.083716 0.152210 +v -0.969228 -0.083716 0.152210 +v -0.966090 0.092132 0.157157 +v -0.966090 0.092132 0.157157 +v -0.919656 0.175849 0.304776 +v -0.919656 0.175849 0.304776 +v -0.574053 0.175850 0.780460 +v -0.574053 0.175850 0.780460 +v -0.444270 -0.083718 0.874754 +v -0.444270 -0.083718 0.874754 +v -0.448006 0.092132 0.870241 +v -0.448006 0.092132 0.870241 +v -0.298144 0.184264 0.917587 +v -0.298144 0.184264 0.917587 +v -0.300080 -0.167435 0.923555 +v -0.300080 -0.167435 0.923555 +v -0.154744 -0.083717 0.968826 +v -0.154744 -0.083717 0.968826 +v -0.149072 0.092132 0.967371 +v -0.149072 0.092132 0.967371 +v 0.005672 0.175849 0.968826 +v 0.005672 0.175849 0.968826 +v 0.564871 0.175849 0.787131 +v 0.564871 0.175849 0.787131 +v 0.694654 -0.083718 0.692838 +v 0.694654 -0.083718 0.692838 +v 0.689208 0.092131 0.694997 +v 0.689208 0.092131 0.694997 +v 0.780548 0.184263 0.567099 +v 0.780548 0.184263 0.567099 +v 0.785625 -0.167435 0.570785 +v 0.785625 -0.167435 0.570785 +v 0.873591 -0.083718 0.446551 +v 0.873591 -0.083718 0.446551 +v 0.873960 0.092131 0.440706 +v 0.873960 0.092131 0.440706 +v 0.923163 0.175848 0.293985 +v 0.923163 0.175848 0.293985 +v 0.444270 0.083717 -0.874754 +v 0.444270 0.083717 -0.874754 +v 0.574053 -0.175850 -0.780460 +v 0.574053 -0.175850 -0.780460 +v 0.300080 0.167435 -0.923555 +v 0.300080 0.167435 -0.923555 +v 0.154744 0.083717 -0.968826 +v 0.154744 0.083717 -0.968826 +v 0.448006 -0.092133 -0.870241 +v 0.448006 -0.092133 -0.870241 +v 0.298144 -0.184265 -0.917587 +v 0.298144 -0.184265 -0.917587 +v 0.149072 -0.092133 -0.967370 +v 0.149072 -0.092133 -0.967370 +v -0.005672 -0.175850 -0.968826 +v -0.005672 -0.175850 -0.968826 +v -0.694654 0.083717 -0.692838 +v -0.694654 0.083717 -0.692838 +v -0.564870 -0.175850 -0.787132 +v -0.564870 -0.175850 -0.787132 +v -0.785625 0.167434 -0.570785 +v -0.785625 0.167434 -0.570785 +v -0.873591 0.083717 -0.446551 +v -0.873591 0.083717 -0.446551 +v -0.689208 -0.092132 -0.694997 +v -0.689208 -0.092132 -0.694997 +v -0.780547 -0.184265 -0.567099 +v -0.780547 -0.184265 -0.567099 +v -0.873960 -0.092132 -0.440707 +v -0.873960 -0.092132 -0.440707 +v -0.923162 -0.175849 -0.293985 +v -0.923162 -0.175849 -0.293985 +v -0.873591 0.083717 0.446551 +v -0.873591 0.083717 0.446551 +v -0.923162 -0.175849 0.293985 +v -0.923162 -0.175849 0.293985 +v -0.785625 0.167434 0.570785 +v -0.785625 0.167434 0.570785 +v -0.694654 0.083717 0.692838 +v -0.694654 0.083717 0.692838 +v -0.873960 -0.092132 0.440707 +v -0.873960 -0.092132 0.440707 +v -0.780547 -0.184265 0.567099 +v -0.780547 -0.184265 0.567099 +v -0.689208 -0.092132 0.694997 +v -0.689208 -0.092132 0.694997 +v -0.564870 -0.175850 0.787132 +v -0.564870 -0.175850 0.787132 +v 0.154744 0.083717 0.968826 +v 0.154744 0.083717 0.968826 +v -0.005672 -0.175850 0.968826 +v -0.005672 -0.175850 0.968826 +v 0.300080 0.167435 0.923555 +v 0.300080 0.167435 0.923555 +v 0.444270 0.083717 0.874754 +v 0.444270 0.083717 0.874754 +v 0.149072 -0.092133 0.967370 +v 0.149072 -0.092133 0.967370 +v 0.298144 -0.184265 0.917587 +v 0.298144 -0.184265 0.917587 +v 0.448006 -0.092133 0.870240 +v 0.448006 -0.092133 0.870240 +v 0.574053 -0.175850 0.780460 +v 0.574053 -0.175850 0.780460 +v 0.969228 0.083716 0.152210 +v 0.969228 0.083716 0.152210 +v 0.919656 -0.175849 0.304776 +v 0.919656 -0.175849 0.304776 +v 0.971084 0.167433 -0.000000 +v 0.971084 0.167433 -0.000000 +v 0.969228 0.083716 -0.152210 +v 0.969228 0.083716 -0.152210 +v 0.966090 -0.092132 0.157157 +v 0.966090 -0.092132 0.157157 +v 0.964809 -0.184264 0.000000 +v 0.964809 -0.184264 0.000000 +v 0.966090 -0.092132 -0.157157 +v 0.966090 -0.092132 -0.157157 +v 0.919656 -0.175849 -0.304776 +v 0.919656 -0.175849 -0.304776 +v 0.087924 -0.943443 0.270597 +vn 0.9511 -0.0000 0.3090 +vn 0.9511 -0.0000 -0.3090 +vn -0.0000 -0.0000 1.0000 +vn 0.5878 -0.0000 0.8090 +vn -0.9511 -0.0000 0.3090 +vn -0.5878 -0.0000 0.8090 +vn -0.5878 -0.0000 -0.8090 +vn -0.9511 -0.0000 -0.3090 +vn 0.5878 -0.0000 -0.8090 +vn -0.0000 -0.0000 -1.0000 +vn 0.8089 0.0178 -0.5877 +vn -0.3090 0.0178 -0.9509 +vn -0.9998 0.0178 -0.0000 +vn -0.3090 0.0178 0.9509 +vn 0.8089 0.0178 0.5877 +vn 0.3090 -0.0178 -0.9509 +vn -0.8089 -0.0178 -0.5877 +vn -0.8089 -0.0178 0.5877 +vn 0.3090 -0.0178 0.9509 +vn 0.9998 -0.0178 -0.0000 +vt 0.500000 0.993725 +vt 0.927579 0.746863 +vt 0.927579 0.253137 +vt 0.500000 0.006275 +vt 0.072421 0.253137 +vt 0.072421 0.746863 +s 1 +f 158/1/1 230/2/1 236/3/1 229/4/1 163/5/1 160/6/1 +f 85/1/2 234/2/2 240/3/2 243/4/2 88/5/2 89/6/2 +f 142/1/3 214/2/3 220/3/3 213/4/3 147/5/3 144/6/3 +f 148/1/4 218/2/4 224/3/4 227/4/4 151/5/4 152/6/4 +f 126/1/5 198/2/5 204/3/5 197/4/5 131/5/5 128/6/5 +f 132/1/6 202/2/6 208/3/6 211/4/6 135/5/6 136/6/6 +f 110/1/7 182/2/7 188/3/7 181/4/7 115/5/7 112/6/7 +f 116/1/8 186/2/8 192/3/8 195/4/8 119/5/8 120/6/8 +f 95/1/9 166/2/9 172/3/9 165/4/9 100/5/9 97/6/9 +f 101/1/10 170/2/10 176/3/10 179/4/10 103/5/10 104/6/10 +f 4/1/11 7/2/11 8/3/11 98/4/11 92/5/11 5/6/11 +f 12/1/12 15/2/12 16/3/12 113/4/12 107/5/12 13/6/12 +f 20/1/13 23/2/13 24/3/13 129/4/13 123/5/13 21/6/13 +f 28/1/14 31/2/14 32/3/14 145/4/14 139/5/14 29/6/14 +f 36/1/15 39/2/15 40/3/15 161/4/15 155/5/15 37/6/15 +f 43/1/16 173/2/16 174/3/16 177/4/16 46/5/16 45/6/16 +f 51/1/17 189/2/17 190/3/17 193/4/17 54/5/17 53/6/17 +f 59/1/18 205/2/18 206/3/18 209/4/18 62/5/18 61/6/18 +f 67/1/19 221/2/19 222/3/19 225/4/19 70/5/19 69/6/19 +f 75/1/20 237/2/20 238/3/20 241/4/20 78/5/20 77/6/20 +l 162 228 +l 228 232 +l 159 231 +l 156 159 +l 87 93 +l 87 242 +l 233 235 +l 86 235 +l 146 212 +l 212 216 +l 143 215 +l 140 143 +l 150 157 +l 150 226 +l 217 219 +l 149 219 +l 130 196 +l 196 200 +l 127 199 +l 124 127 +l 134 141 +l 134 210 +l 201 203 +l 133 203 +l 114 180 +l 180 184 +l 111 183 +l 108 111 +l 118 125 +l 118 194 +l 185 187 +l 117 187 +l 99 164 +l 164 168 +l 96 167 +l 94 96 +l 102 109 +l 102 178 +l 169 171 +l 3 90 +l 90 91 +l 6 9 +l 9 10 +l 11 105 +l 105 106 +l 14 17 +l 17 18 +l 19 121 +l 121 122 +l 22 25 +l 25 26 +l 27 137 +l 137 138 +l 30 33 +l 33 34 +l 35 153 +l 153 154 +l 38 41 +l 41 42 +l 44 47 +l 47 175 +l 48 49 +l 49 50 +l 52 55 +l 55 191 +l 56 57 +l 57 58 +l 60 63 +l 63 207 +l 64 65 +l 65 66 +l 68 71 +l 71 223 +l 72 73 +l 73 74 +l 76 79 +l 79 239 +l 80 81 +l 81 82 diff --git a/apps/editor/public/navigation/proto_pascal_robot.glb b/apps/editor/public/navigation/proto_pascal_robot.glb new file mode 100644 index 000000000..ec99c03ef Binary files /dev/null and b/apps/editor/public/navigation/proto_pascal_robot.glb differ diff --git a/apps/editor/public/navigation/tool-asset.glb b/apps/editor/public/navigation/tool-asset.glb new file mode 100644 index 000000000..ac223f82d Binary files /dev/null and b/apps/editor/public/navigation/tool-asset.glb differ diff --git a/apps/editor/public/navigation/white-black-armored-soldier-animated.glb b/apps/editor/public/navigation/white-black-armored-soldier-animated.glb new file mode 100644 index 000000000..1fc4f47bd Binary files /dev/null and b/apps/editor/public/navigation/white-black-armored-soldier-animated.glb differ diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index c44e7909d..049564c51 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -1,11 +1,89 @@ 'use client' import { useLayoutEffect } from 'react' -import type * as THREE from 'three' +import type { BufferGeometry, Object3D } from 'three' +import { Box3, Matrix4 } from 'three' + +type SceneBoundsEntry = { + dynamic: boolean + localBounds: Box3 | null + object: Object3D +} + +const inverseRootWorldMatrix = new Matrix4() +const childMatrixInRootSpace = new Matrix4() +const childBoundsScratch = new Box3() + +function getObjectGeometryBounds(object: Object3D) { + const geometry = (object as Object3D & { geometry?: BufferGeometry | null }).geometry + if (!geometry) { + return null + } + + if (geometry.boundingBox === null) { + geometry.computeBoundingBox() + } + + return geometry.boundingBox ?? null +} + +function hasDynamicBoundsSubtree(root: Object3D) { + let dynamic = false + + root.traverse((child) => { + if (dynamic) { + return + } + + const maybeAnimatedChild = child as Object3D & { + isSkinnedMesh?: boolean + morphTargetInfluences?: unknown[] | undefined + } + + if ( + maybeAnimatedChild.isSkinnedMesh || + (Array.isArray(maybeAnimatedChild.morphTargetInfluences) && + maybeAnimatedChild.morphTargetInfluences.length > 0) || + child.userData?.navigationDoor + ) { + dynamic = true + } + }) + + return dynamic +} + +function computeLocalBounds(root: Object3D) { + root.updateWorldMatrix(true, true) + inverseRootWorldMatrix.copy(root.matrixWorld).invert() + + let initialized = false + const localBounds = new Box3() + + root.traverse((child) => { + const geometryBounds = getObjectGeometryBounds(child) + if (!geometryBounds) { + return + } + + childMatrixInRootSpace.multiplyMatrices(inverseRootWorldMatrix, child.matrixWorld) + childBoundsScratch.copy(geometryBounds).applyMatrix4(childMatrixInRootSpace) + + if (initialized) { + localBounds.union(childBoundsScratch) + } else { + localBounds.copy(childBoundsScratch) + initialized = true + } + }) + + return initialized ? localBounds : null +} export const sceneRegistry = { // Master lookup: ID -> Object3D - nodes: new Map(), + nodes: new Map(), + bounds: new Map(), // Categorized lookups: Type -> Set of IDs // Using a Set is faster for adding/deleting than an Array @@ -32,16 +110,47 @@ export const sceneRegistry = { /** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */ clear() { this.nodes.clear() + this.bounds.clear() for (const set of Object.values(this.byType)) { set.clear() } }, + + getWorldBounds(id: string, target = new Box3()) { + const object = this.nodes.get(id) + if (!object) { + return target.makeEmpty() + } + + let entry = this.bounds.get(id) + if (!entry || entry.object !== object) { + const dynamic = hasDynamicBoundsSubtree(object) + entry = { + dynamic, + localBounds: dynamic ? null : computeLocalBounds(object), + object, + } + this.bounds.set(id, entry) + } + + if (entry.dynamic) { + object.updateWorldMatrix(true, true) + return target.setFromObject(object) + } + + if (!entry.localBounds) { + return target.makeEmpty() + } + + object.updateWorldMatrix(true, false) + return target.copy(entry.localBounds).applyMatrix4(object.matrixWorld) + }, } export function useRegistry( id: string, type: keyof typeof sceneRegistry.byType, - ref: React.RefObject, + ref: React.RefObject, ) { useLayoutEffect(() => { const obj = ref.current @@ -49,6 +158,7 @@ export function useRegistry( // 1. Add to master map sceneRegistry.nodes.set(id, obj) + sceneRegistry.bounds.delete(id) // 2. Add to type-specific set sceneRegistry.byType[type].add(id) @@ -56,6 +166,7 @@ export function useRegistry( // 4. Cleanup when component unmounts return () => { sceneRegistry.nodes.delete(id) + sceneRegistry.bounds.delete(id) sceneRegistry.byType[type].delete(id) } }, [id, type, ref]) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7ef4e5450..5aa0b7888 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,7 +37,6 @@ export { type Space, wallTouchesOthers, } from './lib/space-detection' -export { baseMaterial, glassMaterial } from './materials' export { getCatalogMaterialById, getLibraryMaterialIdFromRef, @@ -46,37 +45,32 @@ export { LIBRARY_MATERIAL_REF_PREFIX, MATERIAL_CATALOG, MATERIAL_CATEGORIES, - type MaterialCategory, type MaterialCatalogItem, + type MaterialCategory, toLibraryMaterialRef, } from './material-library' +export { baseMaterial, glassMaterial } from './materials' export * from './schema' -export { - type ControlValue, - type ItemInteractiveState, - useInteractive, -} from './store/use-interactive' export { getSceneHistoryPauseDepth, pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, } from './store/history-control' +export { + type ControlValue, + type ItemInteractiveState, + useInteractive, +} from './store/use-interactive' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' -export { FenceSystem } from './systems/fence/fence-system' export { clearSceneHistory, default as useScene } from './store/use-scene' export { CeilingSystem } from './systems/ceiling/ceiling-system' export { DoorSystem } from './systems/door/door-system' +export { FenceSystem } from './systems/fence/fence-system' export { ItemSystem } from './systems/item/item-system' export { RoofSystem } from './systems/roof/roof-system' export { SlabSystem } from './systems/slab/slab-system' export { StairSystem } from './systems/stair/stair-system' -export { - DEFAULT_WALL_HEIGHT, - DEFAULT_WALL_THICKNESS, - getWallPlanFootprint, - getWallThickness, -} from './systems/wall/wall-footprint' export { getClampedWallCurveOffset, getMaxWallCurveOffset, @@ -90,12 +84,18 @@ export { normalizeWallCurveOffset, sampleWallCenterline, } from './systems/wall/wall-curve' +export { + DEFAULT_WALL_HEIGHT, + DEFAULT_WALL_THICKNESS, + getWallPlanFootprint, + getWallThickness, +} from './systems/wall/wall-footprint' export { calculateLevelMiters, getWallMiterBoundaryPoints, type Point2D, - type WallMiterBoundaryPoints, pointToKey, + type WallMiterBoundaryPoints, type WallMiterData, } from './systems/wall/wall-mitering' export { WallSystem } from './systems/wall/wall-system' diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 9d8ad95d3..e1893dc6f 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -7,11 +7,11 @@ export const DoorSegment = z.object({ type: z.enum(['panel', 'glass', 'empty']), heightRatio: z.number(), - // Each segment controls its own column split + // Each segment controls its own column split. columnRatios: z.array(z.number()).default([1]), dividerThickness: z.number().default(0.03), - // panel-specific + // Panel-specific. panelDepth: z.number().default(0.01), // + raised, - recessed panelInset: z.number().default(0.04), }) @@ -28,21 +28,22 @@ export const DoorNode = BaseNode.extend({ side: z.enum(['front', 'back']).optional(), wallId: z.string().optional(), - // Overall dimensions + // Overall dimensions. width: z.number().default(0.9), height: z.number().default(2.1), - // Frame + // Frame. frameThickness: z.number().default(0.05), frameDepth: z.number().default(0.07), threshold: z.boolean().default(true), thresholdHeight: z.number().default(0.02), - // Swing + // Opening behavior. + openingStyle: z.enum(['swing', 'overhead']).optional(), hingesSide: z.enum(['left', 'right']).default('left'), swingDirection: z.enum(['inward', 'outward']).default('inward'), - // Leaf segments — stacked top to bottom, each with its own column split + // Leaf segments stacked top to bottom, each with its own column split. segments: z.array(DoorSegment).default([ { type: 'panel', @@ -62,15 +63,15 @@ export const DoorNode = BaseNode.extend({ }, ]), - // Handle + // Handle. handle: z.boolean().default(true), handleHeight: z.number().default(1.05), handleSide: z.enum(['left', 'right']).default('right'), - // Leaf inner margin — space between leaf edge and segment content area [x, y] + // Space between leaf edge and segment content area [x, y]. contentPadding: z.tuple([z.number(), z.number()]).default([0.04, 0.04]), - // Emergency / commercial hardware + // Emergency / commercial hardware. doorCloser: z.boolean().default(false), panicBar: z.boolean().default(false), panicBarHeight: z.number().default(1.0), @@ -78,6 +79,7 @@ export const DoorNode = BaseNode.extend({ - position: center of the door in wall-local coordinate system (Y = height/2, always at floor) - segments: rows stacked top to bottom, each defining its own columnRatios - type 'empty' = flush flat fill, 'panel' = raised/recessed panel, 'glass' = glazed + - openingStyle: 'swing' for hinged doors, 'overhead' for garage-style sectional doors - hingesSide/swingDirection: which way the door opens - doorCloser/panicBar: commercial and emergency hardware options `) diff --git a/packages/core/src/store/use-live-transforms.ts b/packages/core/src/store/use-live-transforms.ts index b2aef7dd0..0e0ec498c 100644 --- a/packages/core/src/store/use-live-transforms.ts +++ b/packages/core/src/store/use-live-transforms.ts @@ -21,6 +21,17 @@ const useLiveTransforms = create((set, get) => ({ transforms: new Map(), set: (nodeId, transform) => set((state) => { + const current = state.transforms.get(nodeId) + if ( + current && + current.rotation === transform.rotation && + current.position[0] === transform.position[0] && + current.position[1] === transform.position[1] && + current.position[2] === transform.position[2] + ) { + return state + } + const next = new Map(state.transforms) next.set(nodeId, transform) return { transforms: next } @@ -28,6 +39,10 @@ const useLiveTransforms = create((set, get) => ({ get: (nodeId) => get().transforms.get(nodeId), clear: (nodeId) => set((state) => { + if (!state.transforms.has(nodeId)) { + return state + } + const next = new Map(state.transforms) next.delete(nodeId) return { transforms: next } diff --git a/packages/core/src/systems/door/door-system.tsx b/packages/core/src/systems/door/door-system.tsx index 24dacaa19..f2be12000 100644 --- a/packages/core/src/systems/door/door-system.tsx +++ b/packages/core/src/systems/door/door-system.tsx @@ -1,17 +1,57 @@ import { useFrame } from '@react-three/fiber' +import { useEffect } from 'react' import * as THREE from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { baseMaterial, glassMaterial } from '../../materials' import type { AnyNodeId, DoorNode } from '../../schema' import useScene from '../../store/use-scene' +import { getWallThickness } from '../wall/wall-footprint' -// Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) +const GARAGE_DOOR_MIN_WIDTH = 2.4 +const GARAGE_DOOR_MIN_PANEL_SEGMENTS = 4 +const OVERHEAD_TRACK_THICKNESS = 0.014 +const OVERHEAD_TRACK_FACE_OFFSET = 0.016 + +type RuntimeDoorOpeningStyle = 'overhead' | 'swing' + +type NavigationDoorAnimationState = { + alternateOpenPosition?: [number, number, number] + alternateOpenRotation?: [number, number, number] + closedPosition: [number, number, number] + closedRotation: [number, number, number] + localBounds?: { + max: [number, number, number] + min: [number, number, number] + } + openPosition: [number, number, number] + openRotation: [number, number, number] + style: RuntimeDoorOpeningStyle +} + +type SingleMaterialMesh = THREE.Mesh + +type DoorGroupMergeEntry = { + castShadow: boolean + geometries: THREE.BufferGeometry[] + material: THREE.Material + receiveShadow: boolean +} export const DoorSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) + useEffect(() => { + const nodes = useScene.getState().nodes + for (const [id, node] of Object.entries(nodes)) { + if (node?.type === 'door') { + useScene.getState().dirtyNodes.add(id as AnyNodeId) + } + } + }, []) + useFrame(() => { if (dirtyNodes.size === 0) return @@ -22,12 +62,11 @@ export const DoorSystem = () => { if (!node || node.type !== 'door') return const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh - if (!mesh) return // Keep dirty until mesh mounts + if (!mesh) return updateDoorMesh(node as DoorNode, mesh) clearDirty(id as AnyNodeId) - // Rebuild the parent wall so its cutout reflects the updated door geometry if ((node as DoorNode).parentId) { useScene.getState().dirtyNodes.add((node as DoorNode).parentId as AnyNodeId) } @@ -40,29 +79,184 @@ export const DoorSystem = () => { function addBox( parent: THREE.Object3D, material: THREE.Material, - w: number, - h: number, - d: number, + width: number, + height: number, + depth: number, x: number, y: number, z: number, ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) - m.position.set(x, y, z) - parent.add(m) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth), material) + mesh.position.set(x, y, z) + parent.add(mesh) +} + +function optimizeDoorGroupMeshes(group: THREE.Group) { + const childMeshes = group.children.filter( + (child): child is SingleMaterialMesh => + child instanceof THREE.Mesh && !Array.isArray(child.material), + ) + if (childMeshes.length <= 1) { + return + } + + const mergedEntries = new Map() + + for (const mesh of childMeshes) { + mesh.updateMatrix() + const material = mesh.material + const key = `${material.uuid}:${mesh.castShadow ? '1' : '0'}:${mesh.receiveShadow ? '1' : '0'}` + const entry: DoorGroupMergeEntry = mergedEntries.get(key) ?? { + castShadow: mesh.castShadow, + geometries: [], + material, + receiveShadow: mesh.receiveShadow, + } + const geometry = mesh.geometry.clone() + geometry.applyMatrix4(mesh.matrix) + entry.geometries.push(geometry) + mergedEntries.set(key, entry) + } + + for (const mesh of childMeshes) { + mesh.geometry.dispose() + } + group.clear() + + for (const entry of mergedEntries.values()) { + const mergedGeometry = + entry.geometries.length === 1 + ? entry.geometries[0] + : (mergeGeometries(entry.geometries, false) ?? entry.geometries[0]) + if (!mergedGeometry) { + continue + } + + const mergedMesh = new THREE.Mesh(mergedGeometry, entry.material) + mergedMesh.castShadow = entry.castShadow + mergedMesh.receiveShadow = entry.receiveShadow + group.add(mergedMesh) + } +} + +function ensureGroup(parent: THREE.Object3D, name: string) { + const existingGroup = parent.getObjectByName(name) + if (existingGroup instanceof THREE.Group) { + return existingGroup + } + + const group = new THREE.Group() + group.name = name + parent.add(group) + return group +} + +function getObjectBoundsInParentSpace(object: THREE.Object3D, parent: THREE.Object3D) { + object.updateWorldMatrix(true, true) + parent.updateWorldMatrix(true, true) + + const inverseParentMatrix = new THREE.Matrix4().copy(parent.matrixWorld).invert() + const bounds = new THREE.Box3() + let initialized = false + + object.traverse((child) => { + if (!(child instanceof THREE.Mesh)) { + return + } + + child.geometry.computeBoundingBox() + const childBounds = child.geometry.boundingBox?.clone() + if (!childBounds) { + return + } + + const childMatrixInParentSpace = new THREE.Matrix4().multiplyMatrices( + inverseParentMatrix, + child.matrixWorld, + ) + childBounds.applyMatrix4(childMatrixInParentSpace) + + if (initialized) { + bounds.union(childBounds) + } else { + bounds.copy(childBounds) + initialized = true + } + }) + + return initialized ? bounds : null +} + +function getDoorLeafOpenAngle(node: Pick) { + const direction = node.swingDirection === 'inward' ? 1 : -1 + + return direction * THREE.MathUtils.degToRad(170) +} + +function getRuntimeDoorOpeningStyle(node: DoorNode): RuntimeDoorOpeningStyle { + if (node.openingStyle) { + return node.openingStyle + } + + const hasOnlyPanelSegments = (node.segments ?? []).every((segment) => segment.type === 'panel') + if ( + node.width >= GARAGE_DOOR_MIN_WIDTH && + (node.segments?.length ?? 0) >= GARAGE_DOOR_MIN_PANEL_SEGMENTS && + hasOnlyPanelSegments + ) { + return 'overhead' + } + + return 'swing' +} + +function buildNavigationDoorAnimationState( + node: DoorNode, + openingStyle: RuntimeDoorOpeningStyle, + hingeX: number, + leafDepth: number, + leafH: number, + leafTopY: number, + frameDepth: number, + wallDepth: number, +): NavigationDoorAnimationState { + if (openingStyle === 'swing') { + const openAngle = getDoorLeafOpenAngle(node) + return { + alternateOpenPosition: [hingeX, 0, 0], + alternateOpenRotation: [0, -openAngle, 0], + closedPosition: [hingeX, 0, 0], + closedRotation: [0, 0, 0], + openPosition: [hingeX, 0, 0], + openRotation: [0, openAngle, 0], + style: 'swing', + } + } + + const travelDirection = node.swingDirection === 'inward' ? -1 : 1 + const wallHalfDepth = wallDepth / 2 + const closedDepthOffset = travelDirection * Math.max(0, wallHalfDepth - leafDepth / 2 - 0.008) + const trackInset = + travelDirection * (wallHalfDepth + OVERHEAD_TRACK_THICKNESS / 2 + OVERHEAD_TRACK_FACE_OFFSET) + const openDepthOffset = trackInset + travelDirection * leafH + + return { + closedPosition: [0, leafTopY, closedDepthOffset], + closedRotation: [0, 0, 0], + openPosition: [0, leafTopY, openDepthOffset], + openRotation: [travelDirection > 0 ? Math.PI / 2 : -Math.PI / 2, 0, 0], + style: 'overhead', + } } function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { - // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth) mesh.material = hitboxMaterial - // Sync transform from node (React may lag behind the system by a frame during drag) mesh.position.set(node.position[0], node.position[1], node.position[2]) mesh.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2]) - // Dispose and remove all old visual children; preserve 'cutout' for (const child of [...mesh.children]) { if (child.name === 'cutout') continue if (child instanceof THREE.Mesh) child.geometry.dispose() @@ -86,18 +280,45 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { contentPadding, hingesSide, } = node + const parentNode = node.parentId ? useScene.getState().nodes[node.parentId as AnyNodeId] : null + const wallDepth = parentNode?.type === 'wall' ? getWallThickness(parentNode) : frameDepth - // Leaf occupies the full opening (no bottom frame bar — door opens to floor) + const openingStyle = getRuntimeDoorOpeningStyle(node) const leafW = width - 2 * frameThickness - const leafH = height - frameThickness // only top frame + const leafH = height - frameThickness const leafDepth = 0.04 - // Leaf center is shifted down from door center by half the top frame const leafCenterY = -frameThickness / 2 + const leafBottomY = leafCenterY - leafH / 2 + const leafTopY = leafCenterY + leafH / 2 + const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012 + const frameGroup = ensureGroup(mesh, 'door-frame-group') + const leafPivot = ensureGroup(mesh, 'door-leaf-pivot') + const leafGroup = ensureGroup(leafPivot, 'door-leaf-group') + const navigationDoorAnimation = buildNavigationDoorAnimationState( + node, + openingStyle, + hingeX, + leafDepth, + leafH, + leafTopY, + frameDepth, + wallDepth, + ) + + frameGroup.clear() + leafGroup.clear() + leafPivot.position.set(...navigationDoorAnimation.closedPosition) + leafPivot.rotation.set(...navigationDoorAnimation.closedRotation) + leafPivot.userData.navigationDoor = navigationDoorAnimation + leafGroup.position.set( + ...(openingStyle === 'overhead' + ? ([0, -leafH / 2, 0] as [number, number, number]) + : ([-hingeX, 0, 0] as [number, number, number])), + ) + leafGroup.rotation.set(0, 0, 0) - // ── Frame members ── - // Left post — full height addBox( - mesh, + frameGroup, baseMaterial, frameThickness, height, @@ -106,9 +327,8 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { 0, 0, ) - // Right post — full height addBox( - mesh, + frameGroup, baseMaterial, frameThickness, height, @@ -117,9 +337,8 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { 0, 0, ) - // Head (top bar) — full width addBox( - mesh, + frameGroup, baseMaterial, width, frameThickness, @@ -129,10 +348,9 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { 0, ) - // ── Threshold (inside the frame) ── if (threshold) { addBox( - mesh, + frameGroup, baseMaterial, leafW, thresholdHeight, @@ -143,120 +361,100 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { ) } - // ── Leaf — contentPadding border strips (no full backing; glass areas are open) ── const cpX = contentPadding[0] const cpY = contentPadding[1] if (cpY > 0) { - // Top strip - addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0) - // Bottom strip - addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0) + addBox(leafGroup, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0) + addBox(leafGroup, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0) } if (cpX > 0) { const innerH = leafH - 2 * cpY - // Left strip - addBox(mesh, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0) - // Right strip - addBox(mesh, baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0) + addBox(leafGroup, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0) + addBox(leafGroup, baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0) } - // Content area inside padding const contentW = leafW - 2 * cpX const contentH = leafH - 2 * cpY - - // ── Segments (stacked top to bottom within content area) ── - const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0) + const totalRatio = segments.reduce((sum, segment) => sum + segment.heightRatio, 0) const contentTop = leafCenterY + contentH / 2 let segY = contentTop - for (const seg of segments) { - const segH = (seg.heightRatio / totalRatio) * contentH + for (const segment of segments) { + const segH = (segment.heightRatio / totalRatio) * contentH const segCenterY = segY - segH / 2 + const numCols = segment.columnRatios.length + const colSum = segment.columnRatios.reduce((sum, ratio) => sum + ratio, 0) + const usableW = contentW - (numCols - 1) * segment.dividerThickness + const colWidths = segment.columnRatios.map((ratio) => (ratio / colSum) * usableW) - const numCols = seg.columnRatios.length - const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) - const usableW = contentW - (numCols - 1) * seg.dividerThickness - const colWidths = seg.columnRatios.map((r) => (r / colSum) * usableW) - - // Column x-centers (relative to mesh center) const colXCenters: number[] = [] - let cx = -contentW / 2 - for (let c = 0; c < numCols; c++) { - colXCenters.push(cx + colWidths[c]! / 2) - cx += colWidths[c]! - if (c < numCols - 1) cx += seg.dividerThickness + let cursorX = -contentW / 2 + for (let colIndex = 0; colIndex < numCols; colIndex += 1) { + colXCenters.push(cursorX + colWidths[colIndex]! / 2) + cursorX += colWidths[colIndex]! + if (colIndex < numCols - 1) cursorX += segment.dividerThickness } - // Column dividers within this segment - cx = -contentW / 2 - for (let c = 0; c < numCols - 1; c++) { - cx += colWidths[c]! + cursorX = -contentW / 2 + for (let colIndex = 0; colIndex < numCols - 1; colIndex += 1) { + cursorX += colWidths[colIndex]! addBox( - mesh, + leafGroup, baseMaterial, - seg.dividerThickness, + segment.dividerThickness, segH, leafDepth + 0.001, - cx + seg.dividerThickness / 2, + cursorX + segment.dividerThickness / 2, segCenterY, 0, ) - cx += seg.dividerThickness + cursorX += segment.dividerThickness } - // Segment content per column - for (let c = 0; c < numCols; c++) { - const colW = colWidths[c]! - const colX = colXCenters[c]! + for (let colIndex = 0; colIndex < numCols; colIndex += 1) { + const colW = colWidths[colIndex]! + const colX = colXCenters[colIndex]! - if (seg.type === 'glass') { - // Glass only — no opaque backing so it's truly transparent + if (segment.type === 'glass') { const glassDepth = Math.max(0.004, leafDepth * 0.15) - addBox(mesh, glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) - } else if (seg.type === 'panel') { - // Opaque leaf backing for this column - addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) - // Raised panel detail - const panelW = colW - 2 * seg.panelInset - const panelH = segH - 2 * seg.panelInset + addBox(leafGroup, glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + } else if (segment.type === 'panel') { + addBox(leafGroup, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + const panelW = colW - 2 * segment.panelInset + const panelH = segH - 2 * segment.panelInset if (panelW > 0.01 && panelH > 0.01) { - const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) + const effectiveDepth = + Math.abs(segment.panelDepth) < 0.002 ? 0.005 : Math.abs(segment.panelDepth) const panelZ = leafDepth / 2 + effectiveDepth / 2 - addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + addBox(leafGroup, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) } } else { - // 'empty' — opaque backing, no detail - addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + addBox(leafGroup, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) } } segY -= segH } - // ── Handle ── if (handle) { - // Convert from floor-based height to mesh-center-based Y const handleY = handleHeight - height / 2 - // Handle grip sits on the front face (+Z) of the leaf const faceZ = leafDepth / 2 - - // X position: handleSide refers to which side the grip is on - const handleX = handleSide === 'right' ? leafW / 2 - 0.045 : -leafW / 2 + 0.045 - - // Backplate - addBox(mesh, baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) - // Grip lever - addBox(mesh, baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) + const handleX = + openingStyle === 'overhead' + ? 0 + : handleSide === 'right' + ? leafW / 2 - 0.045 + : -leafW / 2 + 0.045 + + addBox(leafGroup, baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) + addBox(leafGroup, baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) } - // ── Door closer (commercial hardware at top) ── - if (doorCloser) { + if (openingStyle === 'swing' && doorCloser) { const closerY = leafCenterY + leafH / 2 - 0.04 - // Body - addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03) - // Arm (simplified as thin bar to frame side) + addBox(leafGroup, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03) addBox( - mesh, + leafGroup, baseMaterial, 0.14, 0.015, @@ -267,28 +465,41 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { ) } - // ── Panic bar ── - if (panicBar) { + if (openingStyle === 'swing' && panicBar) { const barY = panicBarHeight - height / 2 - addBox(mesh, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03) + addBox(leafGroup, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03) } - // ── Hinges (3 knuckle-style hinges on the hinge side) ── - { - const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012 - const hingeZ = 0 // centered in leaf depth + if (openingStyle === 'swing') { + const hingeZ = 0 const hingeH = 0.1 const hingeW = 0.024 const hingeD = leafDepth + 0.016 - // Bottom hinge ~0.25m from floor, middle hinge, top hinge ~0.25m from top - const leafBottom = leafCenterY - leafH / 2 - const leafTop = leafCenterY + leafH / 2 - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafBottom + 0.25, hingeZ) - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, (leafBottom + leafTop) / 2, hingeZ) - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ) + addBox(frameGroup, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafBottomY + 0.25, hingeZ) + addBox( + frameGroup, + baseMaterial, + hingeW, + hingeH, + hingeD, + hingeX, + (leafBottomY + leafTopY) / 2, + hingeZ, + ) + addBox(frameGroup, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTopY - 0.25, hingeZ) + } + + optimizeDoorGroupMeshes(frameGroup) + optimizeDoorGroupMeshes(leafGroup) + + const leafBounds = getObjectBoundsInParentSpace(leafGroup, leafPivot) + if (leafBounds) { + navigationDoorAnimation.localBounds = { + max: [leafBounds.max.x, leafBounds.max.y, leafBounds.max.z], + min: [leafBounds.min.x, leafBounds.min.y, leafBounds.min.z], + } } - // ── Cutout (for wall CSG) — always full door dimensions, 1m deep ── let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined if (!cutout) { cutout = new THREE.Mesh() diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 0bee75838..ab3531c55 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -3,11 +3,12 @@ import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' -import { useThree } from '@react-three/fiber' +import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' import { Box3, Vector3 } from 'three' import { EDITOR_LAYER } from '../../lib/constants' import useEditor from '../../store/use-editor' +import useNavigation, { navigationEmitter } from '../../store/use-navigation' const currentTarget = new Vector3() const tempBox = new Box3() @@ -16,17 +17,125 @@ const tempDelta = new Vector3() const tempPosition = new Vector3() const tempSize = new Vector3() const tempTarget = new Vector3() +const liveActorPosition = new Vector3() +const bufferedActorPosition = new Vector3() +const followFocusPoint = new Vector3() +const followDesiredPosition = new Vector3() +const followDesiredTarget = new Vector3() +const followDefaultViewDirection = new Vector3(-1, -0.45, -1).normalize() const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 +const FOLLOW_CAMERA_CLOSE_DISTANCE = 9.6 +const FOLLOW_CAMERA_MIN_DISTANCE = 7.2 +const FOLLOW_CAMERA_FOCUS_OFFSET = new Vector3(0, 0.55, 0) +const FOLLOW_CAMERA_BUFFER_DELAY_MS = 800 +const FOLLOW_CAMERA_HISTORY_RETENTION_MS = 3000 +const FOLLOW_CAMERA_MANUAL_OVERRIDE_MS = 160 +const FOLLOW_CAMERA_MANUAL_UPDATE_EPSILON = 0.000001 +const FOLLOW_CAMERA_ACTOR_SMOOTHING = 5 + +type FollowHistorySample = { + position: Vector3 + timestampMs: number +} + +type FollowRigState = { + actorAnchor: Vector3 + cameraPosition: Vector3 + cameraTarget: Vector3 + hasActorTransform: boolean + initialized: boolean + positionOffset: Vector3 +} + +function getDampingFactor(lambda: number, delta: number) { + return 1 - Math.exp(-lambda * delta) +} + +function normalizeFollowPositionOffset(offset: Vector3) { + const offsetLength = offset.length() + if (offsetLength <= Number.EPSILON) { + offset.copy(followDefaultViewDirection).multiplyScalar(-FOLLOW_CAMERA_CLOSE_DISTANCE) + return + } + + if (offsetLength < FOLLOW_CAMERA_MIN_DISTANCE) { + offset.multiplyScalar(FOLLOW_CAMERA_MIN_DISTANCE / offsetLength) + } +} + +function sampleBufferedActorPosition( + history: FollowHistorySample[], + targetTimestampMs: number, + out: Vector3, +) { + if (history.length === 0) { + return false + } + + const oldestSample = history[0] + const newestSample = history[history.length - 1] + if (!(oldestSample && newestSample)) { + return false + } + + if (targetTimestampMs <= oldestSample.timestampMs) { + out.copy(oldestSample.position) + return true + } + + if (targetTimestampMs >= newestSample.timestampMs) { + out.copy(newestSample.position) + return true + } + + for (let index = 1; index < history.length; index += 1) { + const nextSample = history[index] + const previousSample = history[index - 1] + if (!(nextSample && previousSample)) { + continue + } + + if (targetTimestampMs > nextSample.timestampMs) { + continue + } + + const sampleSpan = nextSample.timestampMs - previousSample.timestampMs + const alpha = + sampleSpan <= Number.EPSILON + ? 1 + : (targetTimestampMs - previousSample.timestampMs) / sampleSpan + out.copy(previousSample.position).lerp(nextSample.position, alpha) + return true + } + + out.copy(newestSample.position) + return true +} export const CustomCameraControls = () => { const controls = useRef(null!) const isPreviewMode = useEditor((s) => s.isPreviewMode) + const actorAvailable = useNavigation((s) => s.actorAvailable) + const actorWorldPosition = useNavigation((s) => s.actorWorldPosition) + const followRobotEnabled = useNavigation((s) => s.followRobotEnabled) + const setFollowRobotEnabled = useNavigation((s) => s.setFollowRobotEnabled) const walkthroughMode = useViewer((s) => s.walkthroughMode) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) const selection = useViewer((s) => s.selection) const currentLevelId = selection.levelId const firstLoad = useRef(true) + const followHistoryRef = useRef([]) + const followInteractionActiveRef = useRef(false) + const followManualAdjustmentUntilRef = useRef(0) + const followRigRef = useRef({ + actorAnchor: new Vector3(), + cameraPosition: new Vector3(), + cameraTarget: new Vector3(), + hasActorTransform: false, + initialized: false, + positionOffset: new Vector3(6.8, 4.87, 6.8), + }) const maxPolarAngle = !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE @@ -67,14 +176,77 @@ export const CustomCameraControls = () => { } }, [maxPolarAngle]) + useEffect(() => { + if (!(followRobotEnabled && (isPreviewMode || walkthroughMode || !actorAvailable))) { + return + } + + setFollowRobotEnabled(false) + }, [actorAvailable, followRobotEnabled, isPreviewMode, setFollowRobotEnabled, walkthroughMode]) + + useEffect(() => { + if (!followRobotEnabled) { + followHistoryRef.current.length = 0 + followRigRef.current.hasActorTransform = false + followRigRef.current.initialized = false + return + } + + if (actorWorldPosition) { + liveActorPosition.set(actorWorldPosition[0], actorWorldPosition[1], actorWorldPosition[2]) + followHistoryRef.current = [ + { + position: liveActorPosition.clone(), + timestampMs: performance.now(), + }, + ] + followRigRef.current.hasActorTransform = true + } + + const handleActorTransform = (event: { + moving: boolean + position: [number, number, number] | null + rotationY: number + }) => { + const followRig = followRigRef.current + const followHistory = followHistoryRef.current + + if (!event.position) { + followHistory.length = 0 + followRig.hasActorTransform = false + followRig.initialized = false + return + } + + liveActorPosition.set(event.position[0], event.position[1], event.position[2]) + followHistory.push({ + position: liveActorPosition.clone(), + timestampMs: performance.now(), + }) + + while ( + followHistory.length > 1 && + followHistory[0] && + followHistory[0].timestampMs < performance.now() - FOLLOW_CAMERA_HISTORY_RETENTION_MS + ) { + followHistory.shift() + } + + followRig.hasActorTransform = true + } + + navigationEmitter.on('navigation:actor-transform', handleActorTransform) + + return () => { + navigationEmitter.off('navigation:actor-transform', handleActorTransform) + } + }, [actorWorldPosition, followRobotEnabled]) + const focusNode = useCallback( (nodeId: string) => { if (isPreviewMode || !controls.current) return - const object3D = sceneRegistry.nodes.get(nodeId) - if (!object3D) return - - tempBox.setFromObject(object3D) + sceneRegistry.getWorldBounds(nodeId, tempBox) if (tempBox.isEmpty()) return tempBox.getCenter(tempCenter) @@ -242,10 +414,8 @@ export const CustomCameraControls = () => { if (!previewTargetNodeId) return // Calculate camera position from bounding box - const object3D = sceneRegistry.nodes.get(previewTargetNodeId) - if (!object3D) return - - tempBox.setFromObject(object3D) + sceneRegistry.getWorldBounds(previewTargetNodeId, tempBox) + if (tempBox.isEmpty()) return tempBox.getCenter(tempCenter) tempBox.getSize(tempSize) @@ -340,12 +510,32 @@ export const CustomCameraControls = () => { focusNode(nodeId) } + const handleLookAt = (event: { + position: [number, number, number] + target: [number, number, number] + }) => { + if (!controls.current) return + + const { position, target } = event + + controls.current.setLookAt( + position[0], + position[1], + position[2], + target[0], + target[1], + target[2], + true, + ) + } + emitter.on('camera-controls:capture', handleNodeCapture) emitter.on('camera-controls:focus', handleNodeFocus) emitter.on('camera-controls:view', handleNodeView) emitter.on('camera-controls:top-view', handleTopView) emitter.on('camera-controls:orbit-cw', handleOrbitCW) emitter.on('camera-controls:orbit-ccw', handleOrbitCCW) + navigationEmitter.on('navigation:look-at', handleLookAt) return () => { emitter.off('camera-controls:capture', handleNodeCapture) @@ -354,9 +544,178 @@ export const CustomCameraControls = () => { emitter.off('camera-controls:top-view', handleTopView) emitter.off('camera-controls:orbit-cw', handleOrbitCW) emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) + navigationEmitter.off('navigation:look-at', handleLookAt) } }, [focusNode]) + useEffect(() => { + const followRig = followRigRef.current + if (!followRobotEnabled) { + followRig.initialized = false + return + } + + if (!(controls.current && followRig.hasActorTransform)) { + return + } + + controls.current.getPosition(tempPosition) + controls.current.getTarget(tempTarget) + + const delayedActorPosition = sampleBufferedActorPosition( + followHistoryRef.current, + performance.now() - FOLLOW_CAMERA_BUFFER_DELAY_MS, + bufferedActorPosition, + ) + ? bufferedActorPosition + : liveActorPosition + + followRig.actorAnchor.copy(delayedActorPosition) + followRig.cameraPosition.copy(tempPosition) + followFocusPoint.copy(delayedActorPosition).add(FOLLOW_CAMERA_FOCUS_OFFSET) + followRig.positionOffset.copy(tempPosition).sub(followFocusPoint) + normalizeFollowPositionOffset(followRig.positionOffset) + followRig.cameraTarget.copy(followFocusPoint) + followRig.initialized = true + }, [followRobotEnabled]) + + useEffect(() => { + const currentControls = controls.current + if (!currentControls) return + + const syncFollowRigFromControls = () => { + if (!(followRobotEnabled && currentControls && followRigRef.current.hasActorTransform)) { + return + } + + currentControls.getPosition(tempPosition) + followFocusPoint.copy(followRigRef.current.actorAnchor).add(FOLLOW_CAMERA_FOCUS_OFFSET) + followRigRef.current.positionOffset.copy(tempPosition).sub(followFocusPoint) + normalizeFollowPositionOffset(followRigRef.current.positionOffset) + followRigRef.current.cameraPosition.copy(tempPosition) + followRigRef.current.cameraTarget.copy(followFocusPoint) + } + + const handleControlStart = () => { + followInteractionActiveRef.current = true + } + + const handleControlEnd = () => { + followInteractionActiveRef.current = false + followManualAdjustmentUntilRef.current = performance.now() + FOLLOW_CAMERA_MANUAL_OVERRIDE_MS + syncFollowRigFromControls() + } + + const handleUpdate = () => { + if (!(followRobotEnabled && currentControls && followRigRef.current.hasActorTransform)) { + return + } + + currentControls.getPosition(tempPosition) + followFocusPoint.copy(followRigRef.current.actorAnchor).add(FOLLOW_CAMERA_FOCUS_OFFSET) + + const isExternalUpdate = + tempPosition.distanceToSquared(followRigRef.current.cameraPosition) > + FOLLOW_CAMERA_MANUAL_UPDATE_EPSILON || + followFocusPoint.distanceToSquared(followRigRef.current.cameraTarget) > + FOLLOW_CAMERA_MANUAL_UPDATE_EPSILON + + if (!isExternalUpdate) { + return + } + + followManualAdjustmentUntilRef.current = performance.now() + FOLLOW_CAMERA_MANUAL_OVERRIDE_MS + followRigRef.current.positionOffset.copy(tempPosition).sub(followFocusPoint) + normalizeFollowPositionOffset(followRigRef.current.positionOffset) + followRigRef.current.cameraPosition.copy(tempPosition) + followRigRef.current.cameraTarget.copy(followFocusPoint) + } + + currentControls.addEventListener('controlstart', handleControlStart) + currentControls.addEventListener('controlend', handleControlEnd) + currentControls.addEventListener('update', handleUpdate) + + return () => { + currentControls.removeEventListener('controlstart', handleControlStart) + currentControls.removeEventListener('controlend', handleControlEnd) + currentControls.removeEventListener('update', handleUpdate) + } + }, [followRobotEnabled]) + + useFrame((_, delta) => { + if (!controls.current || !followRobotEnabled || isPreviewMode || walkthroughMode) { + return + } + + const followRig = followRigRef.current + if (!followRig.hasActorTransform) { + return + } + + if (!followRig.initialized) { + controls.current.getPosition(tempPosition) + followRig.actorAnchor.copy(liveActorPosition) + followFocusPoint.copy(liveActorPosition).add(FOLLOW_CAMERA_FOCUS_OFFSET) + followRig.cameraPosition.copy(tempPosition) + followRig.positionOffset.copy(tempPosition).sub(followFocusPoint) + normalizeFollowPositionOffset(followRig.positionOffset) + followRig.cameraTarget.copy(followFocusPoint) + followRig.initialized = true + } + + const actorFactor = getDampingFactor(FOLLOW_CAMERA_ACTOR_SMOOTHING, delta) + const delayedActorPosition = sampleBufferedActorPosition( + followHistoryRef.current, + performance.now() - FOLLOW_CAMERA_BUFFER_DELAY_MS, + bufferedActorPosition, + ) + ? bufferedActorPosition + : liveActorPosition + + followRig.actorAnchor.lerp(delayedActorPosition, actorFactor) + const manualAdjustmentActive = + followInteractionActiveRef.current || + performance.now() < followManualAdjustmentUntilRef.current + + if (manualAdjustmentActive) { + controls.current.getPosition(tempPosition) + followDesiredTarget.copy(followRig.actorAnchor).add(FOLLOW_CAMERA_FOCUS_OFFSET) + followRig.positionOffset.copy(tempPosition).sub(followDesiredTarget) + normalizeFollowPositionOffset(followRig.positionOffset) + followRig.cameraPosition.copy(tempPosition) + followRig.cameraTarget.copy(followDesiredTarget) + controls.current.setLookAt( + followRig.cameraPosition.x, + followRig.cameraPosition.y, + followRig.cameraPosition.z, + followRig.cameraTarget.x, + followRig.cameraTarget.y, + followRig.cameraTarget.z, + false, + ) + return + } + + followDesiredPosition + .copy(followRig.actorAnchor) + .add(FOLLOW_CAMERA_FOCUS_OFFSET) + .add(followRig.positionOffset) + followDesiredTarget.copy(followRig.actorAnchor).add(FOLLOW_CAMERA_FOCUS_OFFSET) + + followRig.cameraPosition.copy(followDesiredPosition) + followRig.cameraTarget.copy(followDesiredTarget) + + controls.current.setLookAt( + followRig.cameraPosition.x, + followRig.cameraPosition.y, + followRig.cameraPosition.z, + followRig.cameraTarget.x, + followRig.cameraTarget.y, + followRig.cameraTarget.z, + false, + ) + }) + const onTransitionStart = useCallback(() => { useViewer.getState().setCameraDragging(true) }, []) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index a3ab3da86..ff2e51620 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -25,6 +25,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { + requestNavigationItemDelete, + requestNavigationItemRepair, +} from '../../store/use-navigation' +import { setNavigationDraftRobotCopySourceId } from '../../store/use-navigation-drafts' import { NodeActionMenu } from './node-action-menu' const ALLOWED_TYPES = [ @@ -57,6 +62,7 @@ export function FloatingActionMenu() { const setCurvingWall = useEditor((s) => s.setCurvingWall) const setCurvingFence = useEditor((s) => s.setCurvingFence) const setSelection = useViewer((s) => s.setSelection) + const setHoveredId = useViewer((s) => s.setHoveredId) const setEditingHole = useEditor((s) => s.setEditingHole) const groupRef = useRef(null) @@ -143,10 +149,7 @@ export function FloatingActionMenu() { node.type === 'wall' ? obj.localToWorld( new THREE.Vector3( - Math.hypot( - segment.end[0] - segment.start[0], - segment.end[1] - segment.start[1], - ), + Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]), 0, 0, ), @@ -248,6 +251,7 @@ export function FloatingActionMenu() { duplicate = WindowNode.parse(duplicateInfo) } else if (node.type === 'item') { duplicate = ItemNode.parse(duplicateInfo) + setNavigationDraftRobotCopySourceId(duplicate.id, node.id) } else if (node.type === 'wall') { duplicate = WallNode.parse(duplicateInfo) } else if (node.type === 'fence') { @@ -413,6 +417,9 @@ export function FloatingActionMenu() { const handleDelete = useCallback( (e: React.MouseEvent) => { e.stopPropagation() + if (node?.type === 'item' && requestNavigationItemDelete(node)) { + return + } if (!selectedId) return if (node?.type === 'item') { sfxEmitter.emit('sfx:item-delete') @@ -422,7 +429,24 @@ export function FloatingActionMenu() { setSelection({ selectedIds: [] }) useScene.getState().deleteNode(selectedId as AnyNodeId) }, - [node?.type, selectedId, setSelection], + [node, selectedId, setSelection], + ) + + const handleRepair = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!node || node.type !== 'item') { + return + } + + if (requestNavigationItemRepair(node)) { + return + } + + setHoveredId(null) + setSelection({ selectedIds: [] }) + }, + [node, setHoveredId, setSelection], ) if ( @@ -458,6 +482,7 @@ export function FloatingActionMenu() { : undefined } onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined} + onRepair={node?.type === 'item' ? handleRepair : undefined} onPointerDown={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index e33d856ae..8d7559ca9 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -50,9 +50,25 @@ import { } from 'react' import { createPortal } from 'react-dom' import { useShallow } from 'zustand/react/shallow' +import { measureNavigationPerf, mergeNavigationPerfMeta } from '../../lib/navigation-performance' import { sfxEmitter } from '../../lib/sfx-bus' import { cn } from '../../lib/utils' +import { + buildOverlayPathFromCells, + buildWalkableSurfaceOverlay, + filterWallOverlayCells, + getDoorPortalPolygon, + type getItemPlanTransform, + getWallAttachedItemDoorOpening, + isFloorBlockingItem, + WALKABLE_CELL_SIZE, + WALKABLE_CLEARANCE, + WALKABLE_FILL_OPACITY, + type WalkableSurfaceOverlay, +} from '../../lib/walkable-surface' import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor' +import useNavigation, { requestNavigationItemDelete } from '../../store/use-navigation' +import { setNavigationDraftRobotCopySourceId } from '../../store/use-navigation-drafts' import { snapToHalf } from '../tools/item/placement-math' import { DEFAULT_STAIR_ATTACHMENT_SIDE, @@ -74,8 +90,6 @@ import { furnishTools } from '../ui/action-menu/furnish-tools' import { tools as structureTools } from '../ui/action-menu/structure-tools' import { PALETTE_COLORS } from '../ui/primitives/color-dot' -import { Popover, PopoverContent, PopoverTrigger } from '../ui/primitives/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' import { NodeActionMenu } from './node-action-menu' const FALLBACK_VIEW_SIZE = 12 @@ -406,12 +420,22 @@ type FloorplanStairEntry = { segments: FloorplanStairSegmentEntry[] } +const EMPTY_WALKABLE_POLYGONS: Point2D[][] = [] +const EMPTY_WALKABLE_WALLS: WallNode[] = [] + +const EMPTY_WALKABLE_WALL_POLYGONS: Array<{ + polygon: Point2D[] + wall: WallNode +}> = [] + type FloorplanPalette = { surface: string minorGrid: string majorGrid: string minorGridOpacity: number majorGridOpacity: number + walkableFill: string + wallBlockedFill: string slabFill: string slabStroke: string selectedSlabFill: string @@ -2738,6 +2762,73 @@ const FloorplanGuideLayer = memo(function FloorplanGuideLayer({ ) }) +const FloorplanWalkableLayer = memo(function FloorplanWalkableLayer({ + overlay, + palette, +}: { + overlay: WalkableSurfaceOverlay | null + palette: FloorplanPalette +}) { + if (!overlay) { + return null + } + + return ( + + + Walkable area preview ({overlay.cellCount.toLocaleString()} cells, {WALKABLE_CELL_SIZE}m + cells, {WALKABLE_CLEARANCE}m clearance) + + + + ) +}) + +const FloorplanWallBlockedLayer = memo(function FloorplanWallBlockedLayer({ + overlay, + palette, +}: { + overlay: WalkableSurfaceOverlay | null + palette: FloorplanPalette +}) { + const wallOverlayFilters = useNavigation((state) => state.wallOverlayFilters) + const filteredWallCells = useMemo(() => { + if (!overlay) { + return [] + } + + return filterWallOverlayCells(overlay.wallDebugCells, wallOverlayFilters) + }, [overlay, wallOverlayFilters]) + const filteredWallOverlay = useMemo( + () => buildOverlayPathFromCells(filteredWallCells, WALKABLE_CELL_SIZE), + [filteredWallCells], + ) + + if (!(overlay && filteredWallOverlay.path.length > 0)) { + return null + } + + return ( + + + Wall-blocked area preview ({filteredWallCells.length.toLocaleString()} cells,{' '} + {WALKABLE_CELL_SIZE}m cells, {WALKABLE_CLEARANCE}m wall margin) + + + + ) +}) + function FloorplanGuideSelectionOverlay({ guide, isDarkMode, @@ -4984,6 +5075,9 @@ export function FloorplanPanel() { const movingNode = useEditor((state) => state.movingNode) const curvingWall = useEditor((state) => state.curvingWall) const curvingFence = useEditor((state) => state.curvingFence) + const navigationEnabled = useNavigation((state) => state.enabled) + const moveItemsEnabled = useNavigation((state) => state.moveItemsEnabled) + const walkableOverlayVisible = useNavigation((state) => state.walkableOverlayVisible) const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) const setPhase = useEditor((state) => state.setPhase) @@ -5303,6 +5397,7 @@ export function FloorplanPanel() { : null const floorplanWalls = useMemo(() => walls.map(getFloorplanWall), [walls]) const wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls]) + const shouldRenderWalkableOverlay = walkableOverlayVisible const wallById = useMemo(() => new Map(walls.map((wall) => [wall.id, wall] as const)), [walls]) const floorplanWallById = useMemo( () => new Map(floorplanWalls.map((wall) => [wall.id, wall] as const)), @@ -5353,6 +5448,18 @@ export function FloorplanPanel() { nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall)) return nextFloorplanWallById }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft]) + const displayWalls = useMemo( + () => + shouldRenderWalkableOverlay + ? walls.map((wall) => displayWallById.get(wall.id) ?? wall) + : EMPTY_WALKABLE_WALLS, + [displayWallById, shouldRenderWalkableOverlay, walls], + ) + const displayWallMiterData = useMemo( + () => + shouldRenderWalkableOverlay ? calculateLevelMiters(displayWalls) : EMPTY_WALL_MITER_DATA, + [displayWalls, shouldRenderWalkableOverlay], + ) const wallPolygons = useMemo( () => walls.map((wall) => { @@ -5396,6 +5503,16 @@ export function FloorplanPanel() { : entry, ) }, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons]) + const walkableWallPolygons = useMemo(() => { + if (!shouldRenderWalkableOverlay) { + return EMPTY_WALKABLE_WALL_POLYGONS + } + + return displayWalls.map((wall) => ({ + polygon: getWallPlanFootprint(wall, displayWallMiterData), + wall, + })) + }, [displayWallMiterData, displayWalls, shouldRenderWalkableOverlay]) const openingsPolygons = useMemo( () => @@ -5555,6 +5672,76 @@ export function FloorplanPanel() { ] }) }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) + const walkableObstaclePolygons = useMemo(() => { + if (!shouldRenderWalkableOverlay) { + return EMPTY_WALKABLE_POLYGONS + } + + const transformCache = new Map() + + return floorplanItems.flatMap((item) => { + const itemMetadata = + typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata) + ? (item.metadata as Record) + : null + if (itemMetadata?.isTransient === true) { + return [] + } + + if (!isFloorBlockingItem(item, levelDescendantNodeById)) { + return [] + } + + const transform = getItemFloorplanTransform(item, levelDescendantNodeById, transformCache) + if (!transform) { + return [] + } + + const [width, , depth] = getScaledDimensions(item) + return [getRotatedRectanglePolygon(transform.position, width, depth, transform.rotation)] + }) + }, [floorplanItems, levelDescendantNodeById, shouldRenderWalkableOverlay]) + const walkableDoorPortalPolygons = useMemo(() => { + if (!shouldRenderWalkableOverlay) { + return EMPTY_WALKABLE_POLYGONS + } + + const itemTransformCache = new Map>() + + return levelDescendantNodes.flatMap((node) => { + if (node.visible === false || !node.parentId) { + return [] + } + + const wall = displayFloorplanWallById.get(node.parentId as WallNode['id']) + if (!wall) { + return [] + } + + const opening = + node.type === 'door' + ? node + : node.type === 'item' + ? getWallAttachedItemDoorOpening( + node as ItemNode, + wall, + levelDescendantNodeById, + itemTransformCache, + ) + : null + if (!opening) { + return [] + } + + const polygon = getDoorPortalPolygon(wall, opening, WALKABLE_CLEARANCE) + return polygon.length >= 3 ? [polygon] : [] + }) + }, [ + displayFloorplanWallById, + levelDescendantNodeById, + levelDescendantNodes, + shouldRenderWalkableOverlay, + ]) const floorplanStairEntries = useMemo( () => floorplanStairs.flatMap((stair) => { @@ -5718,6 +5905,33 @@ export function FloorplanPanel() { : floorplanStairEntries, [floorplanPreviewStairEntry, floorplanStairEntries], ) + const walkableAreaOverlay = useMemo(() => { + if (!shouldRenderWalkableOverlay) { + return null + } + + return measureNavigationPerf('walkableOverlay2d.buildMs', () => + buildWalkableSurfaceOverlay( + displaySlabPolygons, + walkableWallPolygons.map(({ polygon }) => polygon), + walkableObstaclePolygons, + WALKABLE_CELL_SIZE, + WALKABLE_CLEARANCE, + walkableDoorPortalPolygons, + ), + ) + }, [ + displaySlabPolygons, + shouldRenderWalkableOverlay, + walkableDoorPortalPolygons, + walkableObstaclePolygons, + walkableWallPolygons, + ]) + useEffect(() => { + mergeNavigationPerfMeta({ + walkableOverlay2dCellCount: walkableAreaOverlay?.cellCount ?? 0, + }) + }, [walkableAreaOverlay]) const floorplanOpeningLocalY = useMemo(() => { if (movingNode?.type === 'door' || movingNode?.type === 'window') { return snapToHalf(movingNode.position[1]) @@ -6165,12 +6379,16 @@ export function FloorplanPanel() { if (levelChanged) { previousLevelIdRef.current = levelId ?? null hasUserAdjustedViewportRef.current = false - setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport)) + setViewport((current) => + floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport, + ) return } if (!hasUserAdjustedViewportRef.current) { - setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport)) + setViewport((current) => + floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport, + ) } }, [fittedViewport, levelId]) @@ -6371,6 +6589,8 @@ export function FloorplanPanel() { majorGrid: '#94a3b8', minorGridOpacity: 0.7, majorGridOpacity: 0.9, + walkableFill: '#34d399', + wallBlockedFill: '#f87171', slabFill: '#5f6483', slabStroke: '#71717a', selectedSlabFill: '#b7b5f7', @@ -6403,6 +6623,8 @@ export function FloorplanPanel() { majorGrid: '#475569', minorGridOpacity: 0.7, majorGridOpacity: 0.9, + walkableFill: '#16a34a', + wallBlockedFill: '#dc2626', slabFill: '#c4c4cc', slabStroke: '#52525b', selectedSlabFill: '#b7b5f7', @@ -6838,7 +7060,10 @@ export function FloorplanPanel() { }, [isStairBuildActive]) useEffect(() => { - if (!isItemPlacementPreviewActive) { + const isRobotCarryItemPreviewActive = + navigationEnabled && moveItemsEnabled && movingNode?.type === 'item' + + if (!isItemPlacementPreviewActive || isRobotCarryItemPreviewActive) { return } @@ -6869,7 +7094,7 @@ export function FloorplanPanel() { emitter.off('item:move', refreshFloorplanItemPreview as any) emitter.off('item:leave', refreshFloorplanItemPreview as any) } - }, [isItemPlacementPreviewActive]) + }, [isItemPlacementPreviewActive, moveItemsEnabled, movingNode?.type, navigationEnabled]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -8668,6 +8893,7 @@ export function FloorplanPanel() { try { const duplicate = ItemNodeSchema.parse(cloned) + setNavigationDraftRobotCopySourceId(duplicate.id, item.id) setMovingNode(duplicate) setSelection({ selectedIds: [] }) } catch (error) { @@ -8690,6 +8916,10 @@ export function FloorplanPanel() { return } + if (requestNavigationItemDelete(item)) { + return + } + sfxEmitter.emit('sfx:item-delete') deleteNode(item.id as AnyNodeId) setSelection({ selectedIds: [] }) @@ -9961,6 +10191,13 @@ export function FloorplanPanel() { wallPolygons={displayWallPolygons} /> + {shouldRenderWalkableOverlay ? ( + <> + + + + ) : null} + (null!) - const [gridY, setGridY] = useState(0) + const gridYRef = useRef(0) // Use custom raycasting for grid events (independent of mesh events) - useGridEvents(gridY) + useGridEvents(gridYRef) // Update cursor position from grid:move events useEffect(() => { @@ -142,7 +142,7 @@ export const Grid = ({ } const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta) gridRef.current.position.y = newY - setGridY(newY) + gridYRef.current = newY }) const showGrid = useViewer((state) => state.showGrid) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index a7591d905..6c17599e2 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -2,15 +2,27 @@ import { Icon } from '@iconify/react' import { + type AnyNode, + type AnyNodeId, + type ItemNode, initSpaceDetectionSync, initSpatialGridSync, spatialGridManager, + useLiveTransforms, useScene, } from '@pascal-app/core' -import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' import { + type HoverStyles, + InteractiveSystem, + useViewer, + Viewer, + ViewerRuntimeStateProvider, +} from '@pascal-app/viewer' +import { + lazy, memo, type ReactNode, + Suspense, useCallback, useEffect, useLayoutEffect, @@ -22,6 +34,12 @@ import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { useKeyboard } from '../../hooks/use-keyboard' +import { + buildPascalTruckNodeForScene, + isPascalTruckNode, + PASCAL_TRUCK_ITEM_NODE_ID, + stripPascalTruckFromSceneGraph, +} from '../../lib/pascal-truck' import { applySceneGraphToEditor, loadSceneFromLocalStorage, @@ -30,6 +48,8 @@ import { } from '../../lib/scene' import { initSFXBus } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import useNavigation from '../../store/use-navigation' +import navigationVisualsStore from '../../store/use-navigation-visuals' import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system' import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { RoofEditSystem } from '../systems/roof/roof-edit-system' @@ -64,7 +84,8 @@ import { Grid } from './grid' import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { SelectionManager } from './selection-manager' import { SiteEdgeLabels } from './site-edge-labels' -import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator' +import { ThumbnailGenerator } from './thumbnail-generator' +import { ToolConeOverlayViewer } from './tool-cone-overlay-viewer' import { WallMeasurementLabel } from './wall-measurement-label' const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' @@ -87,6 +108,16 @@ const EDITOR_HOVER_STYLES: HoverStyles = { }, } +const NavigationPanel = lazy(async () => { + const module = await import('../ui/panels/navigation-panel') + return { default: module.NavigationPanel } +}) + +const NavigationRuntime = lazy(async () => { + const module = await import('./navigation-system') + return { default: module.NavigationSystem } +}) + /** * Wire up module-level singletons (spatial grid, space detection, SFX) for * an Editor mount. Returns a teardown function that detaches the scene-store @@ -110,6 +141,36 @@ function initializeEditorRuntime(): () => void { outliner.hoveredObjects.length = 0 } } + +function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { + if (typeof structuredClone === 'function') { + return structuredClone(sceneGraph) + } + + return JSON.parse(JSON.stringify(sceneGraph)) as SceneGraph +} + +function hasTaskModeSceneContent( + sceneGraph: SceneGraph | null | undefined, +): sceneGraph is SceneGraph { + if ( + !sceneGraph || + !Array.isArray(sceneGraph.rootNodeIds) || + sceneGraph.rootNodeIds.length === 0 + ) { + return false + } + + return Object.values(sceneGraph.nodes ?? {}).some((node) => { + if (!node || typeof node !== 'object' || Array.isArray(node)) { + return false + } + + const type = (node as { type?: unknown }).type + return typeof type === 'string' && type !== 'site' && type !== 'building' && type !== 'level' + }) +} + export interface EditorProps { // Layout version — 'v1' (default) or 'v2' (navbar + two-column) layoutVersion?: 'v1' | 'v2' @@ -140,7 +201,7 @@ export interface EditorProps { isLoading?: boolean // Thumbnail - onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void + onThumbnailCapture?: (blob: Blob) => void // Version preview overlays (rendered by host app) sidebarOverlay?: ReactNode @@ -337,6 +398,15 @@ const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [ { action: 'Zoom', keys: [{ value: 'Scroll' }] }, ] +const SIMPLE_ROBOT_CAMERA_CONTROL_HINTS: CameraControlHint[] = [ + { + action: 'Pan', + keys: [{ value: 'Space' }, { value: 'Left click' }], + }, + { action: 'Rotate/Move Robot', keys: [{ value: 'Right click' }] }, + { action: 'Zoom', keys: [{ value: 'Scroll' }] }, +] + const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [ { action: 'Pan', keys: [{ value: 'Left click' }] }, { action: 'Rotate', keys: [{ value: 'Right click' }] }, @@ -450,13 +520,19 @@ function CameraControlHintItem({ hint }: { hint: CameraControlHint }) { } function ViewerCanvasControlsHint({ + isSimpleRobotMode, isPreviewMode, onDismiss, }: { + isSimpleRobotMode: boolean isPreviewMode: boolean onDismiss: () => void }) { - const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS + const hints = isPreviewMode + ? PREVIEW_CAMERA_CONTROL_HINTS + : isSimpleRobotMode + ? SIMPLE_ROBOT_CAMERA_CONTROL_HINTS + : EDITOR_CAMERA_CONTROL_HINTS return (
@@ -575,11 +651,13 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ isLoading, isFirstPersonMode, onThumbnailCapture, + robotModeActive, }: { isVersionPreviewMode: boolean isLoading: boolean isFirstPersonMode: boolean - onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void + onThumbnailCapture?: (blob: Blob) => void + robotModeActive: boolean }) { return ( <> @@ -597,6 +675,11 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!isLoading && !isFirstPersonMode && ( )} + {!isLoading && !isFirstPersonMode && robotModeActive && ( + + + + )} {!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && } {isFirstPersonMode && } @@ -796,12 +879,14 @@ const ViewerCanvas = memo(function ViewerCanvas({ hasLoadedInitialScene: boolean showLoader: boolean isFirstPersonMode: boolean - onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void + onThumbnailCapture?: (blob: Blob) => void }) { const viewMode = useEditor((s) => s.viewMode) const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio) const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio) const isPreviewMode = useEditor((s) => s.isPreviewMode) + const robotMode = useNavigation((state) => state.robotMode) + const robotModeActive = robotMode !== null const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState( null, @@ -891,22 +976,27 @@ const ViewerCanvas = memo(function ViewerCanvas({ /> {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? ( ) : null} - - - + + + + +
{!(isLoading || isVersionPreviewMode) && } @@ -941,21 +1031,118 @@ export default function Editor({ }: EditorProps) { useKeyboard({ isVersionPreviewMode }) + const robotMode = useNavigation((state) => state.robotMode) + const taskLoopToken = useNavigation((state) => state.taskLoopToken) + const [taskModeSceneRestorePending, setTaskModeSceneRestorePending] = useState(false) + const { isLoadingSceneRef } = useAutoSave({ onSave, onDirty, onSaveStatusChange, isVersionPreviewMode, + suppressSave: robotMode === 'task' || taskModeSceneRestorePending, }) const [isSceneLoading, setIsSceneLoading] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) const isPreviewMode = useEditor((s) => s.isPreviewMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) + const previousRobotModeRef = useRef(robotMode) + const previousTaskLoopTokenRef = useRef(taskLoopToken) + const pascalTruckNodeRef = useRef(null) + const taskModeSceneSnapshotRef = useRef(null) const sidebarWidth = useSidebarStore((s) => s.width) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) + const stripPascalTruckFromScene = useCallback( + (sceneGraph?: SceneGraph | null): SceneGraph | null => { + const { sceneGraph: sanitizedSceneGraph, truckNode } = + stripPascalTruckFromSceneGraph(sceneGraph) + if (truckNode) { + pascalTruckNodeRef.current = truckNode + } + + return (sanitizedSceneGraph as SceneGraph | null | undefined) ?? null + }, + [], + ) + + const captureCurrentSceneGraph = useCallback((): SceneGraph => { + const sceneState = useScene.getState() + const sceneGraph = cloneSceneGraph({ + nodes: sceneState.nodes as SceneGraph['nodes'], + rootNodeIds: [...sceneState.rootNodeIds] as SceneGraph['rootNodeIds'], + }) + return stripPascalTruckFromScene(sceneGraph) ?? sceneGraph + }, [stripPascalTruckFromScene]) + + const restoreTaskModeSceneSnapshot = useCallback( + (options?: { clearSnapshot?: boolean; settledToken?: number }) => { + const finalizeRestore = () => { + if (options?.clearSnapshot) { + taskModeSceneSnapshotRef.current = null + } + if (typeof options?.settledToken === 'number') { + useNavigation.getState().setTaskLoopSettledToken(options.settledToken) + } + } + + const snapshot = taskModeSceneSnapshotRef.current + if (!hasTaskModeSceneContent(snapshot)) { + const currentScene = captureCurrentSceneGraph() + taskModeSceneSnapshotRef.current = hasTaskModeSceneContent(currentScene) + ? currentScene + : null + finalizeRestore() + return false + } + + isLoadingSceneRef.current = true + setTaskModeSceneRestorePending(true) + useLiveTransforms.getState().clearAll() + const nextSceneGraph = cloneSceneGraph(snapshot) + if (useNavigation.getState().robotMode === 'task') { + const hasTruckNode = Object.values(nextSceneGraph.nodes).some((node) => + isPascalTruckNode(node), + ) + if (!hasTruckNode) { + const { node, parentId } = buildPascalTruckNodeForScene( + nextSceneGraph, + pascalTruckNodeRef.current, + ) + if (parentId) { + nextSceneGraph.nodes[node.id] = node + const parentNode = nextSceneGraph.nodes[parentId] + if (parentNode && typeof parentNode === 'object' && parentNode !== null) { + const parentRecord = parentNode as { children?: unknown } + const nextChildren = Array.isArray(parentRecord.children) + ? [...parentRecord.children] + : [] + if (!nextChildren.includes(node.id)) { + nextChildren.push(node.id) + } + nextSceneGraph.nodes[parentId] = { + ...parentNode, + children: nextChildren, + } + } + } + } + } + applySceneGraphToEditor(nextSceneGraph, { + mode: useNavigation.getState().robotMode === 'task' ? 'task-loop' : 'full', + }) + requestAnimationFrame(() => { + isLoadingSceneRef.current = false + setTaskModeSceneRestorePending(false) + finalizeRestore() + }) + return true + }, + [captureCurrentSceneGraph, isLoadingSceneRef], + ) + useEffect(() => { const teardown = initializeEditorRuntime() return teardown @@ -979,12 +1166,23 @@ export default function Editor({ setIsSceneLoading(true) try { - const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage() + const loadedSceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage() + const sceneGraph = stripPascalTruckFromScene(loadedSceneGraph) if (!cancelled) { applySceneGraphToEditor(sceneGraph) + if (useNavigation.getState().robotMode === 'task') { + taskModeSceneSnapshotRef.current = hasTaskModeSceneContent(sceneGraph) + ? cloneSceneGraph(sceneGraph) + : null + } } } catch { - if (!cancelled) applySceneGraphToEditor(null) + if (!cancelled) { + applySceneGraphToEditor(null) + if (useNavigation.getState().robotMode === 'task') { + taskModeSceneSnapshotRef.current = null + } + } } finally { if (!cancelled) { setIsSceneLoading(false) @@ -1001,14 +1199,14 @@ export default function Editor({ return () => { cancelled = true } - }, [onLoad, isLoadingSceneRef]) + }, [isLoadingSceneRef, onLoad, stripPascalTruckFromScene]) // Apply preview scene when version preview mode changes useEffect(() => { if (isVersionPreviewMode && previewScene) { - applySceneGraphToEditor(previewScene) + applySceneGraphToEditor(stripPascalTruckFromScene(previewScene)) } - }, [isVersionPreviewMode, previewScene]) + }, [isVersionPreviewMode, previewScene, stripPascalTruckFromScene]) // Lock scene graph and reset to select mode when entering version preview useEffect(() => { @@ -1021,6 +1219,60 @@ export default function Editor({ } }, [isVersionPreviewMode]) + useEffect(() => { + if (!hasLoadedInitialScene || isVersionPreviewMode || taskModeSceneRestorePending) { + return + } + + const sceneState = useScene.getState() + const currentSceneGraph = cloneSceneGraph({ + nodes: sceneState.nodes as SceneGraph['nodes'], + rootNodeIds: [...sceneState.rootNodeIds] as SceneGraph['rootNodeIds'], + }) + const existingTruckNode = + (Object.values(currentSceneGraph.nodes).find((node) => + isPascalTruckNode(node), + ) as ItemNode | null) ?? null + + if (existingTruckNode) { + pascalTruckNodeRef.current = cloneSceneGraph({ + nodes: { [existingTruckNode.id]: existingTruckNode }, + rootNodeIds: [], + }).nodes[existingTruckNode.id] as ItemNode + } + + if (robotMode === null) { + if (existingTruckNode) { + sceneState.deleteNode(existingTruckNode.id as AnyNodeId) + } + return + } + + if (existingTruckNode?.id === PASCAL_TRUCK_ITEM_NODE_ID) { + return + } + + const { node, parentId } = buildPascalTruckNodeForScene( + currentSceneGraph, + pascalTruckNodeRef.current, + ) + if (!parentId) { + return + } + + if (existingTruckNode) { + sceneState.deleteNode(existingTruckNode.id as AnyNodeId) + } + + sceneState.createNode(node as AnyNode, parentId as AnyNodeId) + }, [ + hasLoadedInitialScene, + isVersionPreviewMode, + robotMode, + taskLoopToken, + taskModeSceneRestorePending, + ]) + useEffect(() => { document.body.classList.add('dark') return () => { @@ -1028,6 +1280,33 @@ export default function Editor({ } }, []) + useEffect(() => { + const previousRobotMode = previousRobotModeRef.current + if (previousRobotMode !== 'task' && robotMode === 'task') { + const currentScene = captureCurrentSceneGraph() + taskModeSceneSnapshotRef.current = hasTaskModeSceneContent(currentScene) ? currentScene : null + useNavigation.getState().setTaskLoopSettledToken(useNavigation.getState().taskLoopToken) + } else if (previousRobotMode === 'task' && robotMode !== 'task') { + restoreTaskModeSceneSnapshot({ clearSnapshot: true }) + } + + previousRobotModeRef.current = robotMode + }, [captureCurrentSceneGraph, restoreTaskModeSceneSnapshot, robotMode]) + + useEffect(() => { + const previousTaskLoopToken = previousTaskLoopTokenRef.current + if (previousTaskLoopToken === taskLoopToken) { + return + } + + previousTaskLoopTokenRef.current = taskLoopToken + if (robotMode !== 'task') { + return + } + + restoreTaskModeSceneSnapshot({ settledToken: taskLoopToken }) + }, [restoreTaskModeSceneSnapshot, robotMode, taskLoopToken]) + const showLoader = isLoading || isSceneLoading const previewViewerContent = ( @@ -1106,6 +1385,13 @@ export default function Editor({ )} + {!isVersionPreviewMode && !isFirstPersonMode && robotMode && ( +
+ + + +
+ )}
diff --git a/packages/editor/src/components/editor/navigation-door-system.tsx b/packages/editor/src/components/editor/navigation-door-system.tsx new file mode 100644 index 000000000..e6cdd77ae --- /dev/null +++ b/packages/editor/src/components/editor/navigation-door-system.tsx @@ -0,0 +1,749 @@ +'use client' + +import { sceneRegistry } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { type MutableRefObject, useEffect, useMemo, useRef } from 'react' +import { Box3, type Curve, Euler, MathUtils, Matrix4, type Object3D, Vector2, Vector3 } from 'three' +import { + getNavigationDoorTransitions, + type NavigationDoorTransition, + type NavigationGraph, + type NavigationPathResult, +} from '../../lib/navigation' +import { + measureNavigationPerf, + mergeNavigationPerfMeta, + recordNavigationPerfSample, +} from '../../lib/navigation-performance' + +const DOOR_APPROACH_OPEN_DISTANCE = 1.15 +const DOOR_EXIT_CLOSE_DISTANCE = 1.65 +const DOOR_SWING_RESPONSE = 10 +const DOOR_OVERHEAD_APPROACH_OPEN_DISTANCE = DOOR_APPROACH_OPEN_DISTANCE * 2 +const DOOR_OVERHEAD_EXIT_CLOSE_DISTANCE = DOOR_EXIT_CLOSE_DISTANCE * 2 +const DOOR_OVERHEAD_OPEN_RESPONSE = 4 +const DOOR_OVERHEAD_CLOSE_RESPONSE = DOOR_OVERHEAD_OPEN_RESPONSE / 2 +const DOOR_ROTATION_SETTLE_EPSILON = MathUtils.degToRad(1) +const DOOR_POSITION_SETTLE_EPSILON = 0.01 +const ITEM_DOOR_OPEN_ANGLE = MathUtils.degToRad(170) +const DOOR_SWING_TARGET_OPEN_FRACTION = 0.86 +const DOOR_OVERHEAD_TARGET_OPEN_FRACTION = 0.72 +const DOOR_OPEN_LEAD_PADDING_SECONDS = 0.18 +const DOOR_OVERHEAD_OPEN_LEAD_PADDING_SECONDS = 0.22 +const DOOR_CLOSE_PADDING_SECONDS = 0.18 +const DOOR_TRIGGER_REFERENCE_SPEED = 1.4 +const DOOR_TRIGGER_MAX_SPEED = 3.6 +const activeNavigationDoorIds = new Set() + +type DoorAnimationState = { + alternateOpenPosition?: [number, number, number] + alternateOpenRotation?: [number, number, number] + closedPosition?: [number, number, number] + closedRotation?: [number, number, number] + localBounds?: { + max: [number, number, number] + min: [number, number, number] + } + openPosition?: [number, number, number] + openRotation?: [number, number, number] + style?: 'overhead' | 'swing' +} + +type MotionStateRef = MutableRefObject<{ + destinationCellIndex: number | null + distance: number + moving: boolean + speed: number +}> + +type NavigationDoorSystemProps = { + enabled: boolean + graph: NavigationGraph + motionRef: MotionStateRef + motionCurve: Curve | null + pathIndices: NavigationPathResult['indices'] + pathLength: number +} + +export function getActiveNavigationDoorIds() { + return activeNavigationDoorIds +} + +type DoorOpenTarget = { + desiredWorldSide: Vector2 + openingId: string + openingWorld: [number, number, number] +} + +type DoorOpenTargetSelection = { + openingId: string + projection: number | null + variant: 'alternate' | 'primary' +} + +function getObjectBoundsInParentSpace(object: Object3D, parent: Object3D) { + object.updateWorldMatrix(true, true) + parent.updateWorldMatrix(true, true) + + const inverseParentMatrix = new Matrix4().copy(parent.matrixWorld).invert() + const bounds = new Box3() + let initialized = false + + object.traverse((child) => { + if ( + !('geometry' in child) || + !(child as { geometry?: { boundingBox?: Box3 | null } }).geometry + ) { + return + } + + const mesh = child as Object3D & { + geometry: { boundingBox?: Box3 | null; computeBoundingBox: () => void } + matrixWorld: Matrix4 + } + mesh.geometry.computeBoundingBox() + const childBounds = mesh.geometry.boundingBox?.clone() + if (!childBounds) { + return + } + + const childMatrixInParentSpace = new Matrix4().multiplyMatrices( + inverseParentMatrix, + mesh.matrixWorld, + ) + childBounds.applyMatrix4(childMatrixInParentSpace) + + if (initialized) { + bounds.union(childBounds) + } else { + bounds.copy(childBounds) + initialized = true + } + }) + + return initialized ? bounds : null +} + +function ensureItemDoorAnimationState(doorId: string) { + const doorRoot = sceneRegistry.nodes.get(doorId) + const leafPivot = doorRoot?.getObjectByName('door-leaf-pivot') + const currentAnimationState = leafPivot?.userData.navigationDoor as DoorAnimationState | undefined + + if (!sceneRegistry.byType.item.has(doorId) || !(doorRoot && leafPivot)) { + return { + animationState: currentAnimationState, + doorRoot, + leafPivot, + } + } + + if (currentAnimationState?.localBounds) { + return { + animationState: currentAnimationState, + doorRoot, + leafPivot, + } + } + + const leafGroup = leafPivot.getObjectByName('door-leaf-group') + if (!leafGroup) { + return { + animationState: currentAnimationState, + doorRoot, + leafPivot, + } + } + + const hingeHint = leafPivot.getObjectByName('door-leaf-hinge-hint') + const initialBounds = getObjectBoundsInParentSpace(leafGroup, leafPivot) + if (!initialBounds) { + return { + animationState: currentAnimationState, + doorRoot, + leafPivot, + } + } + + const hingeX = + hingeHint?.position.x ?? + (Math.abs(initialBounds.min.x) <= Math.abs(initialBounds.max.x) + ? initialBounds.min.x + : initialBounds.max.x) + + leafPivot.position.set(hingeX, 0, 0) + leafPivot.rotation.set(0, 0, 0) + leafGroup.position.set(-hingeX, 0, 0) + leafGroup.rotation.set(0, 0, 0) + + const localBounds = getObjectBoundsInParentSpace(leafGroup, leafPivot) ?? initialBounds + const animationState: DoorAnimationState = { + alternateOpenPosition: [hingeX, 0, 0], + alternateOpenRotation: [0, -ITEM_DOOR_OPEN_ANGLE, 0], + closedPosition: [hingeX, 0, 0], + closedRotation: [0, 0, 0], + localBounds: { + max: [localBounds.max.x, localBounds.max.y, localBounds.max.z], + min: [localBounds.min.x, localBounds.min.y, localBounds.min.z], + }, + openPosition: [hingeX, 0, 0], + openRotation: [0, ITEM_DOOR_OPEN_ANGLE, 0], + style: 'swing', + } + leafPivot.userData.navigationDoor = animationState + + return { + animationState, + doorRoot, + leafPivot, + } +} + +function getDoorAnimationState(doorId: string) { + if (sceneRegistry.byType.item.has(doorId)) { + return ensureItemDoorAnimationState(doorId) + } + + const doorRoot = sceneRegistry.nodes.get(doorId) + const leafPivot = doorRoot?.getObjectByName('door-leaf-pivot') + const animationState = leafPivot?.userData.navigationDoor as DoorAnimationState | undefined + + return { + animationState, + doorRoot, + leafPivot, + } +} + +function getDoorAnimationDelta(doorId: string) { + const { animationState, leafPivot } = getDoorAnimationState(doorId) + if (!leafPivot) { + return 0 + } + + const closedRotation = animationState?.closedRotation ?? [0, 0, 0] + const closedPosition = animationState?.closedPosition ?? [ + leafPivot.position.x, + leafPivot.position.y, + leafPivot.position.z, + ] + + return Math.max( + Math.abs(leafPivot.rotation.x - closedRotation[0]!), + Math.abs(leafPivot.rotation.y - closedRotation[1]!), + Math.abs(leafPivot.rotation.z - closedRotation[2]!), + Math.abs(leafPivot.position.x - closedPosition[0]!), + Math.abs(leafPivot.position.y - closedPosition[1]!), + Math.abs(leafPivot.position.z - closedPosition[2]!), + ) +} + +function getDoorDesiredOpenTarget(doorTrigger: NavigationDoorTransition): DoorOpenTarget | null { + const preferredSide = new Vector2( + doorTrigger.departureWorld[0] - doorTrigger.world[0], + doorTrigger.departureWorld[2] - doorTrigger.world[2], + ) + + if (preferredSide.lengthSq() <= Number.EPSILON) { + preferredSide.set( + doorTrigger.exitWorld[0] - doorTrigger.world[0], + doorTrigger.exitWorld[2] - doorTrigger.world[2], + ) + } + + if (preferredSide.lengthSq() <= Number.EPSILON) { + return null + } + + preferredSide.normalize() + return { + desiredWorldSide: preferredSide, + openingId: doorTrigger.openingId, + openingWorld: doorTrigger.world, + } +} + +function getDoorLeafCentroidWorld( + leafPivot: Object3D, + animationState: DoorAnimationState, + rotation: [number, number, number], + position: [number, number, number], +) { + const parent = leafPivot.parent + if (!parent) { + return null + } + + const localBounds = animationState.localBounds + const localCenter = localBounds + ? new Vector3( + (localBounds.min[0] + localBounds.max[0]) / 2, + (localBounds.min[1] + localBounds.max[1]) / 2, + (localBounds.min[2] + localBounds.max[2]) / 2, + ) + : new Vector3() + + const localPoint = localCenter + .clone() + .applyEuler(new Euler(rotation[0], rotation[1], rotation[2], 'XYZ')) + .add(new Vector3(position[0], position[1], position[2])) + + return parent.localToWorld(localPoint) +} + +function getPreferredSwingDoorTarget( + leafPivot: Object3D, + animationState: DoorAnimationState, + openTarget: DoorOpenTarget | null, +) { + const closedRotation = animationState.closedRotation ?? [0, 0, 0] + const closedPosition = animationState.closedPosition ?? [ + leafPivot.position.x, + leafPivot.position.y, + leafPivot.position.z, + ] + const primaryRotation = animationState.openRotation ?? closedRotation + const primaryPosition = animationState.openPosition ?? closedPosition + const alternateRotation = animationState.alternateOpenRotation + const alternatePosition = animationState.alternateOpenPosition + + if ( + animationState.style !== 'swing' || + !openTarget || + !(alternateRotation && alternatePosition) + ) { + return { + projection: null, + targetPosition: primaryPosition, + targetRotation: primaryRotation, + variant: 'primary' as const, + } + } + + const primaryCentroid = getDoorLeafCentroidWorld( + leafPivot, + animationState, + primaryRotation, + primaryPosition, + ) + const alternateCentroid = getDoorLeafCentroidWorld( + leafPivot, + animationState, + alternateRotation, + alternatePosition, + ) + + if (!(primaryCentroid && alternateCentroid)) { + return { + projection: null, + targetPosition: primaryPosition, + targetRotation: primaryRotation, + variant: 'primary' as const, + } + } + + const projectSide = (centroid: Vector3) => + (centroid.x - openTarget.openingWorld[0]) * openTarget.desiredWorldSide.x + + (centroid.z - openTarget.openingWorld[2]) * openTarget.desiredWorldSide.y + + const primaryScore = projectSide(primaryCentroid) + const alternateScore = projectSide(alternateCentroid) + + if (alternateScore > primaryScore) { + return { + projection: alternateScore, + targetPosition: alternatePosition, + targetRotation: alternateRotation, + variant: 'alternate' as const, + } + } + + return { + projection: primaryScore, + targetPosition: primaryPosition, + targetRotation: primaryRotation, + variant: 'primary' as const, + } +} + +function getDoorTriggerWindowDistances(doorIds: string[], currentSpeed: number) { + let isOverheadDoor = false + for (const doorId of doorIds) { + const { animationState } = getDoorAnimationState(doorId) + if (animationState?.style === 'overhead') { + isOverheadDoor = true + break + } + } + + const baseApproachDistance = isOverheadDoor + ? DOOR_OVERHEAD_APPROACH_OPEN_DISTANCE + : DOOR_APPROACH_OPEN_DISTANCE + const baseCloseDistance = isOverheadDoor + ? DOOR_OVERHEAD_EXIT_CLOSE_DISTANCE + : DOOR_EXIT_CLOSE_DISTANCE + const response = isOverheadDoor ? DOOR_OVERHEAD_OPEN_RESPONSE : DOOR_SWING_RESPONSE + const targetOpenFraction = isOverheadDoor + ? DOOR_OVERHEAD_TARGET_OPEN_FRACTION + : DOOR_SWING_TARGET_OPEN_FRACTION + const leadPaddingSeconds = isOverheadDoor + ? DOOR_OVERHEAD_OPEN_LEAD_PADDING_SECONDS + : DOOR_OPEN_LEAD_PADDING_SECONDS + const anticipatedSpeed = MathUtils.clamp( + Math.max(currentSpeed, DOOR_TRIGGER_REFERENCE_SPEED), + 0, + DOOR_TRIGGER_MAX_SPEED, + ) + const normalizedUnopenedFraction = Math.max(1 - targetOpenFraction, 1e-3) + const responseLeadSeconds = -Math.log(normalizedUnopenedFraction) / Math.max(response, 1e-3) + const approachDistance = Math.max( + baseApproachDistance, + anticipatedSpeed * (responseLeadSeconds + leadPaddingSeconds) + 0.45, + ) + const closeDistance = Math.max( + baseCloseDistance, + anticipatedSpeed * DOOR_CLOSE_PADDING_SECONDS + 0.35, + ) + + return { + approachDistance, + closeDistance, + } +} + +function getInitialOverheadDoorOpenAmount( + leafPivot: Object3D, + closedRotation: [number, number, number], + openRotation: [number, number, number], + closedPosition: [number, number, number], + openPosition: [number, number, number], +) { + const progressCandidates = [ + Math.abs(openRotation[0] - closedRotation[0]) > Number.EPSILON + ? (leafPivot.rotation.x - closedRotation[0]) / (openRotation[0] - closedRotation[0]) + : null, + Math.abs(openRotation[1] - closedRotation[1]) > Number.EPSILON + ? (leafPivot.rotation.y - closedRotation[1]) / (openRotation[1] - closedRotation[1]) + : null, + Math.abs(openRotation[2] - closedRotation[2]) > Number.EPSILON + ? (leafPivot.rotation.z - closedRotation[2]) / (openRotation[2] - closedRotation[2]) + : null, + Math.abs(openPosition[0] - closedPosition[0]) > Number.EPSILON + ? (leafPivot.position.x - closedPosition[0]) / (openPosition[0] - closedPosition[0]) + : null, + Math.abs(openPosition[1] - closedPosition[1]) > Number.EPSILON + ? (leafPivot.position.y - closedPosition[1]) / (openPosition[1] - closedPosition[1]) + : null, + Math.abs(openPosition[2] - closedPosition[2]) > Number.EPSILON + ? (leafPivot.position.z - closedPosition[2]) / (openPosition[2] - closedPosition[2]) + : null, + ].filter((value): value is number => value !== null && Number.isFinite(value)) + + if (progressCandidates.length === 0) { + return 0 + } + + const averageProgress = + progressCandidates.reduce((sum, value) => sum + value, 0) / progressCandidates.length + + return MathUtils.clamp(averageProgress, 0, 1) +} + +function getInterpolatedDoorTransform( + closedRotation: [number, number, number], + openRotation: [number, number, number], + closedPosition: [number, number, number], + openPosition: [number, number, number], + openAmount: number, +) { + const clampedOpenAmount = MathUtils.clamp(openAmount, 0, 1) + + return { + position: [ + MathUtils.lerp(closedPosition[0]!, openPosition[0]!, clampedOpenAmount), + MathUtils.lerp(closedPosition[1]!, openPosition[1]!, clampedOpenAmount), + MathUtils.lerp(closedPosition[2]!, openPosition[2]!, clampedOpenAmount), + ] as [number, number, number], + rotation: [ + MathUtils.lerp(closedRotation[0]!, openRotation[0]!, clampedOpenAmount), + MathUtils.lerp(closedRotation[1]!, openRotation[1]!, clampedOpenAmount), + MathUtils.lerp(closedRotation[2]!, openRotation[2]!, clampedOpenAmount), + ] as [number, number, number], + } +} + +export function NavigationDoorSystem({ + enabled, + graph, + motionRef, + motionCurve, + pathIndices, + pathLength, +}: NavigationDoorSystemProps) { + const trackedDoorIdsRef = useRef(new Set()) + const doorOpenSelectionsRef = useRef(new Map()) + const overheadDoorOpenAmountsRef = useRef(new Map()) + const previewDoorOpenAmountsRef = useRef(new Map()) + const doorTriggers = useMemo( + () => + measureNavigationPerf('navigation.doorTriggerBuildMs', () => + getNavigationDoorTransitions(graph, pathIndices), + ), + [graph, pathIndices], + ) + const doorTriggerDistances = useMemo( + () => + measureNavigationPerf('navigation.doorTriggerDistanceBuildMs', () => { + if (!(motionCurve && pathLength > Number.EPSILON && doorTriggers.length > 0)) { + return [] + } + + const sampleCount = Math.max(128, Math.ceil(pathLength / 0.06)) + const sampledPoint = new Vector3() + const triggerPoint = new Vector3() + + return doorTriggers.map((doorTrigger) => { + let bestDistanceAlongCurve = 0 + let bestDistanceSq = Number.POSITIVE_INFINITY + triggerPoint.set(doorTrigger.world[0], doorTrigger.world[1], doorTrigger.world[2]) + + for (let sampleIndex = 0; sampleIndex <= sampleCount; sampleIndex += 1) { + const sampleProgress = sampleIndex / sampleCount + motionCurve.getPointAt(sampleProgress, sampledPoint) + const distanceSq = sampledPoint.distanceToSquared(triggerPoint) + if (distanceSq < bestDistanceSq) { + bestDistanceSq = distanceSq + bestDistanceAlongCurve = sampleProgress * pathLength + } + } + + return { + doorTrigger, + triggerDistance: bestDistanceAlongCurve, + } + }) + }), + [doorTriggers, motionCurve, pathLength], + ) + + useEffect(() => { + mergeNavigationPerfMeta({ + navigationDoorTriggerCount: doorTriggers.length, + }) + }, [doorTriggers.length]) + + useFrame((_, delta) => { + const frameStart = performance.now() + const currentDistance = + enabled && pathLength > Number.EPSILON + ? MathUtils.clamp(motionRef.current.distance, 0, pathLength) + : null + const openDoorIds = new Set() + const openTargetsByDoorId = new Map() + + if (currentDistance !== null) { + for (const { doorTrigger, triggerDistance } of doorTriggerDistances) { + const { approachDistance, closeDistance } = getDoorTriggerWindowDistances( + doorTrigger.doorIds, + motionRef.current.speed, + ) + + if ( + currentDistance >= triggerDistance - approachDistance && + currentDistance <= triggerDistance + closeDistance + ) { + const openTarget = getDoorDesiredOpenTarget(doorTrigger) + for (const doorId of doorTrigger.doorIds) { + openDoorIds.add(doorId) + if (openTarget) { + openTargetsByDoorId.set(doorId, openTarget) + } + } + } + } + } + + const activeDoorIds = new Set([...trackedDoorIdsRef.current, ...openDoorIds]) + activeNavigationDoorIds.clear() + for (const doorId of activeDoorIds) { + activeNavigationDoorIds.add(doorId) + } + let openDoorCount = 0 + + for (const doorId of activeDoorIds) { + const { animationState, leafPivot } = getDoorAnimationState(doorId) + if (!leafPivot) { + trackedDoorIdsRef.current.delete(doorId) + overheadDoorOpenAmountsRef.current.delete(doorId) + continue + } + + const closedRotation = animationState?.closedRotation ?? [0, 0, 0] + const openRotation = animationState?.openRotation ?? closedRotation + const closedPosition = animationState?.closedPosition ?? [ + leafPivot.position.x, + leafPivot.position.y, + leafPivot.position.z, + ] + const openPosition = animationState?.openPosition ?? closedPosition + const preferredOpenTarget = getPreferredSwingDoorTarget( + leafPivot, + animationState ?? {}, + openTargetsByDoorId.get(doorId) ?? null, + ) + const isOverheadDoor = animationState?.style === 'overhead' + const previewOpenAmount = previewDoorOpenAmountsRef.current.get(doorId) + let targetRotation = openDoorIds.has(doorId) + ? preferredOpenTarget.targetRotation + : closedRotation + let targetPosition = openDoorIds.has(doorId) + ? preferredOpenTarget.targetPosition + : closedPosition + + if (openDoorIds.has(doorId)) { + const openTarget = openTargetsByDoorId.get(doorId) + if (openTarget) { + doorOpenSelectionsRef.current.set(doorId, { + openingId: openTarget.openingId, + projection: preferredOpenTarget.projection, + variant: preferredOpenTarget.variant, + }) + } + } else { + doorOpenSelectionsRef.current.delete(doorId) + } + + if (typeof previewOpenAmount === 'number') { + const previewTransform = getInterpolatedDoorTransform( + closedRotation, + openRotation, + closedPosition, + openPosition, + previewOpenAmount, + ) + leafPivot.rotation.set(...previewTransform.rotation) + leafPivot.position.set(...previewTransform.position) + trackedDoorIdsRef.current.add(doorId) + } else if (isOverheadDoor) { + const targetOpenAmount = openDoorIds.has(doorId) ? 1 : 0 + const currentOpenAmount = + overheadDoorOpenAmountsRef.current.get(doorId) ?? + getInitialOverheadDoorOpenAmount( + leafPivot, + closedRotation, + openRotation, + closedPosition, + openPosition, + ) + const overheadResponse = + targetOpenAmount < currentOpenAmount + ? DOOR_OVERHEAD_CLOSE_RESPONSE + : DOOR_OVERHEAD_OPEN_RESPONSE + const nextOpenAmount = MathUtils.damp( + currentOpenAmount, + targetOpenAmount, + overheadResponse, + delta, + ) + overheadDoorOpenAmountsRef.current.set(doorId, nextOpenAmount) + + const overheadTransform = getInterpolatedDoorTransform( + closedRotation, + openRotation, + closedPosition, + openPosition, + nextOpenAmount, + ) + targetRotation = overheadTransform.rotation + targetPosition = overheadTransform.position + + leafPivot.rotation.set(...targetRotation) + leafPivot.position.set(...targetPosition) + } else { + overheadDoorOpenAmountsRef.current.delete(doorId) + + leafPivot.rotation.x = MathUtils.damp( + leafPivot.rotation.x, + targetRotation[0]!, + DOOR_SWING_RESPONSE, + delta, + ) + leafPivot.rotation.y = MathUtils.damp( + leafPivot.rotation.y, + targetRotation[1]!, + DOOR_SWING_RESPONSE, + delta, + ) + leafPivot.rotation.z = MathUtils.damp( + leafPivot.rotation.z, + targetRotation[2]!, + DOOR_SWING_RESPONSE, + delta, + ) + leafPivot.position.x = MathUtils.damp( + leafPivot.position.x, + targetPosition[0]!, + DOOR_SWING_RESPONSE, + delta, + ) + leafPivot.position.y = MathUtils.damp( + leafPivot.position.y, + targetPosition[1]!, + DOOR_SWING_RESPONSE, + delta, + ) + leafPivot.position.z = MathUtils.damp( + leafPivot.position.z, + targetPosition[2]!, + DOOR_SWING_RESPONSE, + delta, + ) + } + + const rotationDelta = Math.max( + Math.abs(leafPivot.rotation.x - closedRotation[0]!), + Math.abs(leafPivot.rotation.y - closedRotation[1]!), + Math.abs(leafPivot.rotation.z - closedRotation[2]!), + ) + const positionDelta = Math.max( + Math.abs(leafPivot.position.x - closedPosition[0]!), + Math.abs(leafPivot.position.y - closedPosition[1]!), + Math.abs(leafPivot.position.z - closedPosition[2]!), + ) + const isStillAnimating = + openDoorIds.has(doorId) || + rotationDelta > DOOR_ROTATION_SETTLE_EPSILON || + positionDelta > DOOR_POSITION_SETTLE_EPSILON + + if (isStillAnimating) { + trackedDoorIdsRef.current.add(doorId) + } else { + leafPivot.rotation.set(...closedRotation) + leafPivot.position.set(...closedPosition) + trackedDoorIdsRef.current.delete(doorId) + doorOpenSelectionsRef.current.delete(doorId) + overheadDoorOpenAmountsRef.current.delete(doorId) + previewDoorOpenAmountsRef.current.delete(doorId) + } + + if ( + rotationDelta > MathUtils.degToRad(8) || + positionDelta > DOOR_POSITION_SETTLE_EPSILON * 2 + ) { + openDoorCount += 1 + } + } + + mergeNavigationPerfMeta({ + navigationDoorActiveCount: openDoorCount, + }) + recordNavigationPerfSample('navigation.doorsFrameMs', performance.now() - frameStart) + }) + + useEffect(() => { + return () => { + activeNavigationDoorIds.clear() + } + }, []) + + return null +} diff --git a/packages/editor/src/components/editor/navigation-robot.tsx b/packages/editor/src/components/editor/navigation-robot.tsx new file mode 100644 index 000000000..96aedb06b --- /dev/null +++ b/packages/editor/src/components/editor/navigation-robot.tsx @@ -0,0 +1,4779 @@ +'use client' + +import { sceneRegistry, useLiveTransforms } from '@pascal-app/core' +import { useAnimations, useGLTF } from '@react-three/drei' +import { useFrame, useThree } from '@react-three/fiber' +import { type MutableRefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { + AdditiveBlending, + type AnimationAction, + type AnimationClip, + Box3, + BufferGeometry, + type Camera, + Color, + DoubleSide, + Euler, + Float32BufferAttribute, + FrontSide, + Group, + LineBasicMaterial, + LineSegments, + LoopOnce, + LoopRepeat, + type Material, + MathUtils, + Matrix3, + Mesh, + type Object3D, + PerspectiveCamera, + Quaternion, + type Raycaster, + type Scene, + Vector2, + Vector3, + type VectorKeyframeTrack, +} from 'three' +import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js' +import { float, materialOpacity, mix, positionWorld, smoothstep, uniform, uv } from 'three/tsl' +import { MeshBasicNodeMaterial, RenderTarget } from 'three/webgpu' +import { + measureNavigationPerf, + mergeNavigationPerfMeta, + recordNavigationPerfMark, + recordNavigationPerfSample, +} from '../../lib/navigation-performance' +import navigationVisualsStore from '../../store/use-navigation-visuals' + +export const NAVIGATION_ROBOT_ASSETS = { + armored: '/navigation/white-black-armored-soldier-animated.glb', + pascal: '/navigation/proto_pascal_robot.glb', +} as const +export type NavigationRobotAssetPath = + (typeof NAVIGATION_ROBOT_ASSETS)[keyof typeof NAVIGATION_ROBOT_ASSETS] +const DEFAULT_NAVIGATION_ROBOT_ASSET_PATH = NAVIGATION_ROBOT_ASSETS.pascal +const NAVIGATION_ROBOT_CLIP_OVERRIDE_STORAGE_KEY = 'pascalNavigationRobotClipOverrides' +const NAVIGATION_ROBOT_CLIP_OVERRIDE_EVENT = 'pascal-robot-clip-overrides-change' +const NAVIGATION_ROBOT_ASSET_VERSION_STORAGE_KEY = 'pascalNavigationRobotAssetVersion' +const NAVIGATION_ROBOT_ASSET_UPDATED_EVENT = 'pascal-robot-asset-updated' +const NAVIGATION_ROBOT_MATERIAL_WARMUP_FALLBACK_MS = 5000 +const DEFAULT_NAVIGATION_ROBOT_IDLE_CLIP_NAMES = [ + 'Idle_9', + 'Idle_11', + 'Idle_7', + 'Idle_12', + 'Idle_Talking_Loop', + 'Idle_Loop', +] as const +const DEFAULT_NAVIGATION_ROBOT_WALK_CLIP_NAMES = [ + 'Walking', + 'Walk_Loop', + 'Walk_Formal_Loop', + 'Jog_Fwd_Loop', +] as const +const DEFAULT_NAVIGATION_ROBOT_RUN_CLIP_NAMES = ['Running', 'Sprint_Loop', 'Jog_Fwd_Loop'] as const +const EXCLUDED_NAVIGATION_ROBOT_CLIP_NAMES = new Set([ + 'Funky_Walk', + 'Stylish_Walk', + 'Stylish_Walk_inplace', + 'run_fast_3', + 'run_fast_3_inplace', +]) + +function isTrueWebGPUBackend( + renderer: unknown, +): renderer is { backend: { isWebGPUBackend: true } } { + return (renderer as { backend?: { isWebGPUBackend?: boolean } }).backend?.isWebGPUBackend === true +} + +function getRendererDrawingBufferSize( + renderer: { + domElement?: { height?: number; width?: number } + getDrawingBufferSize?: (target: Vector2) => Vector2 + }, + scratch = new Vector2(), +) { + const canvasWidth = Math.max(0, Math.floor(renderer.domElement?.width ?? 0)) + const canvasHeight = Math.max(0, Math.floor(renderer.domElement?.height ?? 0)) + + if (canvasWidth > 1 && canvasHeight > 1) { + return scratch.set(canvasWidth, canvasHeight) + } + + if (typeof renderer.getDrawingBufferSize === 'function') { + return renderer.getDrawingBufferSize(scratch) + } + + return scratch.set(Math.max(1, canvasWidth || 1), Math.max(1, canvasHeight || 1)) +} + +type NavigationRobotClipCategory = 'idle' | 'run' | 'walk' + +type NavigationRobotClipOverrideState = { + idle: string | null + run: string | null + walk: string | null +} + +const DEFAULT_NAVIGATION_ROBOT_CLIP_OVERRIDES: NavigationRobotClipOverrideState = { + idle: null, + run: null, + walk: null, +} + +function normalizeNavigationRobotClipOverrides(value: unknown): NavigationRobotClipOverrideState { + if (!(value && typeof value === 'object')) { + return DEFAULT_NAVIGATION_ROBOT_CLIP_OVERRIDES + } + + const candidate = value as Partial> + return { + idle: typeof candidate.idle === 'string' ? candidate.idle : null, + run: typeof candidate.run === 'string' ? candidate.run : null, + walk: typeof candidate.walk === 'string' ? candidate.walk : null, + } +} + +function getNavigationRobotClipNames( + defaultClipNames: readonly string[], + overrideClipName: string | null | undefined, +) { + const filteredDefaultClipNames = defaultClipNames.filter( + (clipName) => !EXCLUDED_NAVIGATION_ROBOT_CLIP_NAMES.has(clipName), + ) + + if ( + !(typeof overrideClipName === 'string' && overrideClipName.length > 0) || + EXCLUDED_NAVIGATION_ROBOT_CLIP_NAMES.has(overrideClipName) + ) { + return [...filteredDefaultClipNames] + } + + return [ + overrideClipName, + ...filteredDefaultClipNames.filter((clipName) => clipName !== overrideClipName), + ] +} + +function readNavigationRobotClipOverrides(storage: Storage | null | undefined) { + if (!storage) { + return DEFAULT_NAVIGATION_ROBOT_CLIP_OVERRIDES + } + + try { + const rawValue = storage.getItem(NAVIGATION_ROBOT_CLIP_OVERRIDE_STORAGE_KEY) + return normalizeNavigationRobotClipOverrides(rawValue ? JSON.parse(rawValue) : null) + } catch { + return DEFAULT_NAVIGATION_ROBOT_CLIP_OVERRIDES + } +} + +function readNavigationRobotAssetVersion(storage: Storage | null | undefined) { + if (!storage) { + return null + } + + try { + const rawValue = storage.getItem(NAVIGATION_ROBOT_ASSET_VERSION_STORAGE_KEY) + if (!rawValue) { + return null + } + + const parsedValue = Number(rawValue) + return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : null + } catch { + return null + } +} + +function getNavigationRobotAssetUrl( + storage: Storage | null | undefined, + assetPath: NavigationRobotAssetPath = DEFAULT_NAVIGATION_ROBOT_ASSET_PATH, +) { + const version = readNavigationRobotAssetVersion(storage) + if (!version) { + return assetPath + } + + return `${assetPath}?v=${version}` +} + +const ROBOT_TARGET_HEIGHT = 1.82 +const TOOL_CONE_VFX_LAYER = 3 +const SMALL_WORLD_ROBOT_ASSET_SCALE_MULTIPLIER = 1 / 110.16949152542374 +const getRobotAssetScaleMultiplier = (assetPath: string) => + assetPath === '/navigation/proto_pascal_robot.glb' || + assetPath === '/navigation/white-black-armored-soldier-animated.glb' + ? SMALL_WORLD_ROBOT_ASSET_SCALE_MULTIPLIER + : 1 +const IDLE_TIME_SCALE = 0.5 +const CLIP_BLEND_RESPONSE = 8 +const CLIP_TIME_SCALE_RESPONSE = 10 +const FORCED_CLIP_RELEASE_BLEND_RESPONSE = 12 +const SLOW_RELEASE_CLIP_BLEND_RESPONSE_BY_NAME: Record = { + Jumping_Down: 8, +} +const JUMPING_DOWN_CLIP_NAME = 'Jumping_Down' +const FORCED_CLIP_VISUAL_REVEAL_DURATION_SECONDS = 1.5 +const TOOL_ATTACHMENT_REVEAL_DURATION_SECONDS = FORCED_CLIP_VISUAL_REVEAL_DURATION_SECONDS +const MODEL_FORWARD_ROTATION_Y = 0 +const TOOL_ASSET_PATH = '/navigation/tool-asset.glb' +const TOOL_ATTACHMENT_SCALE = 1800 +const NAVIGATION_ROBOT_DEBUG_ENABLED = false +const NAVIGATION_ROBOT_VERBOSE_DEBUG_ENABLED = false +const ROBOT_DEBUG_PUBLISH_INTERVAL_MS = 100 +const SHOULDER_BONE_NAMES = ['LeftShoulder', 'RightShoulder'] as const +const LEFT_SHOULDER_BONE_NAMES = [ + 'LeftShoulder', + 'mixamorigLeftShoulder', + 'Shoulder_L', + 'Left_Shoulder', +] as const +const LEFT_UPPER_ARM_BONE_NAMES = ['LeftArm', 'mixamorigLeftArm', 'Arm_L', 'Left_Arm'] as const +const LEFT_ELBOW_BONE_NAMES = [ + 'LeftForeArm', + 'mixamorigLeftForeArm', + 'ForeArm_L', + 'Left_ForeArm', +] as const +const LEFT_HAND_BONE_NAMES = ['LeftHand', 'mixamorigLeftHand', 'Hand_L', 'Left_Hand'] as const +const CHECKOUT_CLIP_NAME = 'Checkout_Gesture' +const CHECKOUT_LEFT_HAND_ROTATION_DEGREES = { x: -79, y: 24, z: 9 } as const +const LEFT_TOOL_OFFSET = { x: -3, y: 13.4, z: 3.8 } as const +const LEFT_TOOL_ROTATION_DEGREES = { x: -180, y: -21, z: 90 } as const +const TOOL_CONE_OVERLAY_COLOR = '#0fd6ff' +const TOOL_CONE_EDGE_GLOW_COLOR = TOOL_CONE_OVERLAY_COLOR +const TOOL_CONE_EDGE_GLOW_BRIGHTNESS = 1.24 +const TOOL_CONE_EDGE_GLOW_INWARD_DIFFUSION_DEPTH = 0.19504 +const TOOL_CONE_EDGE_GLOW_INWARD_GRADIENT_BEND = 0.1 +const TOOL_CONE_EDGE_GLOW_OUTWARD_DIFFUSION_DEPTH = 0.02184 +const TOOL_CONE_EDGE_GLOW_OUTWARD_GRADIENT_BEND = 0.09 +const TOOL_CONE_EDGE_GLOW_ATTENUATION = 0.26 +const TOOL_CONE_GRADIENT_BEND = 0.58 +const TOOL_CONE_EXTRA_TRANSPARENCY_PERCENT = 61 +const TOOL_CONE_VISIBLE_START_TIME = 1.8 +const TOOL_CONE_VISIBLE_END_TIME = 3.75 +const TOOL_CONE_FOLLOW_BLEND_DURATION_SECONDS = 0.55 +const TOOL_CONE_FOLLOW_RELEASE_RESPONSE = 7 +const TOOL_CONE_FOLLOW_FOREARM_TARGET_HEIGHT_OFFSET = 0.04 +const TOOL_CONE_FOLLOW_SHOULDER_TARGET_HEIGHT_OFFSET = 0.24 +const TOOL_CONE_OPACITY_SCALE = 1 - TOOL_CONE_EXTRA_TRANSPARENCY_PERCENT / 100 +const TOOL_CONE_TOOL_CORNER_OFFSET = { x: -16.5, y: 4.5, z: 0 } as const +const TOOL_CONE_CAMERA_SURFACE_EPSILON = 0.035 +const TOOL_CONE_TARGET_SURFACE_DEPTH_BIAS = 0.012 +const TOOL_CONE_MAX_PROJECTED_HULL_VERTEX_COUNT = 9 +const TOOL_CONE_EXPONENTIAL_BEND_STRENGTH_MULTIPLIER = 6 +const LANDING_SETTLE_VERTICAL_SPEED_THRESHOLD_RATIO = 0.04 +const LANDING_SETTLE_WINDOW_DURATION_SECONDS = 0.2 +const LANDING_SETTLE_FALLBACK_PROGRESS = 0.68 +const LANDING_SHOULDER_BLEND_DURATION_RATIO = 0.075 +const LANDING_SHOULDER_BLEND_MIN_DURATION_SECONDS = 0.2 +const TOOL_CONE_SUPPORT_SIGNS: ReadonlyArray = [ + [-1, -1, -1], + [-1, -1, 1], + [-1, 1, -1], + [-1, 1, 1], + [1, -1, -1], + [1, -1, 1], + [1, 1, -1], + [1, 1, 1], +] +const LOCAL_BONE_AIM_AXIS = new Vector3(0, 1, 0) + +type NavigationRobotMotionRef = MutableRefObject<{ + debugActiveClipName?: string | null + debugForcedClipRevealProgress?: number + debugForcedClipTime?: number | null + debugLandingShoulderBlendWeight?: number + debugReleasedForcedClipName?: string | null + debugReleasedForcedClipTime?: number | null + debugReleasedForcedWeight?: number + debugTransitionPreview?: { + releasedClipName: string + releasedClipTime: number + releasedClipWeight: number + } | null + forcedClip: { + clipName: string + holdLastFrame: boolean + loop: 'once' | 'repeat' + paused: boolean + revealProgress: number + seekTime: number | null + timeScale: number + } | null + locomotion: { + moveBlend: number + runBlend: number + runTimeScale: number + walkTimeScale: number + } + moving: boolean + rootMotionOffset: [number, number, number] + visibilityRevealProgress?: number | null +}> + +export type NavigationRobotToolInteractionPhase = 'delete' | 'drop' | 'pickup' | 'repair' +export type NavigationRobotMaterialDebugMode = 'auto' | 'original-only' | 'reveal-only' + +type NavigationRobotProps = { + active?: boolean + animationPaused?: boolean + assetPath?: NavigationRobotAssetPath + clipNameOverrides?: Partial + debugId?: string + debugStateRef?: MutableRefObject | null> | undefined + debugTransitionPreview?: { + releasedClipName: string + releasedClipTime: number + releasedClipWeight: number + } | null + forcedClipPlayback?: { + clipName: string + holdLastFrame?: boolean + loop?: 'once' | 'repeat' + playbackToken?: number | string + revealFromStart?: boolean + stabilizeRootMotion?: boolean + timeScale?: number + } | null + forcedClipVisualOffset?: [number, number, number] | null + hoverOffset: number + motionRef: NavigationRobotMotionRef + onReady?: (() => void) | undefined + onSceneReady?: ((scene: Group | null) => void) | undefined + onWarmupReadyChange?: ((ready: boolean) => void) | undefined + materialDebugMode?: NavigationRobotMaterialDebugMode + skinnedMeshVisibilityOverride?: boolean | null + staticMeshVisibilityOverride?: boolean | null + showToolAttachments?: boolean + toolConeColor?: string | null + toolCarryItemId?: string | null + toolCarryItemIdRef?: MutableRefObject | undefined + toolInteractionPhaseRef?: MutableRefObject | undefined + toolInteractionTargetItemIdRef?: MutableRefObject | undefined +} + +type RobotTransform = { + offset: [number, number, number] + scale: number +} + +type AnimationBlendState = { + idleWeight: number + runTimeScale: number + runWeight: number + walkTimeScale: number + walkWeight: number +} + +type DebugBoneSample = { + bone: Object3D + name: string + previousPosition: Vector3 + previousQuaternion: Quaternion +} + +type RevealUniform = { + value: number +} + +type RevealMaterialBinding = { + material: Material & { + alphaTest: number + alphaTestNode?: unknown + customProgramCacheKey?: () => string + maskNode?: unknown + needsUpdate: boolean + onBeforeCompile?: + | ((shader: { + fragmentShader: string + uniforms: Record + vertexShader: string + }) => void) + | undefined + opacityNode?: unknown + transparent: boolean + } + uniforms: { + revealFeather: RevealUniform + revealMaxY: RevealUniform + revealMinY: RevealUniform + revealProgress: RevealUniform + } + webgpuUniforms: { + revealFeather: RevealUniform + revealMaxY: RevealUniform + revealMinY: RevealUniform + revealProgress: RevealUniform + } +} + +type RevealMaterialEntry = { + bindings: RevealMaterialBinding[] + mesh: Mesh + originalMaterial: Material | Material[] + revealMaterial: Material | Material[] +} + +function collectMeshList(root: Object3D) { + const meshes: Mesh[] = [] + root.traverse((child) => { + const mesh = child as Mesh + if (mesh.isMesh) { + meshes.push(mesh) + } + }) + return meshes +} + +function hasAncestorNamed(object: Object3D | null, name: string) { + let current: Object3D | null = object + while (current) { + if (current.name === name) { + return true + } + current = current.parent + } + return false +} + +function disableFrustumCulling(root: Object3D) { + root.traverse((child) => { + const mesh = child as Mesh + if (mesh.isMesh) { + mesh.frustumCulled = false + } + }) +} + +function applyWarmupRevealMaterials(root: Object3D, entries: RevealMaterialEntry[]) { + const meshes = collectMeshList(root) + const count = Math.min(meshes.length, entries.length) + for (let index = 0; index < count; index += 1) { + const mesh = meshes[index] + const entry = entries[index] + if (mesh && entry) { + mesh.material = entry.revealMaterial + } + } +} + +type ShoulderBoneName = (typeof SHOULDER_BONE_NAMES)[number] + +type ShoulderPoseTargets = Partial> + +type RuntimePlanarRootMotionClip = { + landingSettleTime: number | null + landingShoulderBlendEndTime: number | null + playbackClip: AnimationClip + samplePlanarLocalOffset: (time: number, target: Vector3) => Vector3 +} + +type ToolOffset = { + x: number + y: number + z: number +} + +type ToolRotationDegrees = { + x: number + y: number + z: number +} + +type ProjectedHullCandidate = { + cameraSnapped?: boolean + cameraSurfaceDistanceDelta?: number | null + cameraSurfaceMeshName?: string | null + cameraSurfacePoint?: [number, number, number] | null + cameraSurfaceRelation?: 'no-hit' | 'occluded' | 'visible' + isApex: boolean + localPoint: Vector3 + projectedPoint: Vector2 + sourceMeshName: string | null + sourceMeshVisible: boolean | null + supportIndex: number | null + worldPoint: Vector3 +} + +type ToolConeSupportPointDiagnostic = { + cameraSnapped?: boolean + cameraSurfaceDistanceDelta?: number | null + cameraSurfaceMeshName?: string | null + cameraSurfacePoint?: [number, number, number] | null + cameraSurfaceRelation?: 'no-hit' | 'occluded' | 'visible' + sourceMeshName: string | null + sourceMeshVisible: boolean +} + +type FrozenToolConeHullPoint = { + cameraSnapped: boolean + cameraSurfaceDistanceDelta: number | null + cameraSurfaceMeshName: string | null + cameraSurfacePoint: [number, number, number] | null + cameraSurfaceRelation: 'no-hit' | 'occluded' | 'visible' | null + sourceMeshName: string | null + sourceMeshVisible: boolean | null + supportIndex: number | null + targetLocalPoint: Vector3 + worldPoint: Vector3 +} + +type ToolConeRenderable = { + group: Group + inwardGlowMesh: Mesh + inwardGlowPositionAttribute: Float32BufferAttribute + mainGeometry: BufferGeometry + mainMesh: Mesh + mainPositionAttribute: Float32BufferAttribute + mainUvAttribute: Float32BufferAttribute + outlineMesh: LineSegments + outlinePositionAttribute: Float32BufferAttribute + outwardGlowMesh: Mesh + outwardGlowPositionAttribute: Float32BufferAttribute +} + +const ROOT_MOTION_BONE_CANDIDATE_NAMES = ['Hips', 'hips', 'mixamorigHips'] as const + +function degreesToRadians(rotation: ToolRotationDegrees): [number, number, number] { + return [ + MathUtils.degToRad(rotation.x), + MathUtils.degToRad(rotation.y), + MathUtils.degToRad(rotation.z), + ] +} + +function createToolRenderable( + toolScene: Group, + name: string, + initialOffset: ToolOffset, + initialRotationDegrees: ToolRotationDegrees, +) { + const toolRoot = new Group() + const toolAttachment = toolScene.clone(true) as Group + const toolBounds = new Box3() + const toolCenter = new Vector3() + + toolRoot.name = `${name}-root` + toolRoot.position.set(initialOffset.x, initialOffset.y, initialOffset.z) + toolRoot.rotation.set(...degreesToRadians(initialRotationDegrees)) + + toolAttachment.name = name + toolAttachment.scale.setScalar(TOOL_ATTACHMENT_SCALE) + toolAttachment.traverse((child) => { + const mesh = child as Mesh + if (!mesh.isMesh) { + return + } + + mesh.castShadow = true + mesh.receiveShadow = true + }) + + toolBounds.setFromObject(toolAttachment) + toolBounds.getCenter(toolCenter) + toolAttachment.position.set(-toolCenter.x, -toolCenter.y, -toolCenter.z) + toolRoot.add(toolAttachment) + + return toolRoot +} + +function cross2D(origin: Vector2, pointA: Vector2, pointB: Vector2) { + return ( + (pointA.x - origin.x) * (pointB.y - origin.y) - (pointA.y - origin.y) * (pointB.x - origin.x) + ) +} + +function computeProjectedHull(candidates: ProjectedHullCandidate[]) { + if (candidates.length < 3) { + return candidates + } + + const sorted = [...candidates].sort((candidateA, candidateB) => { + if (Math.abs(candidateA.projectedPoint.x - candidateB.projectedPoint.x) > 1e-6) { + return candidateA.projectedPoint.x - candidateB.projectedPoint.x + } + return candidateA.projectedPoint.y - candidateB.projectedPoint.y + }) + const uniqueCandidates = sorted.filter((candidate, index) => { + if (index === 0) { + return true + } + const previousCandidate = sorted[index - 1] + if (!previousCandidate) { + return true + } + return ( + Math.abs(candidate.projectedPoint.x - previousCandidate.projectedPoint.x) > 1e-6 || + Math.abs(candidate.projectedPoint.y - previousCandidate.projectedPoint.y) > 1e-6 + ) + }) + + if (uniqueCandidates.length < 3) { + return uniqueCandidates + } + + const lowerHull: ProjectedHullCandidate[] = [] + for (const candidate of uniqueCandidates) { + while (lowerHull.length >= 2) { + const previousCandidate = lowerHull[lowerHull.length - 1] + const previousPreviousCandidate = lowerHull[lowerHull.length - 2] + if (!previousCandidate || !previousPreviousCandidate) { + break + } + if ( + cross2D( + previousPreviousCandidate.projectedPoint, + previousCandidate.projectedPoint, + candidate.projectedPoint, + ) > 0 + ) { + break + } + lowerHull.pop() + } + lowerHull.push(candidate) + } + + const upperHull: ProjectedHullCandidate[] = [] + for (let index = uniqueCandidates.length - 1; index >= 0; index -= 1) { + const candidate = uniqueCandidates[index] + if (!candidate) { + continue + } + while (upperHull.length >= 2) { + const previousCandidate = upperHull[upperHull.length - 1] + const previousPreviousCandidate = upperHull[upperHull.length - 2] + if (!previousCandidate || !previousPreviousCandidate) { + break + } + if ( + cross2D( + previousPreviousCandidate.projectedPoint, + previousCandidate.projectedPoint, + candidate.projectedPoint, + ) > 0 + ) { + break + } + upperHull.pop() + } + upperHull.push(candidate) + } + + lowerHull.pop() + upperHull.pop() + return [...lowerHull, ...upperHull] +} + +function reorderHullFromApex(projectedHull: ProjectedHullCandidate[]) { + const apexIndex = projectedHull.findIndex((candidate) => candidate.isApex) + if (apexIndex <= 0) { + return projectedHull + } + return [...projectedHull.slice(apexIndex), ...projectedHull.slice(0, apexIndex)] +} + +function createBendFadeNode(bendValue: number) { + const bendNode: any = float(Math.max(bendValue, 0)) + const bendMix: any = smoothstep(float(0), float(0.03), bendNode) + const strength: any = bendNode.mul(float(TOOL_CONE_EXPONENTIAL_BEND_STRENGTH_MULTIPLIER)) + const gradientProgress: any = uv().x + const linearFade: any = gradientProgress.oneMinus() + const expStrength: any = (float(-1).mul(strength) as any).exp() + const expFade: any = (float(-1).mul(strength).mul(gradientProgress) as any) + .exp() + .sub(expStrength) + .div(float(1).sub(expStrength).add(float(1e-5))) + return linearFade.mul(float(1).sub(bendMix)).add(expFade.mul(bendMix)) +} + +function hasToolConeTargetExclusion(target: Object3D | null) { + let current: Object3D | null = target + while (current) { + if ( + typeof current.userData === 'object' && + current.userData !== null && + current.userData.pascalExcludeFromToolConeTarget === true + ) { + return true + } + current = current.parent + } + return false +} + +function isObjectVisibleInHierarchy(target: Object3D | null) { + let current: Object3D | null = target + while (current) { + if (!current.visible) { + return false + } + current = current.parent + } + return true +} + +function vector2ToTuple(value: Vector2) { + return [value.x, value.y] as [number, number] +} + +function vector3ToTuple(value: Vector3) { + return [value.x, value.y, value.z] as [number, number, number] +} + +function getToolConeTargetSurfaceHit( + target: Object3D, + worldPoint: Vector3, + cameraPosition: Vector3, + raycaster: Raycaster, + scratchDirection: Vector3, +) { + scratchDirection.copy(worldPoint).sub(cameraPosition) + const targetDistance = scratchDirection.length() + if (!(targetDistance > 1e-5)) { + return null + } + + scratchDirection.multiplyScalar(1 / targetDistance) + raycaster.set(cameraPosition, scratchDirection) + raycaster.near = 0.001 + raycaster.far = targetDistance + 0.25 + const hit = raycaster + .intersectObject(target, true) + .find( + (intersection) => + !hasToolConeTargetExclusion(intersection.object) && + isObjectVisibleInHierarchy(intersection.object), + ) + if (!hit) { + return { + relation: 'no-hit' as const, + surfaceDistanceDelta: null, + surfaceMeshName: null, + surfaceNormalWorld: null, + surfacePoint: null, + } + } + + const surfaceDistanceDelta = Math.abs(targetDistance - hit.distance) + const surfaceNormalWorld = hit.face?.normal + ? hit.face.normal + .clone() + .applyNormalMatrix(new Matrix3().getNormalMatrix(hit.object.matrixWorld)) + .normalize() + : null + return { + relation: + surfaceDistanceDelta <= TOOL_CONE_CAMERA_SURFACE_EPSILON + ? ('visible' as const) + : ('occluded' as const), + surfaceDistanceDelta, + surfaceMeshName: hit.object.name || null, + surfaceNormalWorld, + surfacePoint: hit.point, + } +} + +function collectTargetSupportPoints( + target: Object3D, + outputPoints: Vector3[], + scratchPoint: Vector3, + scratchScores: number[], + outputDiagnostics?: (ToolConeSupportPointDiagnostic | null)[], + cameraPosition?: Vector3, + surfaceRaycaster?: Raycaster, + surfaceRayDirection?: Vector3, +) { + scratchScores.fill(-Infinity) + outputDiagnostics?.fill(null) + target.updateWorldMatrix(true, true) + + target.traverse((child) => { + const mesh = child as Mesh + if (!mesh.isMesh || !mesh.geometry || hasToolConeTargetExclusion(mesh)) { + return + } + + const positionAttribute = mesh.geometry.getAttribute('position') + if (!positionAttribute) { + return + } + + for (let index = 0; index < positionAttribute.count; index += 1) { + scratchPoint.fromBufferAttribute(positionAttribute, index) + mesh.localToWorld(scratchPoint) + + for (let supportIndex = 0; supportIndex < TOOL_CONE_SUPPORT_SIGNS.length; supportIndex += 1) { + const supportSigns = TOOL_CONE_SUPPORT_SIGNS[supportIndex] + if (!supportSigns) { + continue + } + const [signX, signY, signZ] = supportSigns + const score = scratchPoint.x * signX + scratchPoint.y * signY + scratchPoint.z * signZ + + if (score > (scratchScores[supportIndex] ?? Number.NEGATIVE_INFINITY)) { + scratchScores[supportIndex] = score + outputPoints[supportIndex]?.copy(scratchPoint) + if (outputDiagnostics) { + outputDiagnostics[supportIndex] = { + sourceMeshName: mesh.name || null, + sourceMeshVisible: isObjectVisibleInHierarchy(mesh), + } + } + } + } + } + }) + + if (cameraPosition && surfaceRaycaster && surfaceRayDirection) { + for (let supportIndex = 0; supportIndex < outputPoints.length; supportIndex += 1) { + const supportPoint = outputPoints[supportIndex] + if (!supportPoint) { + continue + } + + const surfaceHit = getToolConeTargetSurfaceHit( + target, + supportPoint, + cameraPosition, + surfaceRaycaster, + surfaceRayDirection, + ) + if (!surfaceHit) { + continue + } + + const diagnostic = outputDiagnostics?.[supportIndex] + if (surfaceHit.surfacePoint) { + supportPoint.copy(surfaceHit.surfacePoint) + supportPoint.addScaledVector(surfaceRayDirection, -TOOL_CONE_TARGET_SURFACE_DEPTH_BIAS) + } + if (diagnostic) { + diagnostic.cameraSnapped = Boolean(surfaceHit.surfacePoint) + diagnostic.cameraSurfaceDistanceDelta = surfaceHit.surfaceDistanceDelta + diagnostic.cameraSurfaceMeshName = surfaceHit.surfaceMeshName + diagnostic.cameraSurfacePoint = surfaceHit.surfacePoint + ? vector3ToTuple(surfaceHit.surfacePoint) + : null + diagnostic.cameraSurfaceRelation = surfaceHit.relation + } + } + } + + return scratchScores.every((score) => Number.isFinite(score)) +} + +function applyLiveTransformToSceneObject(nodeId: string, target: Object3D) { + const liveTransform = useLiveTransforms.getState().get(nodeId) + if (!liveTransform) { + return + } + + target.position.set( + liveTransform.position[0], + liveTransform.position[1], + liveTransform.position[2], + ) + target.rotation.y = liveTransform.rotation + target.updateWorldMatrix(true, true) +} + +function createToolConeRenderable( + name: string, + coneMaterial: MeshBasicNodeMaterial, + outlineMaterial: LineBasicMaterial, + inwardGlowMaterial: MeshBasicNodeMaterial, + outwardGlowMaterial: MeshBasicNodeMaterial, +): ToolConeRenderable { + const group = new Group() + group.name = `${name}-root` + group.layers.set(TOOL_CONE_VFX_LAYER) + group.userData.pascalExcludeFromToolReveal = true + + const mainGeometry = new BufferGeometry() + const mainPositionAttribute = new Float32BufferAttribute( + new Array(TOOL_CONE_MAX_PROJECTED_HULL_VERTEX_COUNT * 3).fill(0), + 3, + ) + const mainUvAttribute = new Float32BufferAttribute( + new Array(TOOL_CONE_MAX_PROJECTED_HULL_VERTEX_COUNT * 2).fill(0), + 2, + ) + const indices: number[] = [] + for (let index = 1; index < TOOL_CONE_MAX_PROJECTED_HULL_VERTEX_COUNT - 1; index += 1) { + indices.push(0, index, index + 1) + } + mainGeometry.setAttribute('position', mainPositionAttribute) + mainGeometry.setAttribute('uv', mainUvAttribute) + mainGeometry.setIndex(indices) + mainGeometry.setDrawRange(0, 0) + mainGeometry.computeVertexNormals() + + const mainMesh = new Mesh(mainGeometry, coneMaterial) + mainMesh.castShadow = false + mainMesh.frustumCulled = false + mainMesh.layers.set(TOOL_CONE_VFX_LAYER) + mainMesh.receiveShadow = false + mainMesh.renderOrder = 50 + mainMesh.userData.pascalExcludeFromOutline = true + mainMesh.userData.pascalExcludeFromToolReveal = true + + const outlineGeometry = new BufferGeometry() + const outlinePositionAttribute = new Float32BufferAttribute( + new Array(TOOL_CONE_MAX_PROJECTED_HULL_VERTEX_COUNT * 2 * 3).fill(0), + 3, + ) + outlineGeometry.setAttribute('position', outlinePositionAttribute) + outlineGeometry.setDrawRange(0, 0) + const outlineMesh = new LineSegments(outlineGeometry, outlineMaterial) + outlineMesh.frustumCulled = false + outlineMesh.layers.set(TOOL_CONE_VFX_LAYER) + outlineMesh.renderOrder = 51 + outlineMesh.userData.pascalExcludeFromToolReveal = true + + const inwardGlowGeometry = new BufferGeometry() + const maxEdgeCount = TOOL_CONE_MAX_PROJECTED_HULL_VERTEX_COUNT + const inwardGlowPositionAttribute = new Float32BufferAttribute( + new Array(maxEdgeCount * 6 * 3).fill(0), + 3, + ) + const inwardGlowUvValues: number[] = [] + for (let edgeIndex = 0; edgeIndex < maxEdgeCount; edgeIndex += 1) { + inwardGlowUvValues.push(0, 0, 0, 1, 1, 1) + inwardGlowUvValues.push(0, 0, 1, 1, 1, 0) + } + inwardGlowGeometry.setAttribute('position', inwardGlowPositionAttribute) + inwardGlowGeometry.setAttribute('uv', new Float32BufferAttribute(inwardGlowUvValues, 2)) + inwardGlowGeometry.setDrawRange(0, 0) + const inwardGlowMesh = new Mesh(inwardGlowGeometry, inwardGlowMaterial) + inwardGlowMesh.castShadow = false + inwardGlowMesh.frustumCulled = false + inwardGlowMesh.layers.set(TOOL_CONE_VFX_LAYER) + inwardGlowMesh.receiveShadow = false + inwardGlowMesh.renderOrder = 52 + inwardGlowMesh.userData.pascalExcludeFromOutline = true + inwardGlowMesh.userData.pascalExcludeFromToolReveal = true + + const outwardGlowGeometry = new BufferGeometry() + const outwardGlowPositionAttribute = new Float32BufferAttribute( + new Array(maxEdgeCount * 6 * 3).fill(0), + 3, + ) + const outwardGlowUvValues: number[] = [] + for (let edgeIndex = 0; edgeIndex < maxEdgeCount; edgeIndex += 1) { + outwardGlowUvValues.push(0, 0, 1, 1, 0, 1) + outwardGlowUvValues.push(0, 0, 1, 0, 1, 1) + } + outwardGlowGeometry.setAttribute('position', outwardGlowPositionAttribute) + outwardGlowGeometry.setAttribute('uv', new Float32BufferAttribute(outwardGlowUvValues, 2)) + outwardGlowGeometry.setDrawRange(0, 0) + const outwardGlowMesh = new Mesh(outwardGlowGeometry, outwardGlowMaterial) + outwardGlowMesh.castShadow = false + outwardGlowMesh.frustumCulled = false + outwardGlowMesh.layers.set(TOOL_CONE_VFX_LAYER) + outwardGlowMesh.receiveShadow = false + outwardGlowMesh.renderOrder = 53 + outwardGlowMesh.userData.pascalExcludeFromOutline = true + outwardGlowMesh.userData.pascalExcludeFromToolReveal = true + + group.add(mainMesh) + group.add(outlineMesh) + group.add(inwardGlowMesh) + group.add(outwardGlowMesh) + + return { + group, + inwardGlowMesh, + inwardGlowPositionAttribute, + mainGeometry, + mainMesh, + mainPositionAttribute, + mainUvAttribute, + outlineMesh, + outlinePositionAttribute, + outwardGlowMesh, + outwardGlowPositionAttribute, + } +} + +function findRootMotionBone(root: Object3D): Object3D | null { + for (const candidateName of ROOT_MOTION_BONE_CANDIDATE_NAMES) { + let matchedBone: Object3D | null = null + root.traverse((child) => { + if (!matchedBone && 'isBone' in child && child.isBone && child.name === candidateName) { + matchedBone = child + } + }) + if (matchedBone) { + return matchedBone + } + } + + let firstBone: Object3D | null = null + root.traverse((child) => { + if (!firstBone && 'isBone' in child && child.isBone) { + firstBone = child + } + }) + return firstBone +} + +let lastRobotDebugPublishAt = 0 + +function shouldWriteRobotDebugState(debugId: string | undefined) { + return Boolean(debugId) || NAVIGATION_ROBOT_DEBUG_ENABLED +} + +function writeRobotDebugState( + _debugId: string | undefined, + debugStateRef: MutableRefObject | null> | undefined, + debugPayload: Record, +) { + if (debugStateRef) { + debugStateRef.current = debugPayload + } +} + +function getCurrentRootMotionOffset( + rootGroup: Group | null, + rootMotionBone: Object3D | null, + baselineScenePosition: Vector3 | null, + baselineWorld: Vector3, + currentWorld: Vector3, + target: Vector3, +) { + if (!(rootGroup && rootMotionBone && baselineScenePosition)) { + return target.set(0, 0, 0) + } + + const currentRootMotionWorld = rootMotionBone.getWorldPosition(currentWorld) + const baselineRootMotionWorld = rootGroup.localToWorld(baselineWorld.copy(baselineScenePosition)) + return target.copy(currentRootMotionWorld).sub(baselineRootMotionWorld) +} + +function findRootMotionTrack(clip: AnimationClip) { + for (const candidateName of ROOT_MOTION_BONE_CANDIDATE_NAMES) { + const candidateTrack = clip.tracks.find( + (track) => track.name === `${candidateName}.position` && track.getValueSize() === 3, + ) + if (candidateTrack) { + return candidateTrack as VectorKeyframeTrack + } + } + + return null +} + +function findAttachmentTargetByTokens( + root: Group, + boneNames: readonly string[], + fuzzyTokens: readonly string[], +) { + for (const boneName of boneNames) { + const exactMatch = root.getObjectByName(boneName) + if (exactMatch) { + return exactMatch + } + } + + let fuzzyMatch: Object3D | null = null + root.traverse((child) => { + if (fuzzyMatch) { + return + } + + const normalizedName = child.name.replaceAll(/[^a-z]/gi, '').toLowerCase() + if (fuzzyTokens.some((token) => normalizedName.includes(token))) { + fuzzyMatch = child + } + }) + + return fuzzyMatch +} + +function findBoneQuaternionTrack(clip: AnimationClip, boneName: ShoulderBoneName) { + const candidateTrack = clip.tracks.find( + (track) => track.name === `${boneName}.quaternion` && track.getValueSize() === 4, + ) + return candidateTrack ?? null +} + +function readTrackFirstQuaternion( + track: ReturnType, + target: Quaternion, +) { + if (!track) { + return target.identity() + } + + return target + .set(track.values[0] ?? 0, track.values[1] ?? 0, track.values[2] ?? 0, track.values[3] ?? 1) + .normalize() +} + +function findLandingSettleTime(rootMotionTrack: VectorKeyframeTrack, clipDuration: number) { + const times = rootMotionTrack.times + const values = rootMotionTrack.values + if (times.length < 3 || values.length < 9) { + return null + } + + const searchStartFrameIndex = Math.max(1, Math.floor(times.length * 0.2)) + let minimumYFrameIndex = searchStartFrameIndex + let minimumY = values[minimumYFrameIndex * 3 + 1] ?? 0 + let maximumY = values[1] ?? minimumY + + for (let frameIndex = 0; frameIndex < times.length; frameIndex += 1) { + const y = values[frameIndex * 3 + 1] ?? minimumY + maximumY = Math.max(maximumY, y) + if (frameIndex >= searchStartFrameIndex && y < minimumY) { + minimumY = y + minimumYFrameIndex = frameIndex + } + } + + const verticalRange = Math.max(1e-3, maximumY - minimumY) + const settleSpeedThreshold = Math.max( + 1, + verticalRange * LANDING_SETTLE_VERTICAL_SPEED_THRESHOLD_RATIO, + ) + const settleWindowDuration = Math.min( + LANDING_SETTLE_WINDOW_DURATION_SECONDS, + Math.max(0.12, clipDuration * 0.08), + ) + + for ( + let startFrameIndex = minimumYFrameIndex + 1; + startFrameIndex < times.length; + startFrameIndex += 1 + ) { + let endFrameIndex = startFrameIndex + while ( + endFrameIndex + 1 < times.length && + (times[endFrameIndex] ?? 0) - (times[startFrameIndex] ?? 0) < settleWindowDuration + ) { + endFrameIndex += 1 + } + + if ((times[endFrameIndex] ?? 0) - (times[startFrameIndex] ?? 0) < settleWindowDuration) { + break + } + + let stable = true + for (let frameIndex = startFrameIndex; frameIndex <= endFrameIndex; frameIndex += 1) { + const previousFrameIndex = Math.max(0, frameIndex - 1) + const currentTime = times[frameIndex] ?? 0 + const previousTime = times[previousFrameIndex] ?? currentTime + const currentY = values[frameIndex * 3 + 1] ?? minimumY + const previousY = values[previousFrameIndex * 3 + 1] ?? currentY + const verticalSpeed = Math.abs( + (currentY - previousY) / Math.max(1e-6, currentTime - previousTime), + ) + if (verticalSpeed > settleSpeedThreshold) { + stable = false + break + } + } + + if (stable) { + return Math.min(clipDuration, times[startFrameIndex] ?? clipDuration) + } + } + + return Math.min( + clipDuration, + Math.max(times[minimumYFrameIndex] ?? 0, clipDuration * LANDING_SETTLE_FALLBACK_PROGRESS), + ) +} + +function getLandingShoulderBlendWeight( + runtimeClip: RuntimePlanarRootMotionClip | null, + clipTime: number, +) { + if ( + !runtimeClip || + runtimeClip.landingSettleTime == null || + runtimeClip.landingShoulderBlendEndTime == null || + runtimeClip.landingShoulderBlendEndTime <= runtimeClip.landingSettleTime + ) { + return 0 + } + + return MathUtils.smoothstep( + clipTime, + runtimeClip.landingSettleTime, + runtimeClip.landingShoulderBlendEndTime, + ) +} + +function getToolConeFollowBlend(toolInteractionClipTime: number | null, hasCarryTarget: boolean) { + if (toolInteractionClipTime === null) { + return hasCarryTarget ? 1 : 0 + } + + return MathUtils.smoothstep( + toolInteractionClipTime, + TOOL_CONE_VISIBLE_END_TIME, + TOOL_CONE_VISIBLE_END_TIME + TOOL_CONE_FOLLOW_BLEND_DURATION_SECONDS, + ) +} + +function shouldShowToolConeOverlay( + toolInteractionClipTime: number | null, + hasCarryTarget: boolean, +) { + if (toolInteractionClipTime !== null) { + return ( + hasCarryTarget || + (toolInteractionClipTime >= TOOL_CONE_VISIBLE_START_TIME && + toolInteractionClipTime <= TOOL_CONE_VISIBLE_END_TIME) + ) + } + + return hasCarryTarget +} + +function shouldContinueToolConeCarry( + toolInteractionPhase: NavigationRobotToolInteractionPhase | null, + toolInteractionClipTime: number | null, + hasCarryTarget: boolean, +) { + if (!hasCarryTarget) { + return false + } + + if (toolInteractionPhase === 'pickup') { + return true + } + + if (toolInteractionPhase === 'drop') { + return true + } + + return false +} + +function getForcedClipHoldTime( + clipName: string, + clipDuration: number, + runtimeClip: RuntimePlanarRootMotionClip | null, +) { + return clipDuration +} + +function buildRuntimePlanarRootMotionClip(clip: AnimationClip): RuntimePlanarRootMotionClip | null { + const rootMotionTrack = findRootMotionTrack(clip) + if (!rootMotionTrack) { + return null + } + + const landingSettleTime = + clip.name === JUMPING_DOWN_CLIP_NAME + ? findLandingSettleTime(rootMotionTrack, clip.duration) + : null + const landingShoulderBlendEndTime = + landingSettleTime == null + ? null + : Math.min( + clip.duration, + landingSettleTime + + Math.max( + LANDING_SHOULDER_BLEND_MIN_DURATION_SECONDS, + clip.duration * LANDING_SHOULDER_BLEND_DURATION_RATIO, + ), + ) + + const baseX = rootMotionTrack.values[0] ?? 0 + const baseZ = rootMotionTrack.values[2] ?? 0 + const flattenedRootMotionTrack = rootMotionTrack.clone() as VectorKeyframeTrack + const flattenedValues = flattenedRootMotionTrack.values.slice() + + for (let valueIndex = 0; valueIndex < flattenedValues.length; valueIndex += 3) { + flattenedValues[valueIndex] = baseX + flattenedValues[valueIndex + 2] = baseZ + } + + flattenedRootMotionTrack.values = flattenedValues + + const playbackClip = clip.clone() + playbackClip.tracks = clip.tracks.map((track) => + track === rootMotionTrack ? flattenedRootMotionTrack : track.clone(), + ) + + return { + landingSettleTime, + landingShoulderBlendEndTime, + playbackClip, + samplePlanarLocalOffset: (time, target) => { + const clampedTime = MathUtils.clamp(time, 0, clip.duration) + const times = rootMotionTrack.times + const values = rootMotionTrack.values + const lastFrameIndex = Math.max(0, times.length - 1) + + if (times.length <= 1 || clampedTime <= (times[0] ?? 0)) { + return target.set((values[0] ?? baseX) - baseX, 0, (values[2] ?? baseZ) - baseZ) + } + + if (clampedTime >= (times[lastFrameIndex] ?? clip.duration)) { + const valueIndex = lastFrameIndex * 3 + return target.set( + (values[valueIndex] ?? baseX) - baseX, + 0, + (values[valueIndex + 2] ?? baseZ) - baseZ, + ) + } + + let upperFrameIndex = 1 + while ( + upperFrameIndex < times.length && + (times[upperFrameIndex] ?? clip.duration) < clampedTime + ) { + upperFrameIndex += 1 + } + + const lowerFrameIndex = Math.max(0, upperFrameIndex - 1) + const lowerTime = times[lowerFrameIndex] ?? 0 + const upperTime = times[upperFrameIndex] ?? lowerTime + const blend = + upperTime > lowerTime + ? MathUtils.clamp((clampedTime - lowerTime) / (upperTime - lowerTime), 0, 1) + : 0 + const lowerValueIndex = lowerFrameIndex * 3 + const upperValueIndex = upperFrameIndex * 3 + return target.set( + MathUtils.lerp(values[lowerValueIndex] ?? baseX, values[upperValueIndex] ?? baseX, blend) - + baseX, + 0, + MathUtils.lerp( + values[lowerValueIndex + 2] ?? baseZ, + values[upperValueIndex + 2] ?? baseZ, + blend, + ) - baseZ, + ) + }, + } +} + +function getRobotTransform( + scene: Group, + hoverOffset: number, + assetPath: NavigationRobotAssetPath, +): RobotTransform { + const bounds = new Box3().setFromObject(scene) + const size = bounds.getSize(new Vector3()) + const center = bounds.getCenter(new Vector3()) + const normalizedScale = size.y > Number.EPSILON ? ROBOT_TARGET_HEIGHT / size.y : 1 + const scale = normalizedScale * getRobotAssetScaleMultiplier(assetPath) + + return { + offset: [-center.x * scale, -hoverOffset - bounds.min.y * scale, -center.z * scale], + scale, + } +} + +function getFirstAvailableAction( + actions: Partial>, + clipNames: readonly string[], +) { + return clipNames.map((clipName) => actions[clipName]).find((action) => action != null) ?? null +} + +function getUniqueActions(actions: Array): AnimationAction[] { + return [...new Set(actions.filter((action): action is AnimationAction => Boolean(action)))] +} + +function syncActionPhase(sourceAction: AnimationAction, targetAction: AnimationAction) { + const sourceDuration = sourceAction.getClip().duration + const targetDuration = targetAction.getClip().duration + if (!(sourceDuration > Number.EPSILON && targetDuration > Number.EPSILON)) { + return + } + + const sourcePhase = + (((sourceAction.time % sourceDuration) + sourceDuration) % sourceDuration) / sourceDuration + targetAction.time = sourcePhase * targetDuration +} + +function applyShoulderPoseTargets( + shoulderBones: Partial>, + shoulderTargets: ShoulderPoseTargets, + weight: number, +) { + const clampedWeight = MathUtils.clamp(weight, 0, 1) + if (clampedWeight <= 1e-3) { + return + } + + for (const shoulderBoneName of SHOULDER_BONE_NAMES) { + const shoulderBone = shoulderBones[shoulderBoneName] + const targetQuaternion = shoulderTargets[shoulderBoneName] + if (!(shoulderBone && targetQuaternion)) { + continue + } + + shoulderBone.quaternion.slerp(targetQuaternion, clampedWeight) + } +} + +function getObjectWorldCenter(target: Object3D | null, bounds: Box3, output: Vector3) { + if (!target) { + return null + } + + bounds.setFromObject(target) + if (bounds.isEmpty()) { + return null + } + + return bounds.getCenter(output) +} + +function aimBoneYAxisTowardWorldTarget( + bone: Object3D | null, + targetWorld: Vector3, + weight: number, + boneWorldPosition: Vector3, + targetDirectionWorld: Vector3, + parentWorldQuaternion: Quaternion, + targetDirectionParent: Vector3, + targetLocalQuaternion: Quaternion, +) { + if (!(bone && weight > 1e-4)) { + return + } + + bone.getWorldPosition(boneWorldPosition) + targetDirectionWorld.copy(targetWorld).sub(boneWorldPosition) + if (targetDirectionWorld.lengthSq() <= 1e-8) { + return + } + + targetDirectionWorld.normalize() + if (bone.parent) { + bone.parent.getWorldQuaternion(parentWorldQuaternion).invert() + targetDirectionParent.copy(targetDirectionWorld).applyQuaternion(parentWorldQuaternion) + } else { + targetDirectionParent.copy(targetDirectionWorld) + } + + if (targetDirectionParent.lengthSq() <= 1e-8) { + return + } + + targetDirectionParent.normalize() + targetLocalQuaternion.setFromUnitVectors(LOCAL_BONE_AIM_AXIS, targetDirectionParent) + bone.quaternion.slerp(targetLocalQuaternion, MathUtils.clamp(weight, 0, 1)) + bone.updateMatrixWorld(true) +} + +function accumulateActionTarget( + targets: Map< + AnimationAction, + { timeScaleSum: number; weight: number; weightedTimeScale: number } + >, + action: AnimationAction | null, + weight: number, + timeScale: number, +) { + if (!action) { + return + } + + const nextWeight = MathUtils.clamp(weight, 0, 1) + const currentTarget = targets.get(action) + if (!currentTarget) { + targets.set(action, { + timeScaleSum: nextWeight > Number.EPSILON ? timeScale * nextWeight : 0, + weight: nextWeight, + weightedTimeScale: nextWeight, + }) + return + } + + currentTarget.weight += nextWeight + currentTarget.timeScaleSum += nextWeight > Number.EPSILON ? timeScale * nextWeight : 0 + currentTarget.weightedTimeScale += nextWeight +} + +function setActionInactive(action: AnimationAction) { + action.setEffectiveWeight(0) + action.enabled = false + action.paused = true +} + +function setActionActive(action: AnimationAction, weight: number, timeScale: number) { + action.enabled = true + action.paused = false + if (!action.isRunning()) { + action.play() + } + action.setEffectiveWeight(weight) + action.setEffectiveTimeScale(timeScale) +} + +type RobotRenderableMaterial = Material & { + alphaTest?: number + color?: Color + depthTest?: boolean + depthWrite?: boolean + emissive?: Color + emissiveIntensity?: number + metalness?: number + name: string + opacity?: number + roughness?: number + side?: number + toneMapped?: boolean + transparent?: boolean +} + +function cloneRobotMaterial(material: Material): Material { + const sourceMaterial = material as RobotRenderableMaterial + const clonedMaterial = material.clone() as RobotRenderableMaterial & Material + clonedMaterial.name = sourceMaterial.name + clonedMaterial.transparent = sourceMaterial.transparent ?? false + clonedMaterial.opacity = sourceMaterial.opacity ?? 1 + clonedMaterial.alphaTest = sourceMaterial.alphaTest ?? 0 + clonedMaterial.depthTest = sourceMaterial.depthTest ?? true + clonedMaterial.depthWrite = sourceMaterial.depthWrite ?? true + clonedMaterial.toneMapped = sourceMaterial.toneMapped ?? true + clonedMaterial.side = sourceMaterial.side ?? FrontSide + clonedMaterial.needsUpdate = true + return clonedMaterial +} + +function cloneObjectMaterials(material: Material | Material[]) { + return Array.isArray(material) + ? material.map((entry) => cloneRobotMaterial(entry)) + : cloneRobotMaterial(material) +} + +function disposeObjectMaterials(material: Material | Material[]) { + if (Array.isArray(material)) { + material.forEach((entry) => { + entry.dispose() + }) + return + } + + material.dispose() +} + +function normalizeRobotBaseMaterials(material: Material | Material[]) { + const materials = Array.isArray(material) ? material : [material] + for (const entry of materials) { + entry.side = entry.side ?? FrontSide + entry.needsUpdate = true + } +} + +function normalizeRobotRevealMaterials(material: Material | Material[]) { + const materials = Array.isArray(material) ? material : [material] + for (const entry of materials) { + const robotMaterial = entry as RobotRenderableMaterial + robotMaterial.side = robotMaterial.side ?? FrontSide + robotMaterial.transparent = true + robotMaterial.depthTest = true + robotMaterial.depthWrite = false + robotMaterial.toneMapped = false + if (robotMaterial.emissive) { + robotMaterial.emissive.copy(robotMaterial.color ?? new Color(0xffffff)) + robotMaterial.emissiveIntensity = Math.max(robotMaterial.emissiveIntensity ?? 0, 0.55) + } + robotMaterial.needsUpdate = true + } +} + +function isExcludedFromToolReveal(object: Object3D | null) { + let current: Object3D | null = object + while (current) { + if ( + typeof current.userData === 'object' && + current.userData !== null && + current.userData.pascalExcludeFromToolReveal === true + ) { + return true + } + current = current.parent + } + return false +} + +function recordNavigationRobotFramePerf(frameStart: number) { + recordNavigationPerfSample('navigationRobot.frameMs', performance.now() - frameStart) +} + +function setToolConeIsolatedOverlay( + overlay: { + apexWorldPoint?: [number, number, number] | null + color?: string | null + hullPoints: Array<{ + isApex: boolean + worldPoint: [number, number, number] + }> + supportWorldPoints?: Array<[number, number, number]> + visible: boolean + } | null, +) { + navigationVisualsStore.getState().setToolConeIsolatedOverlay(overlay) +} + +function computeRobotRevealBounds(rootGroup: Group, targetBounds: Box3, scratchBounds: Box3) { + targetBounds.makeEmpty() + rootGroup.updateWorldMatrix(true, true) + + rootGroup.traverse((child) => { + if (!('isMesh' in child) || !child.isMesh) { + return + } + if (isExcludedFromToolReveal(child as Object3D)) { + return + } + + const mesh = child as Mesh & { + boundingBox?: Box3 | null + computeBoundingBox?: () => void + geometry?: { boundingBox?: Box3 | null; computeBoundingBox?: () => void } | undefined + isSkinnedMesh?: boolean + matrixWorld: Group['matrixWorld'] + } + + if (mesh.isSkinnedMesh && typeof mesh.computeBoundingBox === 'function') { + mesh.computeBoundingBox() + if (mesh.boundingBox) { + scratchBounds.copy(mesh.boundingBox).applyMatrix4(mesh.matrixWorld) + targetBounds.union(scratchBounds) + } + return + } + + const geometry = mesh.geometry + if (!geometry) { + return + } + + geometry.computeBoundingBox?.() + if (geometry.boundingBox) { + scratchBounds.copy(geometry.boundingBox).applyMatrix4(mesh.matrixWorld) + targetBounds.union(scratchBounds) + } + }) + + return targetBounds +} + +function createRevealMaterialBinding( + material: Material, + revealMinY: number, + revealMaxY: number, +): RevealMaterialBinding { + const clonedMaterial = material as RevealMaterialBinding['material'] + const revealProgressUniform = { value: 1 } + const revealMinYUniform = { value: revealMinY } + const revealMaxYUniform = { value: revealMaxY } + const revealFeatherUniform = { value: Math.max((revealMaxY - revealMinY) * 0.04, 0.02) } + const revealProgressNode = uniform(revealProgressUniform.value) + const revealMinYNode = uniform(revealMinYUniform.value) + const revealMaxYNode = uniform(revealMaxYUniform.value) + const revealFeatherNode = uniform(revealFeatherUniform.value) + const originalOnBeforeCompile = clonedMaterial.onBeforeCompile?.bind(clonedMaterial) + const originalCustomProgramCacheKey = clonedMaterial.customProgramCacheKey?.bind(clonedMaterial) + const revealCutoffNode = mix( + revealMinYNode.sub(revealFeatherNode), + revealMaxYNode.add(revealFeatherNode), + float(revealProgressNode).clamp(0, 1), + ) + const revealAlphaNode = float(1).sub( + smoothstep( + revealCutoffNode.sub(revealFeatherNode), + revealCutoffNode.add(revealFeatherNode), + positionWorld.y, + ), + ) + const revealOpacityNode = (materialOpacity as any).mul(revealAlphaNode) + + clonedMaterial.transparent = true + clonedMaterial.alphaTest = Math.max(clonedMaterial.alphaTest ?? 0, 0.001) + clonedMaterial.alphaTestNode = float(clonedMaterial.alphaTest) + clonedMaterial.maskNode = revealOpacityNode.greaterThan(float(0.001)) + clonedMaterial.opacityNode = revealOpacityNode + clonedMaterial.onBeforeCompile = (shader) => { + originalOnBeforeCompile?.(shader) + shader.uniforms.uPascalRevealProgress = revealProgressUniform + shader.uniforms.uPascalRevealMinY = revealMinYUniform + shader.uniforms.uPascalRevealMaxY = revealMaxYUniform + shader.uniforms.uPascalRevealFeather = revealFeatherUniform + shader.vertexShader = shader.vertexShader + .replace('#include ', '#include \nvarying float vPascalRevealY;') + .replace( + '#include ', + 'vec4 pascalRevealWorldPosition = modelMatrix * vec4(transformed, 1.0);\nvPascalRevealY = pascalRevealWorldPosition.y;\n#include ', + ) + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + '#include \nvarying float vPascalRevealY;\nuniform float uPascalRevealFeather;\nuniform float uPascalRevealMaxY;\nuniform float uPascalRevealMinY;\nuniform float uPascalRevealProgress;', + ) + .replace( + '#include ', + `float pascalRevealCutoff = mix(uPascalRevealMinY - uPascalRevealFeather, uPascalRevealMaxY + uPascalRevealFeather, clamp(uPascalRevealProgress, 0.0, 1.0)); +float pascalRevealAlpha = 1.0 - smoothstep(pascalRevealCutoff - uPascalRevealFeather, pascalRevealCutoff + uPascalRevealFeather, vPascalRevealY); +diffuseColor.a *= pascalRevealAlpha; +if (diffuseColor.a <= 0.001) discard; +#include `, + ) + } + clonedMaterial.customProgramCacheKey = () => + `${originalCustomProgramCacheKey?.() ?? ''}|pascal-robot-reveal` + clonedMaterial.needsUpdate = true + + return { + material: clonedMaterial, + uniforms: { + revealFeather: revealFeatherUniform, + revealMaxY: revealMaxYUniform, + revealMinY: revealMinYUniform, + revealProgress: revealProgressUniform, + }, + webgpuUniforms: { + revealFeather: revealFeatherNode as RevealUniform, + revealMaxY: revealMaxYNode as RevealUniform, + revealMinY: revealMinYNode as RevealUniform, + revealProgress: revealProgressNode as RevealUniform, + }, + } +} + +export function NavigationRobot({ + active = true, + animationPaused = false, + assetPath = DEFAULT_NAVIGATION_ROBOT_ASSET_PATH, + clipNameOverrides, + debugId, + debugStateRef, + debugTransitionPreview, + forcedClipPlayback, + forcedClipVisualOffset, + hoverOffset, + motionRef, + onReady, + onSceneReady, + onWarmupReadyChange, + materialDebugMode = 'auto', + skinnedMeshVisibilityOverride = null, + staticMeshVisibilityOverride = null, + showToolAttachments = false, + toolConeColor = null, + toolCarryItemId = null, + toolCarryItemIdRef, + toolInteractionPhaseRef, + toolInteractionTargetItemIdRef, +}: NavigationRobotProps) { + const { camera: sceneCamera, gl, scene: rootScene } = useThree() + const [assetUrl, setAssetUrl] = useState(() => + getNavigationRobotAssetUrl( + typeof window === 'undefined' ? null : window.localStorage, + assetPath, + ), + ) + const { scene, animations } = useGLTF(assetUrl) + const { scene: toolScene } = useGLTF(TOOL_ASSET_PATH) + const clonedScene = useMemo( + () => + measureNavigationPerf('navigationRobot.cloneSceneMs', () => cloneSkeleton(scene) as Group), + [scene], + ) + const runtimePlanarRootMotionClips = useMemo(() => { + const clipByName = new Map() + const processedAnimations = animations.map((clip) => { + if (clip.name !== 'Jumping_Down') { + return clip + } + + const runtimePlanarRootMotionClip = buildRuntimePlanarRootMotionClip(clip) + if (!runtimePlanarRootMotionClip) { + return clip + } + + clipByName.set(clip.name, runtimePlanarRootMotionClip) + return runtimePlanarRootMotionClip.playbackClip + }) + + return { + animations: processedAnimations, + byName: clipByName, + } + }, [animations]) + const { actions, mixer } = useAnimations(runtimePlanarRootMotionClips.animations, clonedScene) + const [storedClipOverrides, setStoredClipOverrides] = useState( + DEFAULT_NAVIGATION_ROBOT_CLIP_OVERRIDES, + ) + const skinnedMeshBaseVisibilityRef = useRef(new WeakMap()) + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + const syncStoredClipOverrides = () => { + setStoredClipOverrides(readNavigationRobotClipOverrides(window.localStorage)) + } + const syncAssetUrl = () => { + setAssetUrl(getNavigationRobotAssetUrl(window.localStorage, assetPath)) + } + + syncStoredClipOverrides() + syncAssetUrl() + window.addEventListener(NAVIGATION_ROBOT_ASSET_UPDATED_EVENT, syncAssetUrl) + window.addEventListener(NAVIGATION_ROBOT_CLIP_OVERRIDE_EVENT, syncStoredClipOverrides) + window.addEventListener('storage', syncStoredClipOverrides) + window.addEventListener('storage', syncAssetUrl) + return () => { + window.removeEventListener(NAVIGATION_ROBOT_ASSET_UPDATED_EVENT, syncAssetUrl) + window.removeEventListener(NAVIGATION_ROBOT_CLIP_OVERRIDE_EVENT, syncStoredClipOverrides) + window.removeEventListener('storage', syncStoredClipOverrides) + window.removeEventListener('storage', syncAssetUrl) + } + }, [assetPath]) + + useLayoutEffect(() => { + clonedScene.traverse((child) => { + const mesh = child as Mesh & { isSkinnedMesh?: boolean } + if (!mesh.isMesh) { + return + } + + if (!skinnedMeshBaseVisibilityRef.current.has(mesh)) { + skinnedMeshBaseVisibilityRef.current.set(mesh, mesh.visible) + } + + const baseVisible = skinnedMeshBaseVisibilityRef.current.get(mesh) ?? true + if (mesh.isSkinnedMesh) { + mesh.visible = + skinnedMeshVisibilityOverride === null + ? baseVisible + : baseVisible && skinnedMeshVisibilityOverride + return + } + + mesh.visible = + staticMeshVisibilityOverride === null + ? baseVisible + : baseVisible && staticMeshVisibilityOverride + }) + }, [clonedScene, skinnedMeshVisibilityOverride, staticMeshVisibilityOverride]) + const resolvedClipOverrides = useMemo( + () => ({ + idle: clipNameOverrides?.idle ?? storedClipOverrides.idle, + run: clipNameOverrides?.run ?? storedClipOverrides.run, + walk: clipNameOverrides?.walk ?? storedClipOverrides.walk, + }), + [ + clipNameOverrides?.idle, + clipNameOverrides?.run, + clipNameOverrides?.walk, + storedClipOverrides.idle, + storedClipOverrides.run, + storedClipOverrides.walk, + ], + ) + const idleClipNames = useMemo( + () => + getNavigationRobotClipNames( + DEFAULT_NAVIGATION_ROBOT_IDLE_CLIP_NAMES, + resolvedClipOverrides.idle, + ), + [resolvedClipOverrides.idle], + ) + const walkClipNames = useMemo( + () => + getNavigationRobotClipNames( + DEFAULT_NAVIGATION_ROBOT_WALK_CLIP_NAMES, + resolvedClipOverrides.walk, + ), + [resolvedClipOverrides.walk], + ) + const runClipNames = useMemo( + () => + getNavigationRobotClipNames( + DEFAULT_NAVIGATION_ROBOT_RUN_CLIP_NAMES, + resolvedClipOverrides.run, + ), + [resolvedClipOverrides.run], + ) + const forcedClipAction = + forcedClipPlayback?.clipName && forcedClipPlayback.clipName.length > 0 + ? (actions[forcedClipPlayback.clipName] ?? null) + : null + const allAnimationActions = useMemo( + () => Object.values(actions).filter((action): action is AnimationAction => Boolean(action)), + [actions], + ) + const fallbackAction = allAnimationActions[0] ?? null + const idleAction = getFirstAvailableAction(actions, idleClipNames) ?? fallbackAction ?? null + const walkAction = getFirstAvailableAction(actions, walkClipNames) ?? idleAction + const runAction = getFirstAvailableAction(actions, runClipNames) ?? walkAction ?? idleAction + const locomotionActions = useMemo( + () => getUniqueActions([idleAction, walkAction, runAction]), + [idleAction, runAction, walkAction], + ) + const runtimeActions = useMemo( + () => getUniqueActions([...locomotionActions, forcedClipAction]), + [forcedClipAction, locomotionActions], + ) + const activeClipNameRef = useRef(null) + const animationBlendStateRef = useRef({ + idleWeight: 1, + runTimeScale: 1, + runWeight: 0, + walkTimeScale: 1, + walkWeight: 0, + }) + const debugBoneSamplesRef = useRef([]) + const debugMovingEvidenceRef = useRef(0) + const revealMaterialBindingsRef = useRef([]) + const revealMaterialEntriesRef = useRef([]) + const revealMaterialsActiveRef = useRef(false) + const toolRevealMaterialBindingsRef = useRef([]) + const toolRevealMaterialEntriesRef = useRef([]) + const toolRevealMaterialsActiveRef = useRef(false) + const readySignalKeyRef = useRef(null) + const materialWarmupQueuedRef = useRef(false) + const [materialWarmupReady, setMaterialWarmupReady] = useState(false) + const resolveRevealMaterialsShouldBeActive = useMemo( + () => (autoValue: boolean) => { + if (materialDebugMode === 'reveal-only') { + return true + } + if (materialDebugMode === 'original-only') { + return false + } + return autoValue + }, + [materialDebugMode], + ) + + const shoulderBonesRef = useRef>>({}) + const leftShoulderFollowBoneRef = useRef(null) + const leftUpperArmBoneRef = useRef(null) + const leftElbowBoneRef = useRef(null) + const leftHandBoneRef = useRef(null) + const leftToolRenderable = useMemo( + () => + createToolRenderable( + toolScene, + 'navigation-robot-tool-left', + LEFT_TOOL_OFFSET, + LEFT_TOOL_ROTATION_DEGREES, + ), + [toolScene], + ) + const leftToolConeMaterial = useMemo(() => { + const material = new MeshBasicNodeMaterial({ + color: TOOL_CONE_OVERLAY_COLOR, + depthTest: false, + depthWrite: false, + side: DoubleSide, + transparent: true, + }) + const opacityGradient = createBendFadeNode(TOOL_CONE_GRADIENT_BEND).mul( + float(TOOL_CONE_OPACITY_SCALE), + ) + material.opacityNode = opacityGradient + material.maskNode = opacityGradient.greaterThan(float(0.001)) + material.toneMapped = false + material.transparent = true + material.depthTest = false + material.depthWrite = false + return material + }, []) + const leftToolConeOccludedMaterial = useMemo(() => { + const material = new MeshBasicNodeMaterial({ + color: TOOL_CONE_OVERLAY_COLOR, + depthTest: false, + depthWrite: false, + side: DoubleSide, + transparent: true, + }) + const opacityGradient = createBendFadeNode(TOOL_CONE_GRADIENT_BEND).mul( + float(TOOL_CONE_OPACITY_SCALE), + ) + material.opacityNode = opacityGradient + material.maskNode = opacityGradient.greaterThan(float(0.001)) + material.toneMapped = false + material.transparent = true + material.depthTest = false + material.depthWrite = false + return material + }, []) + const leftToolConeOutlineMaterial = useMemo(() => { + const material = new LineBasicMaterial({ + color: TOOL_CONE_OVERLAY_COLOR, + depthTest: true, + opacity: 0.96 * TOOL_CONE_OPACITY_SCALE, + transparent: true, + }) + material.toneMapped = false + return material + }, []) + const leftToolConeOccludedOutlineMaterial = useMemo(() => { + const material = new LineBasicMaterial({ + color: TOOL_CONE_OVERLAY_COLOR, + depthTest: true, + opacity: 0.96 * TOOL_CONE_OPACITY_SCALE, + transparent: true, + }) + material.toneMapped = false + return material + }, []) + const leftToolConeInwardGlowMaterial = useMemo(() => { + const material = new MeshBasicNodeMaterial({ + color: TOOL_CONE_EDGE_GLOW_COLOR, + depthTest: false, + depthWrite: false, + side: DoubleSide, + transparent: true, + }) + const inwardFade = smoothstep(float(0), float(1), uv().x) + .pow(float(TOOL_CONE_EDGE_GLOW_INWARD_GRADIENT_BEND)) + .oneMinus() + const lengthFade = uv().y.oneMinus().pow(float(TOOL_CONE_EDGE_GLOW_ATTENUATION)) + const glowOpacity = inwardFade.mul(lengthFade).mul(float(TOOL_CONE_EDGE_GLOW_BRIGHTNESS)) + material.opacityNode = glowOpacity + material.maskNode = glowOpacity.greaterThan(float(0.001)) + material.toneMapped = false + material.transparent = true + material.depthTest = false + material.depthWrite = false + material.blending = AdditiveBlending + return material + }, []) + const leftToolConeOccludedInwardGlowMaterial = useMemo(() => { + const material = new MeshBasicNodeMaterial({ + color: TOOL_CONE_EDGE_GLOW_COLOR, + depthTest: false, + depthWrite: false, + side: DoubleSide, + transparent: true, + }) + const inwardFade = smoothstep(float(0), float(1), uv().x) + .pow(float(TOOL_CONE_EDGE_GLOW_INWARD_GRADIENT_BEND)) + .oneMinus() + const lengthFade = uv().y.oneMinus().pow(float(TOOL_CONE_EDGE_GLOW_ATTENUATION)) + const glowOpacity = inwardFade.mul(lengthFade).mul(float(TOOL_CONE_EDGE_GLOW_BRIGHTNESS)) + material.opacityNode = glowOpacity + material.maskNode = glowOpacity.greaterThan(float(0.001)) + material.toneMapped = false + material.transparent = true + material.depthTest = false + material.depthWrite = false + material.blending = AdditiveBlending + return material + }, []) + const leftToolConeOutwardGlowMaterial = useMemo(() => { + const material = new MeshBasicNodeMaterial({ + color: TOOL_CONE_EDGE_GLOW_COLOR, + depthTest: false, + depthWrite: false, + side: DoubleSide, + transparent: true, + }) + const outwardFade = smoothstep(float(0), float(1), uv().x) + .pow(float(TOOL_CONE_EDGE_GLOW_OUTWARD_GRADIENT_BEND)) + .oneMinus() + const lengthFade = uv().y.oneMinus().pow(float(TOOL_CONE_EDGE_GLOW_ATTENUATION)) + const glowOpacity = outwardFade.mul(lengthFade).mul(float(TOOL_CONE_EDGE_GLOW_BRIGHTNESS)) + material.opacityNode = glowOpacity + material.maskNode = glowOpacity.greaterThan(float(0.001)) + material.toneMapped = false + material.transparent = true + material.depthTest = false + material.depthWrite = false + material.blending = AdditiveBlending + return material + }, []) + const leftToolConeOccludedOutwardGlowMaterial = useMemo(() => { + const material = new MeshBasicNodeMaterial({ + color: TOOL_CONE_EDGE_GLOW_COLOR, + depthTest: false, + depthWrite: false, + side: DoubleSide, + transparent: true, + }) + const outwardFade = smoothstep(float(0), float(1), uv().x) + .pow(float(TOOL_CONE_EDGE_GLOW_OUTWARD_GRADIENT_BEND)) + .oneMinus() + const lengthFade = uv().y.oneMinus().pow(float(TOOL_CONE_EDGE_GLOW_ATTENUATION)) + const glowOpacity = outwardFade.mul(lengthFade).mul(float(TOOL_CONE_EDGE_GLOW_BRIGHTNESS)) + material.opacityNode = glowOpacity + material.maskNode = glowOpacity.greaterThan(float(0.001)) + material.toneMapped = false + material.transparent = true + material.depthTest = false + material.depthWrite = false + material.blending = AdditiveBlending + return material + }, []) + const leftToolConeOverlayRenderable = useMemo( + () => + createToolConeRenderable( + 'navigation-robot-tool-left-cone-overlay', + leftToolConeMaterial, + leftToolConeOutlineMaterial, + leftToolConeInwardGlowMaterial, + leftToolConeOutwardGlowMaterial, + ), + [ + leftToolConeInwardGlowMaterial, + leftToolConeMaterial, + leftToolConeOutwardGlowMaterial, + leftToolConeOutlineMaterial, + ], + ) + const leftToolConeOccludedRenderable = useMemo( + () => + createToolConeRenderable( + 'navigation-robot-tool-left-cone-occluded', + leftToolConeOccludedMaterial, + leftToolConeOccludedOutlineMaterial, + leftToolConeOccludedInwardGlowMaterial, + leftToolConeOccludedOutwardGlowMaterial, + ), + [ + leftToolConeOccludedInwardGlowMaterial, + leftToolConeOccludedMaterial, + leftToolConeOccludedOutlineMaterial, + leftToolConeOccludedOutwardGlowMaterial, + ], + ) + const leftToolConeRenderables = useMemo( + () => [leftToolConeOverlayRenderable, leftToolConeOccludedRenderable] as const, + [leftToolConeOccludedRenderable, leftToolConeOverlayRenderable], + ) + useEffect(() => { + const nextColor = new Color(toolConeColor ?? TOOL_CONE_OVERLAY_COLOR) + leftToolConeMaterial.color.set(nextColor) + leftToolConeOccludedMaterial.color.set(nextColor) + leftToolConeOutlineMaterial.color.set(nextColor) + leftToolConeOccludedOutlineMaterial.color.set(nextColor) + leftToolConeInwardGlowMaterial.color.set(nextColor) + leftToolConeOccludedInwardGlowMaterial.color.set(nextColor) + leftToolConeOutwardGlowMaterial.color.set(nextColor) + leftToolConeOccludedOutwardGlowMaterial.color.set(nextColor) + }, [ + leftToolConeInwardGlowMaterial, + leftToolConeMaterial, + leftToolConeOccludedInwardGlowMaterial, + leftToolConeOccludedMaterial, + leftToolConeOccludedOutlineMaterial, + leftToolConeOccludedOutwardGlowMaterial, + leftToolConeOutlineMaterial, + leftToolConeOutwardGlowMaterial, + toolConeColor, + ]) + const checkoutLeftHandRotationRef = useRef( + new Quaternion().setFromEuler( + new Euler( + MathUtils.degToRad(CHECKOUT_LEFT_HAND_ROTATION_DEGREES.x), + MathUtils.degToRad(CHECKOUT_LEFT_HAND_ROTATION_DEGREES.y), + MathUtils.degToRad(CHECKOUT_LEFT_HAND_ROTATION_DEGREES.z), + ), + ), + ) + const checkoutLeftHandScratchRef = useRef(new Quaternion()) + const checkoutLeftHandBaseQuaternionRef = useRef(new Quaternion()) + const checkoutLeftHandRestorePendingRef = useRef(false) + const visualOffsetGroupRef = useRef(null) + const rootGroupRef = useRef(null) + const rootMotionBoneRef = useRef(null) + const rootMotionBaselineScenePositionRef = useRef(null) + const rootMotionBaselineWorldRef = useRef(new Vector3()) + const rootMotionCurrentWorldRef = useRef(new Vector3()) + const rootMotionOffsetRef = useRef(new Vector3()) + const runtimePlanarRootMotionLocalOffsetRef = useRef(new Vector3()) + const runtimePlanarRootMotionWorldOriginRef = useRef(new Vector3()) + const runtimePlanarRootMotionWorldTargetRef = useRef(new Vector3()) + const runtimePlanarRootMotionWorldOffsetRef = useRef(new Vector3()) + const runtimePlanarRootMotionVisualOriginRef = useRef(new Vector3()) + const runtimePlanarRootMotionVisualTargetRef = useRef(new Vector3()) + const runtimePlanarRootMotionVisualOffsetRef = useRef(new Vector3()) + const previousForcedClipActionRef = useRef(null) + const releasedForcedActionRef = useRef(null) + const releasedForcedWeightRef = useRef(0) + const toolConeSupportWorldPointsRef = useRef(TOOL_CONE_SUPPORT_SIGNS.map(() => new Vector3())) + const toolConeSupportLocalPointsRef = useRef(TOOL_CONE_SUPPORT_SIGNS.map(() => new Vector3())) + const toolConeSupportScoresRef = useRef(TOOL_CONE_SUPPORT_SIGNS.map(() => -Infinity)) + const toolConeSupportDiagnosticsRef = useRef<(ToolConeSupportPointDiagnostic | null)[]>( + TOOL_CONE_SUPPORT_SIGNS.map(() => null), + ) + const toolConeFrozenHullTargetItemIdRef = useRef(null) + const toolConeFrozenHullPointsRef = useRef([]) + const toolConeFrozenHullWorldPointScratchRef = useRef(new Vector3()) + const toolConeScratchPointRef = useRef(new Vector3()) + const toolConeProjectedHullCandidatesRef = useRef([]) + const toolConeApexWorldPointRef = useRef(new Vector3()) + const toolConeApexLocalPointRef = useRef(new Vector3()) + const toolConeCarryTargetBoundsRef = useRef(new Box3()) + const toolConeCarryTargetCenterRef = useRef(new Vector3()) + const toolConeFollowReleaseBlendRef = useRef(0) + const toolConeFollowReleasePoseReadyRef = useRef(false) + const toolConeFollowReleaseShoulderQuaternionRef = useRef(new Quaternion()) + const toolConeFollowReleaseUpperArmQuaternionRef = useRef(new Quaternion()) + const toolConeFollowReleaseElbowQuaternionRef = useRef(new Quaternion()) + const toolConeFollowReleaseLeftHandQuaternionRef = useRef(new Quaternion()) + const toolConeFollowShoulderTargetRef = useRef(new Vector3()) + const toolConeFollowForearmTargetRef = useRef(new Vector3()) + const toolConeFrameIdRef = useRef(0) + const toolConePrewarmedRef = useRef(false) + const toolConeLogicExpectedFrameIdRef = useRef(null) + const toolConeVisibleFrameIdRef = useRef(null) + const toolConeSubmittedAnyFrameIdRef = useRef(null) + const toolConeSubmittedMainFrameIdRef = useRef(null) + const toolConeSubmittedInwardGlowFrameIdRef = useRef(null) + const toolConeSubmittedOutwardGlowFrameIdRef = useRef(null) + const toolConeLastSubmittedAtMsRef = useRef(null) + const toolConePreviousFrameLogicExpectedRef = useRef(false) + const toolConePreviousFrameVisibleRef = useRef(false) + const toolConePreviousFrameSubmittedAnyRef = useRef(false) + const toolConePreviousFrameSubmittedMainRef = useRef(false) + const toolConePreviousFrameSubmittedInwardGlowRef = useRef(false) + const toolConePreviousFrameSubmittedOutwardGlowRef = useRef(false) + const toolConeFailureStreakFramesRef = useRef(0) + const toolConeGeometryMissStreakFramesRef = useRef(0) + const toolConeRenderMissStreakFramesRef = useRef(0) + const toolConeHullProjectedPointScratchRef = useRef(new Vector3()) + const toolConeRenderedWorldPointScratchRef = useRef(new Vector3()) + const shoulderAimBoneWorldPositionRef = useRef(new Vector3()) + const shoulderAimParentWorldQuaternionRef = useRef(new Quaternion()) + const shoulderAimTargetDirectionParentRef = useRef(new Vector3()) + const shoulderAimTargetDirectionWorldRef = useRef(new Vector3()) + const shoulderAimTargetLocalQuaternionRef = useRef(new Quaternion()) + const upperArmAimBoneWorldPositionRef = useRef(new Vector3()) + const upperArmAimParentWorldQuaternionRef = useRef(new Quaternion()) + const upperArmAimTargetDirectionParentRef = useRef(new Vector3()) + const upperArmAimTargetDirectionWorldRef = useRef(new Vector3()) + const upperArmAimTargetLocalQuaternionRef = useRef(new Quaternion()) + const forearmAimBoneWorldPositionRef = useRef(new Vector3()) + const forearmAimParentWorldQuaternionRef = useRef(new Quaternion()) + const forearmAimTargetDirectionParentRef = useRef(new Vector3()) + const forearmAimTargetDirectionWorldRef = useRef(new Vector3()) + const forearmAimTargetLocalQuaternionRef = useRef(new Quaternion()) + const revealBoundsRef = useRef(new Box3()) + const revealBoundsScratchRef = useRef(new Box3()) + const visualRevealProgressRef = useRef(1) + const toolRevealBoundsRef = useRef(new Box3()) + const toolRevealBoundsScratchRef = useRef(new Box3()) + const toolVisualRevealProgressRef = useRef(1) + const forcedClipPlaybackKey = forcedClipPlayback + ? [ + forcedClipPlayback.clipName, + forcedClipPlayback.loop ?? 'once', + forcedClipPlayback.playbackToken ?? 'stable', + forcedClipPlayback.revealFromStart ? 'reveal' : 'plain', + forcedClipPlayback.stabilizeRootMotion ? 'stabilized' : 'free', + forcedClipPlayback.timeScale ?? 1, + forcedClipPlayback.holdLastFrame ? 'hold' : 'release', + ].join(':') + : null + const debugTransitionPreviewClipName = debugTransitionPreview?.releasedClipName ?? null + const robotTransform = useMemo( + () => + measureNavigationPerf('navigationRobot.transformMs', () => + getRobotTransform(clonedScene, hoverOffset, assetPath), + ), + [assetPath, clonedScene, hoverOffset], + ) + const idleShoulderTargets = useMemo(() => { + const idleClip = idleAction?.getClip() ?? null + if (!idleClip) { + return {} + } + + const targets: ShoulderPoseTargets = {} + for (const shoulderBoneName of SHOULDER_BONE_NAMES) { + const shoulderTrack = findBoneQuaternionTrack(idleClip, shoulderBoneName) + if (!shoulderTrack) { + continue + } + + targets[shoulderBoneName] = readTrackFirstQuaternion(shoulderTrack, new Quaternion()) + } + return targets + }, [idleAction]) + const initialSceneRevealProgressRef = useRef(forcedClipPlayback?.revealFromStart ? 0 : 1) + + useEffect(() => { + onSceneReady?.(clonedScene) + + return () => { + onSceneReady?.(null) + } + }, [clonedScene, onSceneReady]) + + useEffect(() => { + const detachTool = (toolRenderable: Group) => { + if (toolRenderable.parent) { + toolRenderable.parent.remove(toolRenderable) + } + } + for (const toolConeRenderable of leftToolConeRenderables) { + if (toolConeRenderable.group.parent) { + toolConeRenderable.group.parent.remove(toolConeRenderable.group) + } + } + + if (!showToolAttachments) { + detachTool(leftToolRenderable) + return + } + + const leftHandBone = leftHandBoneRef.current + + if (leftHandBone) { + leftHandBone.add(leftToolRenderable) + } else { + detachTool(leftToolRenderable) + } + + return () => { + detachTool(leftToolRenderable) + } + }, [clonedScene, leftToolConeRenderables, leftToolRenderable, showToolAttachments]) + + useEffect(() => { + toolConePrewarmedRef.current = false + }, [leftToolConeRenderables]) + + useEffect(() => { + toolConePrewarmedRef.current = true + return + }, [gl, leftToolConeRenderables, rootScene, sceneCamera, showToolAttachments]) + + useEffect(() => { + materialWarmupQueuedRef.current = false + setMaterialWarmupReady(false) + }, [clonedScene, leftToolConeRenderables, leftToolRenderable]) + + useEffect(() => { + onWarmupReadyChange?.(materialWarmupReady) + }, [materialWarmupReady, onWarmupReadyChange]) + + useEffect(() => { + if (materialWarmupQueuedRef.current) { + return + } + + if ( + revealMaterialEntriesRef.current.length === 0 && + toolRevealMaterialEntriesRef.current.length === 0 + ) { + setMaterialWarmupReady(true) + return + } + + materialWarmupQueuedRef.current = true + let cancelled = false + const fallbackTimeoutId = window.setTimeout(() => { + if (cancelled) { + return + } + + recordNavigationPerfMark('navigationRobot.materialWarmupFallbackReady', { + timeoutMs: NAVIGATION_ROBOT_MATERIAL_WARMUP_FALLBACK_MS, + }) + setMaterialWarmupReady(true) + }, NAVIGATION_ROBOT_MATERIAL_WARMUP_FALLBACK_MS) + const compileWarmup = async () => { + if (cancelled) { + return + } + + const warmupRoot = new Group() + warmupRoot.name = '__pascalRobotWarmupRoot__' + const warmupRoots: Object3D[] = [] + const addWarmupRoot = (root: Object3D, x: number, z: number) => { + root.position.set(x, 0, z) + disableFrustumCulling(root) + warmupRoot.add(root) + warmupRoots.push(root) + } + + const warmupCamera = new PerspectiveCamera(42, 1, 0.01, 20) + warmupCamera.position.set(0, 1.2, 3.6) + warmupCamera.lookAt(0, 1.05, -0.8) + warmupCamera.updateProjectionMatrix() + warmupCamera.updateMatrixWorld(true) + + const warmRobotOriginal = cloneSkeleton(clonedScene) as Group + const warmRobotReveal = cloneSkeleton(clonedScene) as Group + applyWarmupRevealMaterials(warmRobotReveal, revealMaterialEntriesRef.current) + addWarmupRoot(warmRobotOriginal, -0.9, -1.4) + addWarmupRoot(warmRobotReveal, 0.9, -1.4) + + if (showToolAttachments) { + const warmToolOriginal = leftToolRenderable.clone(true) + const warmToolReveal = leftToolRenderable.clone(true) + applyWarmupRevealMaterials(warmToolReveal, toolRevealMaterialEntriesRef.current) + addWarmupRoot(warmToolOriginal, -0.25, -0.8) + addWarmupRoot(warmToolReveal, 0.25, -0.8) + } + + leftToolConeRenderables.forEach((renderable, index) => { + addWarmupRoot(renderable.group.clone(true), -0.45 + index * 0.3, -0.35) + }) + scene.add(warmupRoot) + + const renderer = gl as unknown as { + backend?: { isWebGPUBackend?: boolean } + compileAsync?: (scene: Scene, camera: object) => Promise + domElement?: { height?: number; width?: number } + getDrawingBufferSize?: (target: Vector2) => Vector2 + render?: (scene: Scene, camera: object) => void + setScissor?: (x: number, y: number, width: number, height: number) => void + setScissorTest?: (enabled: boolean) => void + setRenderTarget?: (target: RenderTarget | null) => void + setViewport?: (x: number, y: number, width: number, height: number) => void + } + const warmupStart = performance.now() + const renderTarget = new RenderTarget(96, 96, { depthBuffer: true }) + const renderWarmupPass = (sampleName: string, cameraOverride: Camera = warmupCamera) => { + const renderStart = performance.now() + const drawingBufferSize = getRendererDrawingBufferSize(renderer) + const canvasWidth = Math.max(1, Math.floor(drawingBufferSize.x)) + const canvasHeight = Math.max(1, Math.floor(drawingBufferSize.y)) + const allowScreenWarmupPass = !isTrueWebGPUBackend(renderer) + try { + renderer.setRenderTarget?.(renderTarget) + renderer.render?.(scene as unknown as Scene, cameraOverride) + recordNavigationPerfSample(sampleName, performance.now() - renderStart) + + if ( + allowScreenWarmupPass && + renderer.setViewport && + renderer.setScissor && + renderer.setScissorTest && + canvasWidth > 0 && + canvasHeight > 0 + ) { + const screenRenderStart = performance.now() + renderer.setRenderTarget?.(null) + renderer.setScissorTest(true) + renderer.setViewport(0, 0, 1, 1) + renderer.setScissor(0, 0, 1, 1) + renderer.render?.(scene as unknown as Scene, cameraOverride) + recordNavigationPerfSample( + `${sampleName.replace(/Ms$/, '')}ScreenMs`, + performance.now() - screenRenderStart, + ) + } + } finally { + if (renderer.setViewport && renderer.setScissor && renderer.setScissorTest) { + renderer.setRenderTarget?.(null) + renderer.setViewport(0, 0, canvasWidth, canvasHeight) + renderer.setScissor(0, 0, canvasWidth, canvasHeight) + renderer.setScissorTest(false) + } + } + } + try { + try { + await (renderer.compileAsync?.(scene as unknown as Scene, warmupCamera) ?? + Promise.resolve()) + } catch {} + + recordNavigationPerfSample( + 'navigationRobot.renderWarmupCompileAsyncWallMs', + performance.now() - warmupStart, + ) + + if (cancelled) { + return + } + + renderWarmupPass('navigationRobot.renderWarmupRenderMs') + + const liveWarmupRoot = rootGroupRef.current + if (liveWarmupRoot) { + const actorBodyProbeState = { hits: 0, meshName: null as string | null } + const toolProbeState = { hits: 0, meshName: null as string | null } + const bindWarmupSubmissionProbe = ( + predicate: (mesh: Mesh) => boolean, + state: { hits: number; meshName: string | null }, + ) => { + const targetMesh = + collectMeshList(liveWarmupRoot).find((mesh) => predicate(mesh)) ?? null + if (!targetMesh) { + return () => {} + } + state.meshName = targetMesh.name || null + const previousHandler = targetMesh.onBeforeRender + targetMesh.onBeforeRender = (...args: unknown[]) => { + state.hits += 1 + previousHandler(...(args as Parameters>)) + } + return () => { + targetMesh.onBeforeRender = previousHandler + } + } + const cleanupActorBodyProbe = bindWarmupSubmissionProbe( + (mesh) => (mesh as Mesh & { isSkinnedMesh?: boolean }).isSkinnedMesh === true, + actorBodyProbeState, + ) + const cleanupToolProbe = bindWarmupSubmissionProbe( + (mesh) => hasAncestorNamed(mesh, 'navigation-robot-tool-left'), + toolProbeState, + ) + const liveMeshCullingEntries = collectMeshList(liveWarmupRoot).map((mesh) => ({ + frustumCulled: mesh.frustumCulled, + mesh, + })) + const visibilityEntries: Array<{ object: Object3D; visible: boolean }> = [] + let current: Object3D | null = liveWarmupRoot + while (current && current !== scene) { + visibilityEntries.push({ object: current, visible: current.visible }) + current.visible = true + current = current.parent + } + liveMeshCullingEntries.forEach(({ mesh }) => { + mesh.frustumCulled = false + }) + + scene.updateMatrixWorld(true) + + const liveBounds = new Box3().setFromObject(liveWarmupRoot) + if (!liveBounds.isEmpty()) { + const liveCenter = new Vector3() + const liveSize = new Vector3() + liveBounds.getCenter(liveCenter) + liveBounds.getSize(liveSize) + + warmupCamera.position.set( + liveCenter.x, + liveCenter.y + Math.max(0.6, liveSize.y * 0.4), + liveCenter.z + Math.max(1.6, Math.max(liveSize.x, liveSize.z) * 1.8), + ) + warmupCamera.lookAt(liveCenter.x, liveCenter.y + liveSize.y * 0.2, liveCenter.z) + warmupCamera.updateProjectionMatrix() + warmupCamera.updateMatrixWorld(true) + + const liveCompileStart = performance.now() + try { + await (renderer.compileAsync?.(scene as unknown as Scene, warmupCamera) ?? + Promise.resolve()) + } catch {} + recordNavigationPerfSample( + 'navigationRobot.liveRenderWarmupCompileAsyncWallMs', + performance.now() - liveCompileStart, + ) + + renderWarmupPass('navigationRobot.liveRenderWarmupRenderMs') + + const liveSceneCameraCompileStart = performance.now() + try { + await (renderer.compileAsync?.( + scene as unknown as Scene, + sceneCamera as unknown as Camera, + ) ?? Promise.resolve()) + } catch {} + recordNavigationPerfSample( + 'navigationRobot.liveSceneCameraWarmupCompileAsyncWallMs', + performance.now() - liveSceneCameraCompileStart, + ) + renderWarmupPass( + 'navigationRobot.liveSceneCameraWarmupRenderMs', + sceneCamera as unknown as Camera, + ) + + const sampleLocomotionWarmup = ( + label: 'run' | 'walk', + weights: { idle: number; run: number; walk: number }, + ) => { + for (const action of allAnimationActions) { + setActionInactive(action) + } + + const applyActionPose = (action: AnimationAction | null, weight: number) => { + if (!action || weight <= 1e-3) { + return + } + + action.enabled = true + action.clampWhenFinished = false + if (!action.isRunning()) { + action.play() + } + action.paused = true + action.time = action.getClip().duration * 0.25 + action.setEffectiveWeight(weight) + action.setEffectiveTimeScale(0) + } + + applyActionPose(idleAction, weights.idle) + applyActionPose(walkAction, weights.walk) + applyActionPose(runAction, weights.run) + mixer.update(0) + scene.updateMatrixWorld(true) + + renderWarmupPass( + `navigationRobot.liveRenderWarmup${label === 'walk' ? 'Walk' : 'Run'}RenderMs`, + sceneCamera as unknown as Camera, + ) + } + + sampleLocomotionWarmup('walk', { idle: 0, run: 0, walk: 1 }) + sampleLocomotionWarmup('run', { idle: 0, run: 1, walk: 0 }) + for (const action of allAnimationActions) { + setActionInactive(action) + } + mixer.update(0) + scene.updateMatrixWorld(true) + mergeNavigationPerfMeta({ + navigationRobotLiveWarmupActorBodyHits: actorBodyProbeState.hits, + navigationRobotLiveWarmupActorBodyMeshName: actorBodyProbeState.meshName, + navigationRobotLiveWarmupToolHits: toolProbeState.hits, + navigationRobotLiveWarmupToolMeshName: toolProbeState.meshName, + }) + recordNavigationPerfMark('navigationRobot.liveWarmupProbeSummary', { + actorBodyHits: actorBodyProbeState.hits, + actorBodyMeshName: actorBodyProbeState.meshName, + toolHits: toolProbeState.hits, + toolMeshName: toolProbeState.meshName, + }) + } + + visibilityEntries.forEach(({ object, visible }) => { + object.visible = visible + }) + liveMeshCullingEntries.forEach(({ frustumCulled, mesh }) => { + mesh.frustumCulled = frustumCulled + }) + cleanupActorBodyProbe() + cleanupToolProbe() + } + + recordNavigationPerfSample( + 'navigationRobot.renderWarmupMs', + performance.now() - warmupStart, + ) + if (!cancelled) { + window.clearTimeout(fallbackTimeoutId) + recordNavigationPerfMark('navigationRobot.materialWarmupReady') + setMaterialWarmupReady(true) + } + } catch { + } finally { + renderer.setRenderTarget?.(null) + renderTarget.dispose() + warmupRoots.forEach((root) => { + warmupRoot.remove(root) + }) + scene.remove(warmupRoot) + } + } + + void compileWarmup() + + return () => { + cancelled = true + window.clearTimeout(fallbackTimeoutId) + materialWarmupQueuedRef.current = false + // Cleanup in case the effect is interrupted before the render path removes the warmup roots. + scene.children + .filter((child) => child.name === '__pascalRobotWarmupRoot__') + .forEach((child) => { + scene.remove(child) + }) + } + }, [ + allAnimationActions, + clonedScene, + gl, + idleAction, + leftToolConeRenderables, + leftToolRenderable, + mixer, + runAction, + scene, + showToolAttachments, + walkAction, + ]) + + useEffect(() => { + const bindRenderProbe = (mesh: Mesh, frameIdRef: MutableRefObject) => { + const previousHandler = mesh.onBeforeRender + mesh.onBeforeRender = (...args) => { + const frameId = toolConeFrameIdRef.current + if (frameId > 0) { + frameIdRef.current = frameId + toolConeSubmittedAnyFrameIdRef.current = frameId + toolConeLastSubmittedAtMsRef.current = performance.now() + } + previousHandler(...args) + } + + return () => { + mesh.onBeforeRender = previousHandler + } + } + + const cleanupMain = leftToolConeRenderables.map((toolConeRenderable) => + bindRenderProbe(toolConeRenderable.mainMesh, toolConeSubmittedMainFrameIdRef), + ) + const cleanupInwardGlow = leftToolConeRenderables.map((toolConeRenderable) => + bindRenderProbe(toolConeRenderable.inwardGlowMesh, toolConeSubmittedInwardGlowFrameIdRef), + ) + const cleanupOutwardGlow = leftToolConeRenderables.map((toolConeRenderable) => + bindRenderProbe(toolConeRenderable.outwardGlowMesh, toolConeSubmittedOutwardGlowFrameIdRef), + ) + + return () => { + cleanupMain.forEach((cleanup) => { + cleanup() + }) + cleanupInwardGlow.forEach((cleanup) => { + cleanup() + }) + cleanupOutwardGlow.forEach((cleanup) => { + cleanup() + }) + } + }, [leftToolConeRenderables]) + + useEffect(() => { + return () => { + leftToolConeOverlayRenderable.mainGeometry.dispose() + leftToolConeOverlayRenderable.outlineMesh.geometry.dispose() + leftToolConeOverlayRenderable.inwardGlowMesh.geometry.dispose() + leftToolConeOverlayRenderable.outwardGlowMesh.geometry.dispose() + leftToolConeOccludedRenderable.mainGeometry.dispose() + leftToolConeOccludedRenderable.outlineMesh.geometry.dispose() + leftToolConeOccludedRenderable.inwardGlowMesh.geometry.dispose() + leftToolConeOccludedRenderable.outwardGlowMesh.geometry.dispose() + leftToolConeMaterial.dispose() + leftToolConeOutlineMaterial.dispose() + leftToolConeInwardGlowMaterial.dispose() + leftToolConeOutwardGlowMaterial.dispose() + leftToolConeOccludedMaterial.dispose() + leftToolConeOccludedOutlineMaterial.dispose() + leftToolConeOccludedInwardGlowMaterial.dispose() + leftToolConeOccludedOutwardGlowMaterial.dispose() + setToolConeIsolatedOverlay(null) + } + }, [ + leftToolConeOccludedInwardGlowMaterial, + leftToolConeOccludedMaterial, + leftToolConeOccludedOutlineMaterial, + leftToolConeOccludedOutwardGlowMaterial, + leftToolConeOccludedRenderable, + leftToolConeInwardGlowMaterial, + leftToolConeMaterial, + leftToolConeOutlineMaterial, + leftToolConeOutwardGlowMaterial, + leftToolConeOverlayRenderable, + ]) + + useEffect(() => { + mixer.timeScale = animationPaused ? 0 : 1 + }, [animationPaused, mixer]) + + useLayoutEffect(() => { + let meshCount = 0 + let skinnedMeshCount = 0 + let triangleCount = 0 + const debugBoneSamples: DebugBoneSample[] = [] + const shoulderBones: Partial> = {} + let leftHandBone: Object3D | null = null + const revealMaterialBindings: RevealMaterialBinding[] = [] + const revealMaterialEntries: RevealMaterialEntry[] = [] + const initialRevealProgress = initialSceneRevealProgressRef.current + + measureNavigationPerf('navigationRobot.sceneSetupMs', () => { + clonedScene.traverse((child) => { + child.visible = true + + const geometryHolder = child as { + geometry?: { + getAttribute?: (name: string) => { count: number } | undefined + getIndex?: () => { count: number } | null + } + } + + if (geometryHolder.geometry) { + meshCount += 1 + const positionAttribute = geometryHolder.geometry.getAttribute?.('position') + if (positionAttribute) { + const indexCount = geometryHolder.geometry.getIndex?.()?.count + triangleCount += indexCount ? indexCount / 3 : positionAttribute.count / 3 + } + } + + if ('isSkinnedMesh' in child && child.isSkinnedMesh) { + skinnedMeshCount += 1 + } + + if ( + NAVIGATION_ROBOT_DEBUG_ENABLED && + 'isBone' in child && + child.isBone && + debugBoneSamples.length < 16 + ) { + debugBoneSamples.push({ + bone: child as Object3D, + name: child.name || `bone-${debugBoneSamples.length}`, + previousPosition: child.position.clone(), + previousQuaternion: child.quaternion.clone(), + }) + } + + if ('isBone' in child && child.isBone) { + for (const shoulderBoneName of SHOULDER_BONE_NAMES) { + if (!shoulderBones[shoulderBoneName] && child.name === shoulderBoneName) { + shoulderBones[shoulderBoneName] = child as Object3D + } + } + if (!leftHandBone) { + for (const leftHandBoneName of LEFT_HAND_BONE_NAMES) { + if (child.name === leftHandBoneName) { + leftHandBone = child as Object3D + break + } + } + } + if (!leftHandBone) { + const normalizedName = child.name.replaceAll(/[^a-z]/gi, '').toLowerCase() + if (normalizedName.includes('lefthand')) { + leftHandBone = child as Object3D + } + } + } + + if ('isMesh' in child && child.isMesh) { + const mesh = child as Mesh + mesh.userData.pascalExcludeFromOutline = true + if (mesh.material) { + const originalMaterial = cloneObjectMaterials(mesh.material as Material | Material[]) + const revealMaterial = cloneObjectMaterials(originalMaterial) + normalizeRobotBaseMaterials(originalMaterial) + normalizeRobotRevealMaterials(revealMaterial) + const revealMaterialList = Array.isArray(revealMaterial) + ? revealMaterial + : [revealMaterial] + const bindings: RevealMaterialBinding[] = [] + for (const material of revealMaterialList) { + const revealMaterialBinding = createRevealMaterialBinding(material, 0, 1) + revealMaterialBinding.uniforms.revealProgress.value = initialRevealProgress + revealMaterialBindings.push(revealMaterialBinding) + bindings.push(revealMaterialBinding) + } + revealMaterialEntries.push({ + bindings, + mesh, + originalMaterial, + revealMaterial, + }) + mesh.material = initialRevealProgress < 1 - 1e-3 ? revealMaterial : originalMaterial + } + } + + if ('isMesh' in child && child.isMesh) { + child.castShadow = false + child.receiveShadow = false + child.frustumCulled = false + child.renderOrder = 36 + } + }) + }) + debugBoneSamplesRef.current = debugBoneSamples + shoulderBonesRef.current = shoulderBones + leftShoulderFollowBoneRef.current = findAttachmentTargetByTokens( + clonedScene, + LEFT_SHOULDER_BONE_NAMES, + ['leftshoulder'], + ) + leftUpperArmBoneRef.current = findAttachmentTargetByTokens( + clonedScene, + LEFT_UPPER_ARM_BONE_NAMES, + ['leftarm'], + ) + leftElbowBoneRef.current = findAttachmentTargetByTokens(clonedScene, LEFT_ELBOW_BONE_NAMES, [ + 'leftforearm', + ]) + leftHandBoneRef.current = leftHandBone + revealMaterialBindingsRef.current = revealMaterialBindings + revealMaterialEntriesRef.current = revealMaterialEntries + revealMaterialsActiveRef.current = initialRevealProgress < 1 - 1e-3 + + mergeNavigationPerfMeta({ + navigationRobotClipCount: animations.length, + navigationRobotMeshCount: meshCount, + navigationRobotSkinnedMeshCount: skinnedMeshCount, + navigationRobotTriangleCount: triangleCount, + }) + + if (NAVIGATION_ROBOT_DEBUG_ENABLED && typeof window !== 'undefined') { + const bounds = new Box3().setFromObject(clonedScene) + const size = bounds.getSize(new Vector3()) + writeRobotDebugState(debugId, debugStateRef, { + availableClipNames: animations.map((clip) => clip.name), + effectiveClipOverrides: resolvedClipOverrides, + materialWarmupReady, + rawBounds: { + max: [bounds.max.x, bounds.max.y, bounds.max.z], + min: [bounds.min.x, bounds.min.y, bounds.min.z], + size: [size.x, size.y, size.z], + }, + robotScale: robotTransform.scale, + sampleBoneNames: debugBoneSamples.map((sample) => sample.name), + }) + } + + return () => { + for (const entry of revealMaterialEntries) { + disposeObjectMaterials(entry.originalMaterial) + disposeObjectMaterials(entry.revealMaterial) + } + revealMaterialBindingsRef.current = [] + revealMaterialEntriesRef.current = [] + revealMaterialsActiveRef.current = false + } + }, [animations, clonedScene, debugId, debugStateRef, resolvedClipOverrides, robotTransform.scale]) + + useLayoutEffect(() => { + const revealMaterialBindings: RevealMaterialBinding[] = [] + const revealMaterialEntries: RevealMaterialEntry[] = [] + const initialRevealProgress = forcedClipPlayback?.revealFromStart ? 0 : 1 + + measureNavigationPerf('navigationRobot.toolSceneSetupMs', () => { + computeRobotRevealBounds( + leftToolRenderable, + toolRevealBoundsRef.current, + toolRevealBoundsScratchRef.current, + ) + const revealBounds = toolRevealBoundsRef.current + const revealMinY = revealBounds.isEmpty() ? 0 : revealBounds.min.y + const revealMaxY = revealBounds.isEmpty() ? 1 : revealBounds.max.y + + leftToolRenderable.traverse((child) => { + const mesh = child as Mesh + if (!mesh.isMesh || !mesh.material) { + return + } + if (isExcludedFromToolReveal(mesh)) { + return + } + const originalMaterial = cloneObjectMaterials(mesh.material as Material | Material[]) + const revealMaterial = cloneObjectMaterials(originalMaterial) + normalizeRobotBaseMaterials(originalMaterial) + normalizeRobotRevealMaterials(revealMaterial) + const revealMaterialList = Array.isArray(revealMaterial) ? revealMaterial : [revealMaterial] + const bindings: RevealMaterialBinding[] = [] + for (const material of revealMaterialList) { + const revealMaterialBinding = createRevealMaterialBinding( + material, + revealMinY, + revealMaxY, + ) + revealMaterialBinding.uniforms.revealProgress.value = initialRevealProgress + revealMaterialBindings.push(revealMaterialBinding) + bindings.push(revealMaterialBinding) + } + revealMaterialEntries.push({ + bindings, + mesh, + originalMaterial, + revealMaterial, + }) + mesh.material = initialRevealProgress < 1 - 1e-3 ? revealMaterial : originalMaterial + }) + }) + + toolRevealMaterialBindingsRef.current = revealMaterialBindings + toolRevealMaterialEntriesRef.current = revealMaterialEntries + toolRevealMaterialsActiveRef.current = initialRevealProgress < 1 - 1e-3 + + return () => { + for (const entry of revealMaterialEntries) { + disposeObjectMaterials(entry.originalMaterial) + disposeObjectMaterials(entry.revealMaterial) + } + toolRevealMaterialBindingsRef.current = [] + toolRevealMaterialEntriesRef.current = [] + toolRevealMaterialsActiveRef.current = false + } + }, [leftToolRenderable]) + + useEffect(() => { + rootMotionBoneRef.current = findRootMotionBone(clonedScene) + scene.updateMatrixWorld(true) + const referenceRootMotionBonePosition = + findRootMotionBone(scene)?.getWorldPosition(new Vector3()) ?? null + if (!referenceRootMotionBonePosition) { + rootMotionBaselineScenePositionRef.current = null + motionRef.current.rootMotionOffset = [0, 0, 0] + return + } + + rootMotionBaselineScenePositionRef.current = referenceRootMotionBonePosition + motionRef.current.rootMotionOffset = [0, 0, 0] + }, [clonedScene, motionRef, scene]) + + useEffect(() => { + if (!clonedScene) { + readySignalKeyRef.current = null + return + } + if (!materialWarmupReady) { + return + } + + const readySignalKey = `${clonedScene.uuid}:${forcedClipPlaybackKey ?? 'base'}` + if (readySignalKeyRef.current === readySignalKey) { + return + } + + readySignalKeyRef.current = readySignalKey + recordNavigationPerfMark('navigationRobot.onReady') + onReady?.() + }, [clonedScene, forcedClipPlaybackKey, materialWarmupReady, onReady]) + + useFrame(() => { + const leftHandBone = leftHandBoneRef.current + if (!(leftHandBone && checkoutLeftHandRestorePendingRef.current)) { + return + } + + leftHandBone.quaternion.copy(checkoutLeftHandBaseQuaternionRef.current) + leftHandBone.updateMatrixWorld(true) + checkoutLeftHandRestorePendingRef.current = false + }, -100) + + useEffect(() => { + const initialRevealProgress = forcedClipPlayback?.revealFromStart ? 0 : 1 + visualRevealProgressRef.current = initialRevealProgress + toolVisualRevealProgressRef.current = initialRevealProgress + revealBoundsRef.current.makeEmpty() + for (const binding of revealMaterialBindingsRef.current) { + binding.uniforms.revealProgress.value = initialRevealProgress + binding.webgpuUniforms.revealProgress.value = initialRevealProgress + } + const revealMaterialsShouldBeActive = resolveRevealMaterialsShouldBeActive( + initialRevealProgress < 1 - 1e-3, + ) + if (revealMaterialsActiveRef.current !== revealMaterialsShouldBeActive) { + for (const entry of revealMaterialEntriesRef.current) { + entry.mesh.material = revealMaterialsShouldBeActive + ? entry.revealMaterial + : entry.originalMaterial + } + revealMaterialsActiveRef.current = revealMaterialsShouldBeActive + recordNavigationPerfMark('navigationRobot.revealMaterialModeSwitch', { + materialDebugMode, + revealMaterialsActive: revealMaterialsShouldBeActive, + toolRevealMaterialsActive: toolRevealMaterialsActiveRef.current, + trigger: 'initial', + }) + } + for (const binding of toolRevealMaterialBindingsRef.current) { + binding.uniforms.revealProgress.value = initialRevealProgress + binding.webgpuUniforms.revealProgress.value = initialRevealProgress + } + const toolRevealMaterialsShouldBeActive = resolveRevealMaterialsShouldBeActive( + initialRevealProgress < 1 - 1e-3, + ) + if (toolRevealMaterialsActiveRef.current !== toolRevealMaterialsShouldBeActive) { + for (const entry of toolRevealMaterialEntriesRef.current) { + entry.mesh.material = toolRevealMaterialsShouldBeActive + ? entry.revealMaterial + : entry.originalMaterial + } + toolRevealMaterialsActiveRef.current = toolRevealMaterialsShouldBeActive + recordNavigationPerfMark('navigationRobot.toolRevealMaterialModeSwitch', { + materialDebugMode, + revealMaterialsActive: revealMaterialsActiveRef.current, + toolRevealMaterialsActive: toolRevealMaterialsShouldBeActive, + trigger: 'initial', + }) + } + + if (forcedClipPlaybackKey === null) { + return + } + + previousForcedClipActionRef.current = null + const releasedForcedAction = releasedForcedActionRef.current + if (releasedForcedAction) { + releasedForcedAction.clampWhenFinished = false + releasedForcedAction.paused = false + releasedForcedAction.setEffectiveTimeScale(1) + releasedForcedAction.stop() + } + releasedForcedActionRef.current = null + releasedForcedWeightRef.current = 0 + }, [ + debugTransitionPreviewClipName, + forcedClipPlayback?.revealFromStart, + forcedClipPlaybackKey, + materialDebugMode, + resolveRevealMaterialsShouldBeActive, + ]) + + useEffect(() => { + if (allAnimationActions.length === 0) { + activeClipNameRef.current = null + return + } + + measureNavigationPerf('navigationRobot.clipSetupMs', () => { + for (const action of allAnimationActions) { + action.stop() + action.enabled = false + action.clampWhenFinished = false + action.paused = false + action.setEffectiveWeight(0) + action.setEffectiveTimeScale(1) + } + + if (active) { + for (const action of locomotionActions) { + action.enabled = true + action.reset().setLoop(LoopRepeat, Infinity) + action.paused = false + action.setEffectiveTimeScale(action === idleAction ? IDLE_TIME_SCALE : 1) + action.setEffectiveWeight(action === idleAction ? 1 : 0) + action.play() + } + } + }) + + animationBlendStateRef.current = { + idleWeight: idleAction ? 1 : 0, + runTimeScale: 1, + runWeight: 0, + walkTimeScale: 1, + walkWeight: 0, + } + activeClipNameRef.current = active + ? (idleAction?.getClip().name ?? + walkAction?.getClip().name ?? + runAction?.getClip().name ?? + null) + : null + mergeNavigationPerfMeta({ + navigationRobotActiveClip: activeClipNameRef.current, + }) + + return () => { + for (const action of allAnimationActions) { + action.stop() + action.enabled = false + } + } + }, [active, allAnimationActions, idleAction, locomotionActions, runAction, walkAction]) + + useEffect(() => { + if (!(forcedClipPlayback && forcedClipAction)) { + return + } + + const loopMode = forcedClipPlayback.loop === 'once' ? LoopOnce : LoopRepeat + forcedClipAction.enabled = true + forcedClipAction.clampWhenFinished = Boolean( + forcedClipPlayback.loop === 'once' && forcedClipPlayback.holdLastFrame, + ) + forcedClipAction.reset() + forcedClipAction.setLoop(loopMode, forcedClipPlayback.loop === 'once' ? 1 : Infinity) + forcedClipAction.paused = false + forcedClipAction.setEffectiveWeight(1) + forcedClipAction.setEffectiveTimeScale(Math.max(0.01, forcedClipPlayback.timeScale ?? 1)) + forcedClipAction.play() + + return () => { + forcedClipAction.clampWhenFinished = false + } + }, [forcedClipAction, forcedClipPlaybackKey]) + + const updateToolConeOverlay = ( + camera: Camera, + toolInteractionTargetItemId: string | null, + toolInteractionPhase: NavigationRobotToolInteractionPhase | null, + toolInteractionClipTime: number | null, + hasCarryTarget: boolean, + carryContinuationVisible: boolean, + rawCarryTargetPresent: boolean, + captureDebugPayload: boolean, + ) => { + const toolConeGroupAttached = Boolean(leftToolRenderable.parent) + + for (const toolConeRenderable of leftToolConeRenderables) { + toolConeRenderable.mainMesh.visible = false + toolConeRenderable.inwardGlowMesh.visible = false + toolConeRenderable.outlineMesh.visible = false + toolConeRenderable.outwardGlowMesh.visible = false + } + + const logicExpectedVisible = Boolean( + toolConeGroupAttached && + toolInteractionTargetItemId && + shouldShowToolConeOverlay(toolInteractionClipTime, hasCarryTarget), + ) + + let toolConeDebugPayload: Record | null = captureDebugPayload + ? { + active: false, + carryContinuationVisible, + clipTime: toolInteractionClipTime, + geometryMissStreakFrames: toolConeGeometryMissStreakFramesRef.current, + groupAttached: toolConeGroupAttached, + interactionPhase: toolInteractionPhase, + logicExpectedVisible, + overlayGateCarryVisible: hasCarryTarget, + previousFrameLogicExpectedVisible: toolConePreviousFrameLogicExpectedRef.current, + previousFrameRenderSubmitted: toolConePreviousFrameSubmittedAnyRef.current, + previousFrameSubmittedInwardGlow: toolConePreviousFrameSubmittedInwardGlowRef.current, + previousFrameSubmittedMain: toolConePreviousFrameSubmittedMainRef.current, + previousFrameSubmittedOutwardGlow: toolConePreviousFrameSubmittedOutwardGlowRef.current, + previousFrameVisible: toolConePreviousFrameVisibleRef.current, + renderFailureStreakFrames: toolConeFailureStreakFramesRef.current, + renderLastSubmittedAtMs: toolConeLastSubmittedAtMsRef.current, + renderMissStreakFrames: toolConeRenderMissStreakFramesRef.current, + rawCarryTargetPresent, + targetItemId: toolInteractionTargetItemId, + visibleEndTime: TOOL_CONE_VISIBLE_END_TIME, + visibleStartTime: TOOL_CONE_VISIBLE_START_TIME, + visible: false, + } + : null + + if (!logicExpectedVisible) { + toolConeFrozenHullTargetItemIdRef.current = null + toolConeFrozenHullPointsRef.current = [] + setToolConeIsolatedOverlay(null) + return toolConeDebugPayload + } + + toolConeLogicExpectedFrameIdRef.current = toolConeFrameIdRef.current + + camera.updateMatrixWorld(true) + leftToolRenderable.updateWorldMatrix(true, true) + + const targetItemId = toolInteractionTargetItemId + if (!targetItemId) { + setToolConeIsolatedOverlay(null) + return toolConeDebugPayload + } + + const toolInteractionTarget = sceneRegistry.nodes.get(targetItemId) + if (!toolInteractionTarget) { + setToolConeIsolatedOverlay(null) + return toolConeDebugPayload + } + + applyLiveTransformToSceneObject(targetItemId, toolInteractionTarget) + toolInteractionTarget.updateWorldMatrix(true, true) + + const shouldFreezeTargetHull = Boolean(toolInteractionPhase && targetItemId) + const frozenHullPoints = toolConeFrozenHullPointsRef.current + if (!shouldFreezeTargetHull) { + toolConeFrozenHullTargetItemIdRef.current = null + frozenHullPoints.length = 0 + } else if (toolConeFrozenHullTargetItemIdRef.current !== targetItemId) { + if (frozenHullPoints.length > 0) { + for (const frozenHullPoint of frozenHullPoints) { + frozenHullPoint.targetLocalPoint.copy(frozenHullPoint.worldPoint) + toolInteractionTarget.worldToLocal(frozenHullPoint.targetLocalPoint) + } + } + toolConeFrozenHullTargetItemIdRef.current = targetItemId + } + + const projectedHullCandidates = toolConeProjectedHullCandidatesRef.current + projectedHullCandidates.length = 0 + + toolConeApexLocalPointRef.current.set( + TOOL_CONE_TOOL_CORNER_OFFSET.x, + TOOL_CONE_TOOL_CORNER_OFFSET.y, + TOOL_CONE_TOOL_CORNER_OFFSET.z, + ) + toolConeApexWorldPointRef.current.copy(toolConeApexLocalPointRef.current) + leftToolRenderable.localToWorld(toolConeApexWorldPointRef.current) + toolConeHullProjectedPointScratchRef.current + .copy(toolConeApexWorldPointRef.current) + .project(camera) + projectedHullCandidates.push({ + cameraSnapped: false, + cameraSurfaceDistanceDelta: null, + cameraSurfaceMeshName: null, + cameraSurfacePoint: null, + cameraSurfaceRelation: undefined, + isApex: true, + localPoint: toolConeApexLocalPointRef.current.clone(), + projectedPoint: new Vector2( + toolConeHullProjectedPointScratchRef.current.x, + toolConeHullProjectedPointScratchRef.current.y, + ), + sourceMeshName: null, + sourceMeshVisible: null, + supportIndex: null, + worldPoint: toolConeApexWorldPointRef.current.clone(), + }) + + let projectedHull: ProjectedHullCandidate[] = [] + if (shouldFreezeTargetHull && frozenHullPoints.length > 0) { + for ( + let frozenHullIndex = 0; + frozenHullIndex < frozenHullPoints.length; + frozenHullIndex += 1 + ) { + const frozenHullPoint = frozenHullPoints[frozenHullIndex] + if (!frozenHullPoint) { + continue + } + const supportWorldPoint = toolConeFrozenHullWorldPointScratchRef.current.copy( + frozenHullPoint.targetLocalPoint, + ) + toolInteractionTarget.localToWorld(supportWorldPoint) + frozenHullPoint.worldPoint.copy(supportWorldPoint) + const supportLocalPoint = + toolConeSupportLocalPointsRef.current[frozenHullIndex]?.copy(supportWorldPoint) + if (!supportLocalPoint) { + continue + } + + leftToolRenderable.worldToLocal(supportLocalPoint) + toolConeHullProjectedPointScratchRef.current.copy(supportWorldPoint).project(camera) + if ( + !Number.isFinite(toolConeHullProjectedPointScratchRef.current.x) || + !Number.isFinite(toolConeHullProjectedPointScratchRef.current.y) + ) { + continue + } + + projectedHullCandidates.push({ + cameraSnapped: frozenHullPoint.cameraSnapped, + cameraSurfaceDistanceDelta: frozenHullPoint.cameraSurfaceDistanceDelta, + cameraSurfaceMeshName: frozenHullPoint.cameraSurfaceMeshName, + cameraSurfacePoint: frozenHullPoint.cameraSurfacePoint, + cameraSurfaceRelation: frozenHullPoint.cameraSurfaceRelation ?? undefined, + isApex: false, + localPoint: supportLocalPoint.clone(), + projectedPoint: new Vector2( + toolConeHullProjectedPointScratchRef.current.x, + toolConeHullProjectedPointScratchRef.current.y, + ), + sourceMeshName: frozenHullPoint.sourceMeshName, + sourceMeshVisible: frozenHullPoint.sourceMeshVisible, + supportIndex: frozenHullPoint.supportIndex, + worldPoint: supportWorldPoint.clone(), + }) + } + projectedHull = reorderHullFromApex(computeProjectedHull(projectedHullCandidates)) + } else { + if ( + !collectTargetSupportPoints( + toolInteractionTarget, + toolConeSupportWorldPointsRef.current, + toolConeScratchPointRef.current, + toolConeSupportScoresRef.current, + NAVIGATION_ROBOT_VERBOSE_DEBUG_ENABLED + ? toolConeSupportDiagnosticsRef.current + : undefined, + ) + ) { + setToolConeIsolatedOverlay(null) + return { + ...toolConeDebugPayload, + active: true, + collectSuccess: false, + frozenTargetHull: frozenHullPoints.length > 0, + visible: false, + } + } + + for (let index = 0; index < toolConeSupportWorldPointsRef.current.length; index += 1) { + const supportWorldPoint = toolConeSupportWorldPointsRef.current[index] + const supportLocalTarget = toolConeSupportLocalPointsRef.current[index] + const supportDiagnostic = toolConeSupportDiagnosticsRef.current[index] + if (!supportWorldPoint || !supportLocalTarget) { + continue + } + + const supportLocalPoint = supportLocalTarget.copy(supportWorldPoint) + leftToolRenderable.worldToLocal(supportLocalPoint) + toolConeHullProjectedPointScratchRef.current.copy(supportWorldPoint).project(camera) + if ( + !Number.isFinite(toolConeHullProjectedPointScratchRef.current.x) || + !Number.isFinite(toolConeHullProjectedPointScratchRef.current.y) + ) { + continue + } + + projectedHullCandidates.push({ + cameraSnapped: supportDiagnostic?.cameraSnapped ?? false, + cameraSurfaceDistanceDelta: supportDiagnostic?.cameraSurfaceDistanceDelta ?? null, + cameraSurfaceMeshName: supportDiagnostic?.cameraSurfaceMeshName ?? null, + cameraSurfacePoint: supportDiagnostic?.cameraSurfacePoint ?? null, + cameraSurfaceRelation: supportDiagnostic?.cameraSurfaceRelation, + isApex: false, + localPoint: supportLocalPoint.clone(), + projectedPoint: new Vector2( + toolConeHullProjectedPointScratchRef.current.x, + toolConeHullProjectedPointScratchRef.current.y, + ), + sourceMeshName: supportDiagnostic?.sourceMeshName ?? null, + sourceMeshVisible: supportDiagnostic?.sourceMeshVisible ?? null, + supportIndex: index, + worldPoint: supportWorldPoint.clone(), + }) + } + + projectedHull = reorderHullFromApex(computeProjectedHull(projectedHullCandidates)) + if (shouldFreezeTargetHull && projectedHull.length >= 3) { + toolConeFrozenHullTargetItemIdRef.current = targetItemId + toolConeFrozenHullPointsRef.current = projectedHullCandidates + .filter((candidate) => !candidate.isApex) + .map((candidate) => { + const targetLocalPoint = candidate.worldPoint.clone() + toolInteractionTarget.worldToLocal(targetLocalPoint) + return { + cameraSnapped: candidate.cameraSnapped ?? false, + cameraSurfaceDistanceDelta: candidate.cameraSurfaceDistanceDelta ?? null, + cameraSurfaceMeshName: candidate.cameraSurfaceMeshName ?? null, + cameraSurfacePoint: candidate.cameraSurfacePoint ?? null, + cameraSurfaceRelation: candidate.cameraSurfaceRelation ?? null, + sourceMeshName: candidate.sourceMeshName, + sourceMeshVisible: candidate.sourceMeshVisible, + supportIndex: candidate.supportIndex, + targetLocalPoint, + worldPoint: candidate.worldPoint.clone(), + } + }) + } + } + + if (projectedHull.length < 3) { + if (frozenHullPoints.length > 0) { + toolConeFrozenHullTargetItemIdRef.current = null + frozenHullPoints.length = 0 + } + setToolConeIsolatedOverlay(null) + return toolConeDebugPayload + } + + setToolConeIsolatedOverlay({ + apexWorldPoint: vector3ToTuple(toolConeApexWorldPointRef.current), + color: toolConeColor, + hullPoints: projectedHull.map((hullPoint) => ({ + isApex: hullPoint.isApex, + worldPoint: vector3ToTuple(hullPoint.worldPoint), + })), + supportWorldPoints: projectedHullCandidates + .filter((candidate) => !candidate.isApex) + .map((candidate) => vector3ToTuple(candidate.worldPoint)), + visible: true, + }) + toolConeVisibleFrameIdRef.current = toolConeFrameIdRef.current + + if (!captureDebugPayload) { + return null + } + + const baseToolConeDebugPayload = { + active: true, + apexLocalPoint: vector3ToTuple(toolConeApexLocalPointRef.current), + apexWorldPoint: vector3ToTuple(toolConeApexWorldPointRef.current), + carryContinuationVisible, + clipTime: toolInteractionClipTime, + geometryMissStreakFrames: toolConeGeometryMissStreakFramesRef.current, + groupAttached: toolConeGroupAttached, + hullPointCount: projectedHull.length, + interactionPhase: toolInteractionPhase, + logicExpectedVisible, + overlayGateCarryVisible: hasCarryTarget, + previousFrameLogicExpectedVisible: toolConePreviousFrameLogicExpectedRef.current, + previousFrameRenderSubmitted: toolConePreviousFrameSubmittedAnyRef.current, + previousFrameSubmittedInwardGlow: toolConePreviousFrameSubmittedInwardGlowRef.current, + previousFrameSubmittedMain: toolConePreviousFrameSubmittedMainRef.current, + previousFrameSubmittedOutwardGlow: toolConePreviousFrameSubmittedOutwardGlowRef.current, + previousFrameVisible: toolConePreviousFrameVisibleRef.current, + rawCarryTargetPresent, + renderFailureStreakFrames: toolConeFailureStreakFramesRef.current, + renderLastSubmittedAtMs: toolConeLastSubmittedAtMsRef.current, + renderMissStreakFrames: toolConeRenderMissStreakFramesRef.current, + supportPointCount: projectedHullCandidates.length, + targetItemId: toolInteractionTargetItemId, + targetObjectName: toolInteractionTarget.name || toolInteractionTarget.type, + frozenTargetHull: shouldFreezeTargetHull, + visibleEndTime: TOOL_CONE_VISIBLE_END_TIME, + visibleStartTime: TOOL_CONE_VISIBLE_START_TIME, + visible: true, + } + + if (!NAVIGATION_ROBOT_VERBOSE_DEBUG_ENABLED) { + return baseToolConeDebugPayload + } + + const supportDebugPoints = projectedHullCandidates.map((candidate) => ({ + cameraSnapped: candidate.cameraSnapped ?? false, + cameraSurfaceDistanceDelta: candidate.cameraSurfaceDistanceDelta ?? null, + cameraSurfaceMeshName: candidate.cameraSurfaceMeshName ?? null, + cameraSurfacePoint: candidate.cameraSurfacePoint ?? null, + cameraSurfaceRelation: candidate.cameraSurfaceRelation ?? null, + projectedPoint: vector2ToTuple(candidate.projectedPoint), + sourceMeshName: candidate.sourceMeshName, + sourceMeshVisible: candidate.sourceMeshVisible, + supportIndex: candidate.supportIndex, + worldPoint: vector3ToTuple(candidate.worldPoint), + })) + + return { + ...baseToolConeDebugPayload, + hullPoints: projectedHull.map((hullPoint) => { + toolConeRenderedWorldPointScratchRef.current.copy(hullPoint.localPoint) + leftToolRenderable.localToWorld(toolConeRenderedWorldPointScratchRef.current) + return { + cameraSnapped: hullPoint.cameraSnapped ?? false, + cameraSurfaceDistanceDelta: hullPoint.cameraSurfaceDistanceDelta ?? null, + cameraSurfaceMeshName: hullPoint.cameraSurfaceMeshName ?? null, + cameraSurfacePoint: hullPoint.cameraSurfacePoint ?? null, + cameraSurfaceRelation: hullPoint.cameraSurfaceRelation ?? null, + isApex: hullPoint.isApex, + projectedPoint: vector2ToTuple(hullPoint.projectedPoint), + renderedWorldPoint: vector3ToTuple(toolConeRenderedWorldPointScratchRef.current), + sourceMeshName: hullPoint.sourceMeshName, + sourceMeshVisible: hullPoint.sourceMeshVisible, + supportIndex: hullPoint.supportIndex, + worldAlignmentError: hullPoint.isApex + ? 0 + : toolConeRenderedWorldPointScratchRef.current.distanceTo(hullPoint.worldPoint), + worldPoint: vector3ToTuple(hullPoint.worldPoint), + } + }), + supportPointCount: supportDebugPoints.length, + supportPoints: supportDebugPoints, + } + } + + useFrame(({ camera }, delta) => { + const frameStart = performance.now() + const frameDelta = animationPaused ? 0 : delta + const toolConeFrameId = toolConeFrameIdRef.current + 1 + toolConeFrameIdRef.current = toolConeFrameId + const previousToolConeFrameId = toolConeFrameId - 1 + const previousFrameLogicExpectedVisible = + toolConeLogicExpectedFrameIdRef.current === previousToolConeFrameId + const previousFrameVisible = toolConeVisibleFrameIdRef.current === previousToolConeFrameId + const previousFrameSubmittedAny = + toolConeSubmittedAnyFrameIdRef.current === previousToolConeFrameId + const previousFrameSubmittedMain = + toolConeSubmittedMainFrameIdRef.current === previousToolConeFrameId + const previousFrameSubmittedInwardGlow = + toolConeSubmittedInwardGlowFrameIdRef.current === previousToolConeFrameId + const previousFrameSubmittedOutwardGlow = + toolConeSubmittedOutwardGlowFrameIdRef.current === previousToolConeFrameId + toolConePreviousFrameLogicExpectedRef.current = previousFrameLogicExpectedVisible + toolConePreviousFrameVisibleRef.current = previousFrameVisible + toolConePreviousFrameSubmittedAnyRef.current = previousFrameSubmittedAny + toolConePreviousFrameSubmittedMainRef.current = previousFrameSubmittedMain + toolConePreviousFrameSubmittedInwardGlowRef.current = previousFrameSubmittedInwardGlow + toolConePreviousFrameSubmittedOutwardGlowRef.current = previousFrameSubmittedOutwardGlow + const shouldCaptureRobotDebugState = + shouldWriteRobotDebugState(debugId) && + frameStart - lastRobotDebugPublishAt >= ROBOT_DEBUG_PUBLISH_INTERVAL_MS + if (shouldCaptureRobotDebugState) { + lastRobotDebugPublishAt = frameStart + } + if (previousFrameLogicExpectedVisible) { + if (previousFrameVisible && previousFrameSubmittedAny) { + toolConeFailureStreakFramesRef.current = 0 + } else { + toolConeFailureStreakFramesRef.current += 1 + } + + if (previousFrameVisible) { + toolConeGeometryMissStreakFramesRef.current = 0 + } else { + toolConeGeometryMissStreakFramesRef.current += 1 + } + + if (previousFrameVisible && !previousFrameSubmittedAny) { + toolConeRenderMissStreakFramesRef.current += 1 + } else { + toolConeRenderMissStreakFramesRef.current = 0 + } + } else { + toolConeFailureStreakFramesRef.current = 0 + toolConeGeometryMissStreakFramesRef.current = 0 + toolConeRenderMissStreakFramesRef.current = 0 + } + const leftHandBone = leftHandBoneRef.current + for (const toolConeRenderable of leftToolConeRenderables) { + toolConeRenderable.mainMesh.visible = false + toolConeRenderable.inwardGlowMesh.visible = false + toolConeRenderable.outlineMesh.visible = false + toolConeRenderable.outwardGlowMesh.visible = false + } + + const toolInteractionTargetItemId = + toolInteractionTargetItemIdRef?.current ?? + toolCarryItemIdRef?.current ?? + toolCarryItemId ?? + null + const toolCarryTargetItemId = toolCarryItemIdRef?.current ?? toolCarryItemId ?? null + const toolInteractionPhase = toolInteractionPhaseRef?.current ?? null + const toolInteractionClipTime = + motionRef.current.forcedClip?.clipName === CHECKOUT_CLIP_NAME + ? (motionRef.current.forcedClip.seekTime ?? 0) + : null + const toolConeCarryContinuationVisible = shouldContinueToolConeCarry( + toolInteractionPhase, + toolInteractionClipTime, + Boolean(toolCarryTargetItemId), + ) + const applyToolConeCarryFollow = () => { + let followBlend = getToolConeFollowBlend( + toolInteractionClipTime, + Boolean(toolCarryTargetItemId), + ) + const followTargetItemId = toolInteractionTargetItemId ?? toolCarryTargetItemId + const hasActiveFollowTarget = Boolean(followTargetItemId && followBlend > 1e-4) + let followTargetCenter = toolConeCarryTargetCenterRef.current + const leftShoulderBone = leftShoulderFollowBoneRef.current + const leftUpperArmBone = leftUpperArmBoneRef.current + const leftElbowBone = leftElbowBoneRef.current + + const applyStoredReleasePose = () => { + const releaseBlend = MathUtils.clamp(toolConeFollowReleaseBlendRef.current, 0, 1) + if (!toolConeFollowReleasePoseReadyRef.current || releaseBlend <= 1e-4) { + toolConeFollowReleaseBlendRef.current = 0 + toolConeFollowReleasePoseReadyRef.current = false + return + } + + if (leftShoulderBone) { + leftShoulderBone.quaternion.slerp( + toolConeFollowReleaseShoulderQuaternionRef.current, + releaseBlend, + ) + leftShoulderBone.updateMatrixWorld(true) + } + if (leftUpperArmBone) { + leftUpperArmBone.quaternion.slerp( + toolConeFollowReleaseUpperArmQuaternionRef.current, + releaseBlend, + ) + leftUpperArmBone.updateMatrixWorld(true) + } + if (leftElbowBone) { + leftElbowBone.quaternion.slerp( + toolConeFollowReleaseElbowQuaternionRef.current, + releaseBlend, + ) + leftElbowBone.updateMatrixWorld(true) + } + if (leftHandBone) { + leftHandBone.quaternion.slerp( + toolConeFollowReleaseLeftHandQuaternionRef.current, + releaseBlend, + ) + leftHandBone.updateMatrixWorld(true) + } + + const nextReleaseBlend = MathUtils.damp( + releaseBlend, + 0, + TOOL_CONE_FOLLOW_RELEASE_RESPONSE, + frameDelta, + ) + toolConeFollowReleaseBlendRef.current = nextReleaseBlend + if (nextReleaseBlend <= 1e-4) { + toolConeFollowReleaseBlendRef.current = 0 + toolConeFollowReleasePoseReadyRef.current = false + } + } + + const captureReleasePose = () => { + const clampedFollowBlend = MathUtils.clamp(followBlend, 0, 1) + if (clampedFollowBlend <= 1e-4) { + return + } + + toolConeFollowReleaseBlendRef.current = clampedFollowBlend + toolConeFollowReleasePoseReadyRef.current = true + if (leftShoulderBone) { + toolConeFollowReleaseShoulderQuaternionRef.current.copy(leftShoulderBone.quaternion) + } + if (leftUpperArmBone) { + toolConeFollowReleaseUpperArmQuaternionRef.current.copy(leftUpperArmBone.quaternion) + } + if (leftElbowBone) { + toolConeFollowReleaseElbowQuaternionRef.current.copy(leftElbowBone.quaternion) + } + if (leftHandBone) { + toolConeFollowReleaseLeftHandQuaternionRef.current.copy(leftHandBone.quaternion) + } + } + + if (hasActiveFollowTarget && followTargetItemId) { + const targetObject = sceneRegistry.nodes.get(followTargetItemId) ?? null + if (!targetObject) { + followBlend = 0 + } else { + applyLiveTransformToSceneObject(followTargetItemId, targetObject) + targetObject.updateWorldMatrix(true, true) + if ( + getObjectWorldCenter( + targetObject, + toolConeCarryTargetBoundsRef.current, + toolConeCarryTargetCenterRef.current, + ) + ) { + followTargetCenter = toolConeCarryTargetCenterRef.current + } else { + followBlend = 0 + } + } + } else { + applyStoredReleasePose() + return + } + + if (followBlend <= 1e-4) { + applyStoredReleasePose() + return + } + + toolConeFollowShoulderTargetRef.current.copy(followTargetCenter).y += + TOOL_CONE_FOLLOW_SHOULDER_TARGET_HEIGHT_OFFSET + toolConeFollowForearmTargetRef.current.copy(followTargetCenter).y += + TOOL_CONE_FOLLOW_FOREARM_TARGET_HEIGHT_OFFSET + + aimBoneYAxisTowardWorldTarget( + leftShoulderBone, + toolConeFollowShoulderTargetRef.current, + followBlend * 0.35, + shoulderAimBoneWorldPositionRef.current, + shoulderAimTargetDirectionWorldRef.current, + shoulderAimParentWorldQuaternionRef.current, + shoulderAimTargetDirectionParentRef.current, + shoulderAimTargetLocalQuaternionRef.current, + ) + aimBoneYAxisTowardWorldTarget( + leftUpperArmBone, + toolConeFollowShoulderTargetRef.current, + followBlend * 0.82, + upperArmAimBoneWorldPositionRef.current, + upperArmAimTargetDirectionWorldRef.current, + upperArmAimParentWorldQuaternionRef.current, + upperArmAimTargetDirectionParentRef.current, + upperArmAimTargetLocalQuaternionRef.current, + ) + aimBoneYAxisTowardWorldTarget( + leftElbowBone, + toolConeFollowForearmTargetRef.current, + followBlend, + forearmAimBoneWorldPositionRef.current, + forearmAimTargetDirectionWorldRef.current, + forearmAimParentWorldQuaternionRef.current, + forearmAimTargetDirectionParentRef.current, + forearmAimTargetLocalQuaternionRef.current, + ) + captureReleasePose() + } + let toolConeDebugPayload: Record | null = { + active: false, + clipTime: toolInteractionClipTime, + targetItemId: toolInteractionTargetItemId, + visible: false, + } + + if (allAnimationActions.length === 0) { + visualOffsetGroupRef.current?.position.set(0, 0, 0) + motionRef.current.rootMotionOffset = [0, 0, 0] + recordNavigationRobotFramePerf(frameStart) + return + } + + if ( + !active && + !forcedClipPlayback && + !releasedForcedActionRef.current && + !revealMaterialsActiveRef.current && + !toolRevealMaterialsActiveRef.current + ) { + visualOffsetGroupRef.current?.position.set(0, 0, 0) + motionRef.current.rootMotionOffset = [0, 0, 0] + recordNavigationRobotFramePerf(frameStart) + return + } + + const forcedClipState = motionRef.current.forcedClip + const visibilityRevealProgress = motionRef.current.visibilityRevealProgress ?? null + const forcedClipStateMatchesPlayback = + forcedClipState?.clipName === forcedClipPlayback?.clipName + const hasActiveForcedClip = + Boolean(forcedClipPlayback) && + Boolean(forcedClipAction) && + (forcedClipStateMatchesPlayback || forcedClipPlayback?.clipName === JUMPING_DOWN_CLIP_NAME) + const activeForcedClipPlayback = hasActiveForcedClip ? forcedClipPlayback : null + const forcedClipRevealEnabled = Boolean(activeForcedClipPlayback?.revealFromStart) + let revealProgress = 1 + const targetRevealProgress = + visibilityRevealProgress !== null + ? MathUtils.clamp(visibilityRevealProgress, 0, 1) + : forcedClipState && forcedClipRevealEnabled + ? MathUtils.clamp(forcedClipState.revealProgress, 0, 1) + : null + if (targetRevealProgress !== null || revealMaterialsActiveRef.current) { + const resolvedRevealProgress = targetRevealProgress ?? 1 + revealProgress = resolvedRevealProgress + if (visibilityRevealProgress !== null) { + visualRevealProgressRef.current = resolvedRevealProgress + } else if (forcedClipState && forcedClipRevealEnabled) { + const previousVisualRevealProgress = visualRevealProgressRef.current + revealProgress = + resolvedRevealProgress < previousVisualRevealProgress + ? resolvedRevealProgress + : Math.min( + resolvedRevealProgress, + previousVisualRevealProgress + + frameDelta / Math.max(FORCED_CLIP_VISUAL_REVEAL_DURATION_SECONDS, 1e-3), + ) + visualRevealProgressRef.current = revealProgress + } else { + visualRevealProgressRef.current = 1 + } + } else { + visualRevealProgressRef.current = 1 + } + const forcedClipSeekTime = forcedClipState?.seekTime ?? null + const forcedClipPaused = forcedClipState?.paused ?? false + const effectiveDebugTransitionPreview = + motionRef.current.debugTransitionPreview ?? debugTransitionPreview ?? null + const visualOffsetGroup = visualOffsetGroupRef.current + const revealBoundsGroup = rootGroupRef.current + if (forcedClipState || visibilityRevealProgress !== null || revealMaterialsActiveRef.current) { + if ( + revealBoundsGroup && + (forcedClipState || visibilityRevealProgress !== null) && + (visibilityRevealProgress !== null || + forcedClipPaused || + forcedClipSeekTime !== null || + revealBoundsRef.current.isEmpty()) + ) { + computeRobotRevealBounds( + revealBoundsGroup, + revealBoundsRef.current, + revealBoundsScratchRef.current, + ) + } + let revealMinY = 0 + let revealMaxY = 1 + if (!revealBoundsRef.current.isEmpty()) { + revealMinY = revealBoundsRef.current.min.y + revealMaxY = revealBoundsRef.current.max.y + } + const revealFeather = Math.max((revealMaxY - revealMinY) * 0.04, 0.02) + for (const binding of revealMaterialBindingsRef.current) { + binding.uniforms.revealFeather.value = revealFeather + binding.uniforms.revealMinY.value = revealMinY + binding.uniforms.revealMaxY.value = revealMaxY + binding.uniforms.revealProgress.value = revealProgress + binding.webgpuUniforms.revealFeather.value = revealFeather + binding.webgpuUniforms.revealMinY.value = revealMinY + binding.webgpuUniforms.revealMaxY.value = revealMaxY + binding.webgpuUniforms.revealProgress.value = revealProgress + } + const revealMaterialsShouldBeActive = resolveRevealMaterialsShouldBeActive( + revealProgress < 1 - 1e-3, + ) + if (revealMaterialsActiveRef.current !== revealMaterialsShouldBeActive) { + for (const entry of revealMaterialEntriesRef.current) { + entry.mesh.material = revealMaterialsShouldBeActive + ? entry.revealMaterial + : entry.originalMaterial + } + revealMaterialsActiveRef.current = revealMaterialsShouldBeActive + recordNavigationPerfMark('navigationRobot.revealMaterialModeSwitch', { + materialDebugMode, + revealMaterialsActive: revealMaterialsShouldBeActive, + toolRevealMaterialsActive: toolRevealMaterialsActiveRef.current, + trigger: + visibilityRevealProgress !== null + ? 'visibility-reveal' + : forcedClipState + ? 'forced-clip' + : 'frame', + }) + } + } + let toolRevealProgress = 1 + if (visibilityRevealProgress !== null) { + toolRevealProgress = revealProgress + toolVisualRevealProgressRef.current = toolRevealProgress + } else if (forcedClipState && forcedClipRevealEnabled) { + if (revealProgress < 1 - 1e-3) { + toolRevealProgress = 0 + toolVisualRevealProgressRef.current = 0 + } else { + toolRevealProgress = Math.min( + 1, + toolVisualRevealProgressRef.current + + frameDelta / Math.max(TOOL_ATTACHMENT_REVEAL_DURATION_SECONDS, 1e-3), + ) + toolVisualRevealProgressRef.current = toolRevealProgress + } + } else { + toolRevealProgress = 1 + toolVisualRevealProgressRef.current = 1 + } + if (toolRevealMaterialBindingsRef.current.length > 0) { + for (const binding of toolRevealMaterialBindingsRef.current) { + binding.uniforms.revealProgress.value = toolRevealProgress + binding.webgpuUniforms.revealProgress.value = toolRevealProgress + } + const toolRevealMaterialsShouldBeActive = resolveRevealMaterialsShouldBeActive( + toolRevealProgress < 1 - 1e-3, + ) + if (toolRevealMaterialsActiveRef.current !== toolRevealMaterialsShouldBeActive) { + for (const entry of toolRevealMaterialEntriesRef.current) { + entry.mesh.material = toolRevealMaterialsShouldBeActive + ? entry.revealMaterial + : entry.originalMaterial + } + toolRevealMaterialsActiveRef.current = toolRevealMaterialsShouldBeActive + recordNavigationPerfMark('navigationRobot.toolRevealMaterialModeSwitch', { + materialDebugMode, + revealMaterialsActive: revealMaterialsActiveRef.current, + toolRevealMaterialsActive: toolRevealMaterialsShouldBeActive, + trigger: + visibilityRevealProgress !== null + ? 'visibility-reveal' + : forcedClipState + ? 'forced-clip' + : 'frame', + }) + } + } + + if (effectiveDebugTransitionPreview) { + const previousForcedClipAction = previousForcedClipActionRef.current + if (previousForcedClipAction) { + previousForcedClipAction.clampWhenFinished = false + previousForcedClipAction.paused = false + previousForcedClipAction.setEffectiveTimeScale(1) + previousForcedClipAction.stop() + previousForcedClipActionRef.current = null + } + + const releasedForcedAction = releasedForcedActionRef.current + if (releasedForcedAction) { + releasedForcedAction.clampWhenFinished = false + releasedForcedAction.paused = false + releasedForcedAction.setEffectiveTimeScale(1) + releasedForcedAction.stop() + releasedForcedActionRef.current = null + } + + releasedForcedWeightRef.current = 0 + } else if (hasActiveForcedClip && forcedClipAction) { + previousForcedClipActionRef.current = forcedClipAction + releasedForcedActionRef.current = null + releasedForcedWeightRef.current = 0 + } else if (!releasedForcedActionRef.current && previousForcedClipActionRef.current) { + const previousForcedClipAction = previousForcedClipActionRef.current + const previousForcedClipName = previousForcedClipAction.getClip().name + const previousForcedRuntimePlanarRootMotionClip = + runtimePlanarRootMotionClips.byName.get(previousForcedClipName) ?? null + if (previousForcedClipName === CHECKOUT_CLIP_NAME) { + animationBlendStateRef.current = { + idleWeight: idleAction ? 1 : 0, + runTimeScale: 1, + runWeight: 0, + walkTimeScale: 1, + walkWeight: 0, + } + } + releasedForcedActionRef.current = previousForcedClipAction + const releasedForcedClipDuration = releasedForcedActionRef.current.getClip().duration + const releasedForcedClipHoldTime = getForcedClipHoldTime( + previousForcedClipName, + releasedForcedClipDuration, + previousForcedRuntimePlanarRootMotionClip, + ) + const releasedForcedClipTime = MathUtils.clamp( + releasedForcedActionRef.current.time, + 0, + releasedForcedClipDuration, + ) + releasedForcedWeightRef.current = 1 + releasedForcedActionRef.current.enabled = true + releasedForcedActionRef.current.clampWhenFinished = true + releasedForcedActionRef.current.paused = true + releasedForcedActionRef.current.play() + releasedForcedActionRef.current.setEffectiveWeight(1) + releasedForcedActionRef.current.setEffectiveTimeScale(0) + // Preserve the exact cut frame when a forced clip ends early, otherwise the release blend + // snaps to the authored last frame before fading into idle. + releasedForcedActionRef.current.time = releasedForcedClipTime + previousForcedClipActionRef.current = null + } + + if (!effectiveDebugTransitionPreview && activeForcedClipPlayback && forcedClipAction) { + const stabilizeRootMotion = Boolean(activeForcedClipPlayback.stabilizeRootMotion) + const forcedClipTimeScale = Math.max( + 0.01, + forcedClipState?.timeScale ?? activeForcedClipPlayback.timeScale ?? 1, + ) + const forcedClipDuration = forcedClipAction.getClip().duration + const forcedClipName = forcedClipAction.getClip().name + const runtimePlanarRootMotionClip = + runtimePlanarRootMotionClips.byName.get(activeForcedClipPlayback.clipName) ?? null + const forcedClipHoldTime = getForcedClipHoldTime( + forcedClipName, + forcedClipDuration, + runtimePlanarRootMotionClip, + ) + const effectiveForcedClipSeekTime = + forcedClipSeekTime ?? (revealProgress < 1 - 1e-3 ? 0 : null) + const sampledForcedClipTime = MathUtils.clamp( + effectiveForcedClipSeekTime ?? forcedClipAction.time, + 0, + forcedClipHoldTime, + ) + const forcedClipAnimationProgress = + forcedClipDuration > Number.EPSILON + ? MathUtils.clamp(sampledForcedClipTime / forcedClipDuration, 0, 1) + : 0 + let rootMotionOffsetX = 0 + let rootMotionOffsetY = 0 + let rootMotionOffsetZ = 0 + if (runtimePlanarRootMotionClip && rootMotionBoneRef.current) { + const rootMotionParent = rootMotionBoneRef.current.parent ?? rootGroupRef.current + if (rootMotionParent) { + const runtimePlanarLocalOffset = runtimePlanarRootMotionClip.samplePlanarLocalOffset( + sampledForcedClipTime, + runtimePlanarRootMotionLocalOffsetRef.current, + ) + rootMotionParent.localToWorld(runtimePlanarRootMotionWorldOriginRef.current.set(0, 0, 0)) + rootMotionParent.localToWorld( + runtimePlanarRootMotionWorldTargetRef.current.copy(runtimePlanarLocalOffset), + ) + runtimePlanarRootMotionWorldOffsetRef.current + .copy(runtimePlanarRootMotionWorldTargetRef.current) + .sub(runtimePlanarRootMotionWorldOriginRef.current) + rootMotionOffsetX = runtimePlanarRootMotionWorldOffsetRef.current.x + rootMotionOffsetZ = runtimePlanarRootMotionWorldOffsetRef.current.z + + if (visualOffsetGroup?.parent) { + runtimePlanarRootMotionVisualOriginRef.current.copy( + runtimePlanarRootMotionWorldOriginRef.current, + ) + runtimePlanarRootMotionVisualTargetRef.current.copy( + runtimePlanarRootMotionWorldTargetRef.current, + ) + visualOffsetGroup.parent.worldToLocal(runtimePlanarRootMotionVisualOriginRef.current) + visualOffsetGroup.parent.worldToLocal(runtimePlanarRootMotionVisualTargetRef.current) + runtimePlanarRootMotionVisualOffsetRef.current + .copy(runtimePlanarRootMotionVisualTargetRef.current) + .sub(runtimePlanarRootMotionVisualOriginRef.current) + } else { + runtimePlanarRootMotionVisualOffsetRef.current.copy( + runtimePlanarRootMotionWorldOffsetRef.current, + ) + } + } else { + runtimePlanarRootMotionVisualOffsetRef.current.set(0, 0, 0) + } + } else if ( + rootGroupRef.current && + rootMotionBoneRef.current && + rootMotionBaselineScenePositionRef.current + ) { + getCurrentRootMotionOffset( + rootGroupRef.current, + rootMotionBoneRef.current, + rootMotionBaselineScenePositionRef.current, + rootMotionBaselineWorldRef.current, + rootMotionCurrentWorldRef.current, + rootMotionOffsetRef.current, + ) + rootMotionOffsetX = rootMotionOffsetRef.current.x + rootMotionOffsetY = rootMotionOffsetRef.current.y + rootMotionOffsetZ = rootMotionOffsetRef.current.z + } + if (visualOffsetGroup) { + let visualOffsetX = 0 + let visualOffsetY = 0 + let visualOffsetZ = 0 + if (runtimePlanarRootMotionClip) { + visualOffsetX += runtimePlanarRootMotionVisualOffsetRef.current.x + visualOffsetY += runtimePlanarRootMotionVisualOffsetRef.current.y + visualOffsetZ += runtimePlanarRootMotionVisualOffsetRef.current.z + } + if (forcedClipVisualOffset) { + const visualOffsetWeight = + forcedClipPaused || effectiveForcedClipSeekTime !== null + ? 1 + : 1 - MathUtils.smoothstep(forcedClipAnimationProgress, 0, 0.22) + visualOffsetX += forcedClipVisualOffset[0] * visualOffsetWeight + visualOffsetY += forcedClipVisualOffset[1] * visualOffsetWeight + visualOffsetZ += forcedClipVisualOffset[2] * visualOffsetWeight + } + if (stabilizeRootMotion && !runtimePlanarRootMotionClip) { + visualOffsetX -= rootMotionOffsetX + visualOffsetZ -= rootMotionOffsetZ + } + visualOffsetGroup.position.set(visualOffsetX, visualOffsetY, visualOffsetZ) + if (effectiveForcedClipSeekTime !== null) { + forcedClipAction.time = MathUtils.clamp( + effectiveForcedClipSeekTime, + 0, + forcedClipHoldTime, + ) + } + } else { + if (effectiveForcedClipSeekTime !== null) { + forcedClipAction.time = MathUtils.clamp( + effectiveForcedClipSeekTime, + 0, + forcedClipHoldTime, + ) + } + } + const shouldHoldLastForcedFrame = + activeForcedClipPlayback.loop === 'once' && + Boolean(activeForcedClipPlayback.holdLastFrame) && + effectiveForcedClipSeekTime === null && + forcedClipAction.time >= forcedClipHoldTime - 1e-3 + if (shouldHoldLastForcedFrame) { + forcedClipAction.time = forcedClipHoldTime + } + const forcedClipShouldPause = + forcedClipPaused || effectiveForcedClipSeekTime !== null || shouldHoldLastForcedFrame + const forcedClipFinished = + activeForcedClipPlayback.loop === 'once' && + forcedClipAction.time >= forcedClipHoldTime - 1e-3 + const keepLocomotionWarmDuringForcedClip = forcedClipName === CHECKOUT_CLIP_NAME + + for (const action of runtimeActions) { + const isForcedAction = action === forcedClipAction + if (!isForcedAction) { + if (!keepLocomotionWarmDuringForcedClip) { + setActionInactive(action) + continue + } + const standbyTimeScale = + action === idleAction + ? IDLE_TIME_SCALE + : action === walkAction + ? Math.max(0.01, motionRef.current.locomotion.walkTimeScale) + : action === runAction + ? Math.max(0.01, motionRef.current.locomotion.runTimeScale) + : 1 + setActionActive(action, 0, standbyTimeScale) + continue + } + + action.enabled = true + action.paused = forcedClipShouldPause + if (!action.isRunning()) { + action.play() + } + action.setEffectiveWeight(1) + action.setEffectiveTimeScale(forcedClipFinished ? 0 : forcedClipTimeScale) + } + + const landingShoulderBlendWeight = + forcedClipName === JUMPING_DOWN_CLIP_NAME + ? getLandingShoulderBlendWeight(runtimePlanarRootMotionClip, sampledForcedClipTime) + : 0 + motionRef.current.rootMotionOffset = stabilizeRootMotion + ? [0, 0, 0] + : [rootMotionOffsetX, rootMotionOffsetY, rootMotionOffsetZ] + motionRef.current.debugActiveClipName = forcedClipName + motionRef.current.debugForcedClipRevealProgress = revealProgress + motionRef.current.debugForcedClipTime = sampledForcedClipTime + motionRef.current.debugLandingShoulderBlendWeight = landingShoulderBlendWeight + motionRef.current.debugReleasedForcedClipName = null + motionRef.current.debugReleasedForcedClipTime = null + motionRef.current.debugReleasedForcedWeight = 0 + + if (forcedClipName !== activeClipNameRef.current) { + activeClipNameRef.current = forcedClipName + mergeNavigationPerfMeta({ + navigationRobotActiveClip: forcedClipName, + }) + } + + applyShoulderPoseTargets( + shoulderBonesRef.current, + idleShoulderTargets, + landingShoulderBlendWeight, + ) + if (forcedClipName === CHECKOUT_CLIP_NAME) { + const checkoutBlend = MathUtils.smootherstep( + 1 - Math.abs(forcedClipAnimationProgress * 2 - 1), + 0, + 1, + ) + if (checkoutBlend > 1e-3 && leftHandBoneRef.current) { + checkoutLeftHandBaseQuaternionRef.current.copy(leftHandBoneRef.current.quaternion) + checkoutLeftHandScratchRef.current + .identity() + .slerp(checkoutLeftHandRotationRef.current, checkoutBlend) + leftHandBoneRef.current.quaternion.premultiply(checkoutLeftHandScratchRef.current) + leftHandBoneRef.current.updateMatrixWorld(true) + checkoutLeftHandRestorePendingRef.current = true + } + } + measureNavigationPerf('navigationRobot.toolConeCarryFollowMs', () => { + applyToolConeCarryFollow() + }) + toolConeDebugPayload = measureNavigationPerf('navigationRobot.toolConeOverlayMs', () => + updateToolConeOverlay( + camera, + toolInteractionTargetItemId, + toolInteractionPhase, + toolInteractionClipTime, + toolConeCarryContinuationVisible, + toolConeCarryContinuationVisible, + Boolean(toolCarryTargetItemId), + shouldCaptureRobotDebugState, + ), + ) + + if (shouldCaptureRobotDebugState && typeof window !== 'undefined') { + writeRobotDebugState(debugId, debugStateRef, { + activeClipName: activeClipNameRef.current, + forcedClipName, + forcedClipPlaying: true, + forcedClipRevealProgress: revealProgress, + forcedClipTime: sampledForcedClipTime, + landingShoulderBlendWeight, + locomotion: { + moveBlend: motionRef.current.locomotion.moveBlend, + runBlend: motionRef.current.locomotion.runBlend, + runTimeScale: motionRef.current.locomotion.runTimeScale, + walkTimeScale: motionRef.current.locomotion.walkTimeScale, + }, + materialDebugMode, + revealMaterialsActive: revealMaterialsActiveRef.current, + toolRevealMaterialsActive: toolRevealMaterialsActiveRef.current, + materialWarmupReady, + moving: motionRef.current.moving, + releasedForcedClipName: null, + releasedForcedClipTime: null, + releasedForcedWeight: 0, + rootMotionOffset: motionRef.current.rootMotionOffset, + toolCone: toolConeDebugPayload, + }) + } + + recordNavigationRobotFramePerf(frameStart) + return + } + + if (effectiveDebugTransitionPreview) { + motionRef.current.rootMotionOffset = [0, 0, 0] + const locomotion = motionRef.current.locomotion + const animationBlendState = animationBlendStateRef.current + const previewReleasedAction = + actions[effectiveDebugTransitionPreview.releasedClipName] ?? null + const previewReleasedClipTime = previewReleasedAction + ? MathUtils.clamp( + effectiveDebugTransitionPreview.releasedClipTime, + 0, + previewReleasedAction.getClip().duration, + ) + : 0 + const previewReleasedWeight = MathUtils.clamp( + effectiveDebugTransitionPreview.releasedClipWeight, + 0, + 1, + ) + const previewReleasedRuntimePlanarRootMotionClip = previewReleasedAction + ? (runtimePlanarRootMotionClips.byName.get(previewReleasedAction.getClip().name) ?? null) + : null + + if (previewReleasedAction) { + previewReleasedAction.enabled = true + previewReleasedAction.clampWhenFinished = true + previewReleasedAction.paused = true + if (!previewReleasedAction.isRunning()) { + previewReleasedAction.play() + } + previewReleasedAction.setEffectiveWeight(1) + previewReleasedAction.setEffectiveTimeScale(0) + previewReleasedAction.time = previewReleasedClipTime + } + + if (visualOffsetGroup) { + if ( + previewReleasedAction && + rootGroupRef.current && + rootMotionBoneRef.current && + rootMotionBaselineScenePositionRef.current + ) { + getCurrentRootMotionOffset( + rootGroupRef.current, + rootMotionBoneRef.current, + rootMotionBaselineScenePositionRef.current, + rootMotionBaselineWorldRef.current, + rootMotionCurrentWorldRef.current, + rootMotionOffsetRef.current, + ) + const previewReleaseOffsetWeight = MathUtils.clamp(previewReleasedWeight, 0, 1) + visualOffsetGroup.position.set( + -rootMotionOffsetRef.current.x * previewReleaseOffsetWeight, + 0, + -rootMotionOffsetRef.current.z * previewReleaseOffsetWeight, + ) + } else { + visualOffsetGroup.position.set(0, 0, 0) + } + } + + const moveBlendTarget = motionRef.current.moving + ? MathUtils.clamp(locomotion.moveBlend, 0, 1) + : 0 + const runBlendTarget = Math.min(moveBlendTarget, MathUtils.clamp(locomotion.runBlend, 0, 1)) + const walkBlendTarget = Math.max(0, moveBlendTarget - runBlendTarget) + const idleBlendTarget = Math.max(0, 1 - moveBlendTarget) + animationBlendState.idleWeight = idleBlendTarget + animationBlendState.walkWeight = walkBlendTarget + animationBlendState.runWeight = runBlendTarget + animationBlendState.walkTimeScale = Math.max(0.01, locomotion.walkTimeScale) + animationBlendState.runTimeScale = Math.max(0.01, locomotion.runTimeScale) + + if ( + walkAction && + runAction && + walkAction !== runAction && + (animationBlendState.walkWeight > 1e-3 || animationBlendState.runWeight > 1e-3) + ) { + const sourceAction = + animationBlendState.runWeight > animationBlendState.walkWeight ? runAction : walkAction + const targetAction = sourceAction === runAction ? walkAction : runAction + syncActionPhase(sourceAction, targetAction) + } + + const actionTargets = new Map< + AnimationAction, + { timeScaleSum: number; weight: number; weightedTimeScale: number } + >() + const locomotionBlendWeight = 1 - previewReleasedWeight + accumulateActionTarget( + actionTargets, + idleAction, + animationBlendState.idleWeight * locomotionBlendWeight, + IDLE_TIME_SCALE, + ) + accumulateActionTarget( + actionTargets, + walkAction, + animationBlendState.walkWeight * locomotionBlendWeight, + animationBlendState.walkTimeScale, + ) + accumulateActionTarget( + actionTargets, + runAction, + animationBlendState.runWeight * locomotionBlendWeight, + animationBlendState.runTimeScale, + ) + accumulateActionTarget(actionTargets, previewReleasedAction, previewReleasedWeight, 0) + + const blendedRuntimeActions = getUniqueActions([...runtimeActions, previewReleasedAction]) + for (const action of blendedRuntimeActions) { + const target = actionTargets.get(action) + const targetWeight = MathUtils.clamp(target?.weight ?? 0, 0, 1) + + if (targetWeight <= 1e-3) { + setActionInactive(action) + continue + } + + setActionActive( + action, + targetWeight, + target && target.weightedTimeScale > Number.EPSILON + ? target.timeScaleSum / target.weightedTimeScale + : 1, + ) + } + + const landingShoulderBlendWeight = + previewReleasedAction?.getClip().name === JUMPING_DOWN_CLIP_NAME + ? getLandingShoulderBlendWeight( + previewReleasedRuntimePlanarRootMotionClip, + previewReleasedClipTime, + ) + : 0 + applyShoulderPoseTargets( + shoulderBonesRef.current, + idleShoulderTargets, + previewReleasedAction ? Math.max(1 - previewReleasedWeight, landingShoulderBlendWeight) : 0, + ) + measureNavigationPerf('navigationRobot.toolConeCarryFollowMs', () => { + applyToolConeCarryFollow() + }) + toolConeDebugPayload = measureNavigationPerf('navigationRobot.toolConeOverlayMs', () => + updateToolConeOverlay( + camera, + toolInteractionTargetItemId, + toolInteractionPhase, + toolInteractionClipTime, + toolConeCarryContinuationVisible, + toolConeCarryContinuationVisible, + Boolean(toolCarryTargetItemId), + shouldCaptureRobotDebugState, + ), + ) + motionRef.current.debugActiveClipName = activeClipNameRef.current + motionRef.current.debugForcedClipRevealProgress = 1 + motionRef.current.debugForcedClipTime = null + motionRef.current.debugLandingShoulderBlendWeight = landingShoulderBlendWeight + motionRef.current.debugReleasedForcedClipName = previewReleasedAction?.getClip().name ?? null + motionRef.current.debugReleasedForcedClipTime = previewReleasedAction + ? previewReleasedClipTime + : null + motionRef.current.debugReleasedForcedWeight = previewReleasedWeight + + const dominantAction = + animationBlendState.runWeight >= animationBlendState.walkWeight && + animationBlendState.runWeight >= animationBlendState.idleWeight + ? runAction + : animationBlendState.walkWeight >= animationBlendState.idleWeight + ? walkAction + : idleAction + const dominantClipName = dominantAction?.getClip().name ?? null + if (dominantClipName !== activeClipNameRef.current) { + activeClipNameRef.current = dominantClipName + mergeNavigationPerfMeta({ + navigationRobotActiveClip: dominantClipName, + }) + } + motionRef.current.debugActiveClipName = activeClipNameRef.current + + if (shouldCaptureRobotDebugState && typeof window !== 'undefined') { + writeRobotDebugState(debugId, debugStateRef, { + activeClipName: activeClipNameRef.current, + forcedClipName: null, + forcedClipPlaying: false, + forcedClipRevealProgress: 1, + forcedClipTime: null, + landingShoulderBlendWeight, + locomotion: { + moveBlend: motionRef.current.locomotion.moveBlend, + runBlend: motionRef.current.locomotion.runBlend, + runTimeScale: motionRef.current.locomotion.runTimeScale, + walkTimeScale: motionRef.current.locomotion.walkTimeScale, + }, + materialDebugMode, + revealMaterialsActive: revealMaterialsActiveRef.current, + toolRevealMaterialsActive: toolRevealMaterialsActiveRef.current, + materialWarmupReady, + moving: motionRef.current.moving, + releasedForcedClipName: previewReleasedAction?.getClip().name ?? null, + releasedForcedClipTime: previewReleasedAction ? previewReleasedClipTime : null, + releasedForcedWeight: previewReleasedWeight, + rootMotionOffset: motionRef.current.rootMotionOffset, + toolCone: toolConeDebugPayload, + }) + } + + recordNavigationRobotFramePerf(frameStart) + return + } + + motionRef.current.rootMotionOffset = [0, 0, 0] + const locomotion = motionRef.current.locomotion + const animationBlendState = animationBlendStateRef.current + const releasedForcedAction = releasedForcedActionRef.current + const releasedForcedRuntimePlanarRootMotionClip = releasedForcedAction + ? (runtimePlanarRootMotionClips.byName.get(releasedForcedAction.getClip().name) ?? null) + : null + const releasedForcedBlendResponse = releasedForcedAction + ? (SLOW_RELEASE_CLIP_BLEND_RESPONSE_BY_NAME[releasedForcedAction.getClip().name] ?? + FORCED_CLIP_RELEASE_BLEND_RESPONSE) + : FORCED_CLIP_RELEASE_BLEND_RESPONSE + const releasedForcedWeight = releasedForcedAction + ? MathUtils.damp(releasedForcedWeightRef.current, 0, releasedForcedBlendResponse, frameDelta) + : 0 + releasedForcedWeightRef.current = releasedForcedWeight + if (releasedForcedAction && releasedForcedWeight <= 1e-3) { + releasedForcedAction.clampWhenFinished = false + releasedForcedAction.paused = false + releasedForcedAction.setEffectiveTimeScale(1) + releasedForcedAction.stop() + releasedForcedActionRef.current = null + releasedForcedWeightRef.current = 0 + } + if (visualOffsetGroup) { + if ( + releasedForcedAction && + rootGroupRef.current && + rootMotionBoneRef.current && + rootMotionBaselineScenePositionRef.current + ) { + getCurrentRootMotionOffset( + rootGroupRef.current, + rootMotionBoneRef.current, + rootMotionBaselineScenePositionRef.current, + rootMotionBaselineWorldRef.current, + rootMotionCurrentWorldRef.current, + rootMotionOffsetRef.current, + ) + const releaseOffsetWeight = MathUtils.clamp(releasedForcedWeight, 0, 1) + visualOffsetGroup.position.set( + -rootMotionOffsetRef.current.x * releaseOffsetWeight, + 0, + -rootMotionOffsetRef.current.z * releaseOffsetWeight, + ) + } else { + visualOffsetGroup.position.set(0, 0, 0) + } + } + const moveBlendTarget = motionRef.current.moving + ? MathUtils.clamp(locomotion.moveBlend, 0, 1) + : 0 + const runBlendTarget = Math.min(moveBlendTarget, MathUtils.clamp(locomotion.runBlend, 0, 1)) + const walkBlendTarget = Math.max(0, moveBlendTarget - runBlendTarget) + const idleBlendTarget = Math.max(0, 1 - moveBlendTarget) + + animationBlendState.idleWeight = MathUtils.damp( + animationBlendState.idleWeight, + idleBlendTarget, + CLIP_BLEND_RESPONSE, + frameDelta, + ) + animationBlendState.walkWeight = MathUtils.damp( + animationBlendState.walkWeight, + walkBlendTarget, + CLIP_BLEND_RESPONSE, + frameDelta, + ) + animationBlendState.runWeight = MathUtils.damp( + animationBlendState.runWeight, + runBlendTarget, + CLIP_BLEND_RESPONSE, + frameDelta, + ) + animationBlendState.walkTimeScale = MathUtils.damp( + animationBlendState.walkTimeScale, + Math.max(0.01, locomotion.walkTimeScale), + CLIP_TIME_SCALE_RESPONSE, + frameDelta, + ) + animationBlendState.runTimeScale = MathUtils.damp( + animationBlendState.runTimeScale, + Math.max(0.01, locomotion.runTimeScale), + CLIP_TIME_SCALE_RESPONSE, + frameDelta, + ) + + if ( + walkAction && + runAction && + walkAction !== runAction && + (animationBlendState.walkWeight > 1e-3 || animationBlendState.runWeight > 1e-3) + ) { + const sourceAction = + animationBlendState.runWeight > animationBlendState.walkWeight ? runAction : walkAction + const targetAction = sourceAction === runAction ? walkAction : runAction + syncActionPhase(sourceAction, targetAction) + } + + const actionTargets = new Map< + AnimationAction, + { timeScaleSum: number; weight: number; weightedTimeScale: number } + >() + const releaseToIdleOnly = + releasedForcedAction?.getClip().name === CHECKOUT_CLIP_NAME && !motionRef.current.moving + const locomotionBlendWeight = 1 - MathUtils.clamp(releasedForcedWeight, 0, 1) + accumulateActionTarget( + actionTargets, + idleAction, + (releaseToIdleOnly ? 1 : animationBlendState.idleWeight) * locomotionBlendWeight, + IDLE_TIME_SCALE, + ) + if (!releaseToIdleOnly) { + accumulateActionTarget( + actionTargets, + walkAction, + animationBlendState.walkWeight * locomotionBlendWeight, + animationBlendState.walkTimeScale, + ) + accumulateActionTarget( + actionTargets, + runAction, + animationBlendState.runWeight * locomotionBlendWeight, + animationBlendState.runTimeScale, + ) + } + accumulateActionTarget( + actionTargets, + releasedForcedActionRef.current, + MathUtils.clamp(releasedForcedWeight, 0, 1), + 0, + ) + + const blendedRuntimeActions = getUniqueActions([ + ...runtimeActions, + releasedForcedActionRef.current, + ]) + + for (const action of blendedRuntimeActions) { + const target = actionTargets.get(action) + const targetWeight = MathUtils.clamp(target?.weight ?? 0, 0, 1) + + if (targetWeight <= 1e-3) { + setActionInactive(action) + continue + } + + setActionActive( + action, + targetWeight, + target && target.weightedTimeScale > Number.EPSILON + ? target.timeScaleSum / target.weightedTimeScale + : 1, + ) + } + + const releaseLandingShoulderBlendWeight = releasedForcedAction + ? Math.max( + 1 - MathUtils.clamp(releasedForcedWeight, 0, 1), + getLandingShoulderBlendWeight( + releasedForcedRuntimePlanarRootMotionClip, + releasedForcedAction.time, + ), + ) + : 0 + applyShoulderPoseTargets( + shoulderBonesRef.current, + idleShoulderTargets, + releaseLandingShoulderBlendWeight, + ) + measureNavigationPerf('navigationRobot.toolConeCarryFollowMs', () => { + applyToolConeCarryFollow() + }) + toolConeDebugPayload = measureNavigationPerf('navigationRobot.toolConeOverlayMs', () => + updateToolConeOverlay( + camera, + toolInteractionTargetItemId, + toolInteractionPhase, + toolInteractionClipTime, + toolConeCarryContinuationVisible, + toolConeCarryContinuationVisible, + Boolean(toolCarryTargetItemId), + shouldCaptureRobotDebugState, + ), + ) + + const dominantAction = + animationBlendState.runWeight >= animationBlendState.walkWeight && + animationBlendState.runWeight >= animationBlendState.idleWeight + ? runAction + : animationBlendState.walkWeight >= animationBlendState.idleWeight + ? walkAction + : idleAction + const dominantClipName = dominantAction?.getClip().name ?? null + + if (dominantClipName !== activeClipNameRef.current) { + activeClipNameRef.current = dominantClipName + mergeNavigationPerfMeta({ + navigationRobotActiveClip: dominantClipName, + }) + } + motionRef.current.debugActiveClipName = activeClipNameRef.current + motionRef.current.debugForcedClipRevealProgress = 1 + motionRef.current.debugForcedClipTime = null + motionRef.current.debugLandingShoulderBlendWeight = releaseLandingShoulderBlendWeight + motionRef.current.debugReleasedForcedClipName = releasedForcedAction?.getClip().name ?? null + motionRef.current.debugReleasedForcedClipTime = releasedForcedAction?.time ?? null + motionRef.current.debugReleasedForcedWeight = releasedForcedWeight + + let changedBoneCount = 0 + let maxBoneAngleDelta = 0 + let maxBonePositionDelta = 0 + + if (NAVIGATION_ROBOT_DEBUG_ENABLED) { + const debugBoneSamples = debugBoneSamplesRef.current + for (const sample of debugBoneSamples) { + const positionDelta = sample.bone.position.distanceTo(sample.previousPosition) + const angleDelta = sample.bone.quaternion.angleTo(sample.previousQuaternion) + if (positionDelta > 1e-5 || angleDelta > 1e-5) { + changedBoneCount += 1 + } + maxBonePositionDelta = Math.max(maxBonePositionDelta, positionDelta) + maxBoneAngleDelta = Math.max(maxBoneAngleDelta, angleDelta) + sample.previousPosition.copy(sample.bone.position) + sample.previousQuaternion.copy(sample.bone.quaternion) + } + + if ( + motionRef.current.moving && + (changedBoneCount > 0 || maxBoneAngleDelta > 1e-5 || maxBonePositionDelta > 1e-5) + ) { + debugMovingEvidenceRef.current += 1 + } + } + + if (shouldCaptureRobotDebugState && typeof window !== 'undefined') { + writeRobotDebugState(debugId, debugStateRef, { + activeClipName: activeClipNameRef.current, + changedBoneCount, + forcedClipName: null, + forcedClipPlaying: false, + forcedClipRevealProgress: 1, + forcedClipTime: null, + landingShoulderBlendWeight: releaseLandingShoulderBlendWeight, + locomotion: { + moveBlend: locomotion.moveBlend, + runBlend: locomotion.runBlend, + runTimeScale: locomotion.runTimeScale, + walkTimeScale: locomotion.walkTimeScale, + }, + materialDebugMode, + revealMaterialsActive: revealMaterialsActiveRef.current, + toolRevealMaterialsActive: toolRevealMaterialsActiveRef.current, + materialWarmupReady, + maxBoneAngleDelta, + maxBonePositionDelta, + moving: motionRef.current.moving, + movingEvidenceFrames: debugMovingEvidenceRef.current, + releasedForcedClipName: releasedForcedAction?.getClip().name ?? null, + releasedForcedClipTime: releasedForcedAction?.time ?? null, + releasedForcedWeight, + rootMotionOffset: motionRef.current.rootMotionOffset, + toolCone: toolConeDebugPayload, + weights: { + idle: animationBlendState.idleWeight, + run: animationBlendState.runWeight, + walk: animationBlendState.walkWeight, + }, + }) + } + + recordNavigationRobotFramePerf(frameStart) + }) + + return ( + + + + + + ) +} + +useGLTF.preload(TOOL_ASSET_PATH) + +useGLTF.preload(NAVIGATION_ROBOT_ASSETS.pascal) +useGLTF.preload(NAVIGATION_ROBOT_ASSETS.armored) diff --git a/packages/editor/src/components/editor/navigation-system.tsx b/packages/editor/src/components/editor/navigation-system.tsx new file mode 100644 index 000000000..8ef6844ef --- /dev/null +++ b/packages/editor/src/components/editor/navigation-system.tsx @@ -0,0 +1,12155 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + emitter, + getScaledDimensions, + ItemNode, + type LevelNode, + resolveLevelId, + sceneRegistry, + spatialGridManager, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { ITEM_DELETE_FADE_OUT_MS, useViewer } from '@pascal-app/viewer' +import { addAfterEffect, useFrame, useLoader, useThree } from '@react-three/fiber' +import { Suspense, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + AdditiveBlending, + Box3, + BufferGeometry, + CanvasTexture, + CatmullRomCurve3, + Color, + type Curve, + CurvePath, + DoubleSide, + FileLoader, + Float32BufferAttribute, + Group, + LineBasicMaterial, + LineCurve3, + type Material, + MathUtils, + Matrix4, + Mesh, + MeshBasicMaterial, + type Object3D, + PerspectiveCamera, + QuadraticBezierCurve3, + Quaternion, + Raycaster, + RepeatWrapping, + type Scene, + TubeGeometry, + Vector2, + Vector3, +} from 'three' +import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js' +import { color, float, mix, uniform, uv } from 'three/tsl' +import { MeshBasicNodeMaterial, RenderTarget } from 'three/webgpu' +import { useShallow } from 'zustand/react/shallow' +import { + type ItemMoveVisualState, + setItemMoveVisualState as setItemMoveVisualMetadata, +} from '../../lib/item-move-visuals' +import { + buildNavigationGraph, + findClosestNavigationCell, + findNavigationPath, + getNavigationDoorTransitions, + getNavigationPathWorldPoints, + getNavigationPointBlockers, + isNavigationPointSupported, + NAVIGATION_AGENT_RADIUS, + type NavigationDoorTransition, + type NavigationGraph, + type NavigationPathResult, + simplifyNavigationPath, +} from '../../lib/navigation' +import { + measureNavigationPerf, + mergeNavigationPerfMeta, + recordNavigationPerfMark, + recordNavigationPerfSample, + resetNavigationPerf, +} from '../../lib/navigation-performance' +import { + getPascalTruckIntroReleaseDurationMs, + PASCAL_TRUCK_ASSET, + PASCAL_TRUCK_ASSET_ID, + PASCAL_TRUCK_ENTRY_CLIP_DURATION_SECONDS, + PASCAL_TRUCK_ENTRY_CLIP_NAME, + PASCAL_TRUCK_ENTRY_MAX_STEP_MS, + PASCAL_TRUCK_ENTRY_REAR_EDGE_INSET, + PASCAL_TRUCK_ENTRY_REAR_TRAVEL_DISTANCE, + PASCAL_TRUCK_ENTRY_REVEAL_DURATION_MS, + PASCAL_TRUCK_ENTRY_REVEAL_TRAVEL_RATIO, + PASCAL_TRUCK_ENTRY_TRAVEL_END_PROGRESS, + PASCAL_TRUCK_ITEM_NODE_ID, + PASCAL_TRUCK_REAR_LOCAL_X_SIGN, +} from '../../lib/pascal-truck' +import { sfxEmitter } from '../../lib/sfx-bus' +import useEditor from '../../store/use-editor' +import useNavigation, { + type NavigationItemDeleteRequest, + type NavigationItemMoveController, + type NavigationItemMoveRequest, + type NavigationItemRepairRequest, + type NavigationQueuedTask, + type NavigationRobotMode, + navigationEmitter, + requestNavigationItemDelete, +} from '../../store/use-navigation' +import { getNavigationDraftRobotCopySourceId } from '../../store/use-navigation-drafts' +import navigationVisualsStore, { useNavigationVisuals } from '../../store/use-navigation-visuals' +import { stripTransient } from '../tools/item/placement-math' +import { getActiveNavigationDoorIds, NavigationDoorSystem } from './navigation-door-system' + +function appendTaskModeTrace(type: string, payload: Record = {}) { + void type + void payload +} + +function summarizeDebugSnapshotKey(key: string | null) { + if (key === null) { + return null + } + + let hash = 0 + for (let index = 0; index < key.length; index += 1) { + hash = (hash * 31 + key.charCodeAt(index)) | 0 + } + + return { + hash: hash.toString(16), + length: key.length, + } +} + +import type { NavigationRobotMaterialDebugMode } from './navigation-robot' +import { + NAVIGATION_ROBOT_ASSETS, + NavigationRobot, + type NavigationRobotToolInteractionPhase, +} from './navigation-robot' + +const PATH_CURVE_OFFSET_Y = 0.92 +const ACTOR_HOVER_Y = 0.16 +const ACTOR_SPEED_SCALE = 0.75 +const ACTOR_RUN_SPEED_RATIO = 3 +const ACTOR_WALK_ANIMATION_SPEED_SCALE = ACTOR_SPEED_SCALE * 1.3 +const ACTOR_RUN_ANIMATION_SPEED_SCALE = 1.05 +const ACTOR_COLLISION_RADIUS = NAVIGATION_AGENT_RADIUS +const ACTOR_DOOR_COLLISION_HEIGHT = 0.9 +const ACTOR_WALK_MAX_SPEED = 1.9 * ACTOR_SPEED_SCALE +const ACTOR_RUN_MAX_SPEED = ACTOR_WALK_MAX_SPEED * ACTOR_RUN_SPEED_RATIO +const ACTOR_WALK_ACCELERATION = 2.8 * ACTOR_SPEED_SCALE +const ACTOR_RUN_ACCELERATION = 3.6 * ACTOR_SPEED_SCALE +const ACTOR_WALK_DECELERATION = 3.2 * ACTOR_SPEED_SCALE +const ACTOR_RUN_DECELERATION = 4.1 * ACTOR_SPEED_SCALE +const ACTOR_LOCOMOTION_BLEND_SPEED = Math.max(0.24, ACTOR_WALK_MAX_SPEED * 0.22) +// Cap pathological stalls, but do not slow normal low-FPS movement. +const ACTOR_MOTION_MAX_FRAME_DELTA_SECONDS = 1 / 8 +const PASCAL_TRUCK_ENTRY_RELEASE_DURATION_MS = getPascalTruckIntroReleaseDurationMs() +const PASCAL_TRUCK_ENTRY_ROBOT_READY_FALLBACK_MS = 8000 +const NAVIGATION_SYSTEM_ACTOR_DEBUG_ID = 'pascal-navigation-actor' +const ACTOR_TURN_RESPONSE = 12 +const ACTOR_REPATH_SPEED_RETENTION = 0.82 +const TRAJECTORY_CURVATURE_SAMPLE_STEP = 0.18 +const TRAJECTORY_CURVATURE_WINDOW_DISTANCE = 0.36 +const TRAJECTORY_SMALL_RADIUS_THRESHOLD = 1.8 +const TRAJECTORY_RUN_LOOKAHEAD_DISTANCE = 2 +const TRAJECTORY_RUN_MIN_SECTION_LENGTH = 2 +const TRAJECTORY_RUN_ACCELERATION_DISTANCE = 0.85 +const TRAJECTORY_RUN_DECELERATION_DISTANCE = 0.95 +const MAX_REACHABLE_TARGET_SNAP_DISTANCE = 1.4 +const SPAWN_SUPPORT_RADIUS_CELLS = 2 +const PATH_STATIC_PREVIEW_MODE = false +const PATH_RENDER_MAIN_RADIUS = 0.045 +const PATH_RENDER_STATIC_PREVIEW_MAIN_RADIUS = 0.06 +const PATH_STATIC_PREVIEW_FADE_SEGMENT_COUNT = 24 +const PATH_RENDER_MAIN_RADIAL_SEGMENTS = 12 +const PATH_RENDER_SEGMENT_LENGTH = 0.18 +const PATH_RENDER_FADE_START_DISTANCE = 0.5 +const PATH_RENDER_FADE_END_DISTANCE = 1.5 +const PATH_RENDER_THREAD_WIDTH = 0.04 +const PATH_RENDER_THREAD_COLOR = '#b9ff9d' +const PATH_RENDER_ORBITS_ENABLED = false +const PATH_MAIN_HIGHLIGHT_ALPHA = 0.68 +const TOOL_CONE_CAMERA_SURFACE_EPSILON = 0.035 +const PATH_MAIN_HIGHLIGHT_FEATHER = 0.18 +const PATH_MAIN_HIGHLIGHT_LENGTH = 0.32 +const PATH_RENDER_ORBIT_OFFSET = 0.06 +const PATH_RENDER_ORBIT_VERTICAL_SCALE = 0.42 +const PATH_RENDER_ORBIT_RIBBON_WIDTH = 0.044 +const PATH_RENDER_ORBIT_RIBBON_TWIST_COUNT = 1.5 +const PATH_RENDER_ORBIT_WAVE_COUNT = 2.35 +const PATH_RENDER_ORBIT_PHASE_SPEED = 0.38 +const PATH_RENDER_ORBIT_EDGE_FADE_DISTANCE = 0.7 +const PATH_RENDER_ORBIT_ALPHA_WAVE_COUNT = 2.8 +const PATH_RENDER_ORBIT_ALPHA_WAVE_SPEED = 1.8 +const PATH_RENDER_ORBIT_ALPHA_MIN = 0.76 +const PATH_RENDER_ORBIT_ALPHA_MAX = 1 +const PATH_MIN_CORNER_RADIUS = 0.05 +const PATH_MAX_CORNER_RADIUS = 0.18 +const PATH_SUPPORT_SAMPLE_STEP = 0.08 +const STRAIGHT_PATH_DOT_THRESHOLD = 0.999 +const MIN_CURVE_SEGMENT_LENGTH = 0.02 +const ACTOR_POSITION_PUBLISH_DISTANCE = 0.14 +const ACTOR_POSITION_PUBLISH_INTERVAL_MS = 180 +const DOOR_COLLISION_ACTIVE_EPSILON = 0.02 +const TASK_SOURCE_SHIELD_MESH_URL = '/meshes/scifi-shield/mesh_shield_1.obj' +const TASK_SOURCE_SHIELD_EDGE_COLOR_MULTIPLIER = 0.48 +const TASK_SOURCE_SHIELD_OPACITY = 0.94 +const TASK_SOURCE_SHIELD_SCALE_MULTIPLIER = 1.1 +const TASK_SOURCE_SHIELD_SECONDARY_SCALE_MULTIPLIER = 1.03 +const TASK_SOURCE_SHIELD_SPIN_SPEED = 0.336 +const TASK_SOURCE_SHIELD_VERTICAL_OFFSET_MULTIPLIER = 0.5 +const TASK_SOURCE_SHIELD_FADE_IN_MS = 1000 + +const configureTaskSourceShieldTextLoader = (loader: FileLoader) => { + loader.setResponseType('text') +} + +const stripTaskSourceShieldLineRecords = (objSource: string) => + objSource + .split(/\r?\n/) + .filter((line) => !line.startsWith('l ')) + .join('\n') + +const stripTaskSourceShieldFaceRecords = (objSource: string) => + objSource + .split(/\r?\n/) + .filter((line) => !line.startsWith('f ')) + .join('\n') + +useLoader.preload(FileLoader, TASK_SOURCE_SHIELD_MESH_URL, configureTaskSourceShieldTextLoader) + +function isNavigationDebugEnabled() { + return false +} +const NAVIGATION_AUDIT_DIAGNOSTICS_ENABLED = false +const NAVIGATION_FRAME_TRACE_ENABLED = false +// Keep the actor back far enough for the proof-scene cone handoff to read. +const ITEM_MOVE_APPROACH_STANDOFF = 1.25 +const ITEM_MOVE_APPROACH_MARGIN = Math.max( + ITEM_MOVE_APPROACH_STANDOFF, + NAVIGATION_AGENT_RADIUS + 0.06, +) +const ITEM_MOVE_APPROACH_MAX_SNAP_DISTANCE = 1.45 +const ITEM_MOVE_PICKUP_DURATION_MS = 760 +const ITEM_MOVE_DROP_DURATION_MS = 820 +const ITEM_INTERACTION_GESTURE_DURATION_SCALE = 0.5 +const ITEM_MOVE_ROBOT_HEIGHT_ESTIMATE = 1.82 +const ITEM_MOVE_CARRY_HEAD_CLEARANCE = 0.26 +const ITEM_MOVE_CARRY_ITEM_HEIGHT_SCALE = 0.16 +const ITEM_MOVE_CARRY_ITEM_HEIGHT_MAX = 0.26 +const ITEM_MOVE_CARRY_FORWARD_DISTANCE = 0.5 +const ITEM_MOVE_CARRY_WOBBLE_LATERAL = 0.035 +const ITEM_MOVE_CARRY_WOBBLE_VERTICAL = 0.028 +const ITEM_MOVE_CARRY_WOBBLE_SPEED = 0.0024 +const ITEM_MOVE_PICKUP_ARC_HEIGHT = 0.42 +const ITEM_MOVE_DROP_SETTLE_DURATION_MS = 34 +const ITEM_MOVE_COMMIT_DEFER_DELAY_MS = 180 +const ITEM_MOVE_COMMIT_IDLE_TIMEOUT_MS = 1200 +const ITEM_MOVE_PREVIEW_PLAN_CACHE_MAX_ENTRIES = 24 +const ITEM_MOVE_PREVIEW_PLAN_DEBOUNCE_MS = 0 +const NAVIGATION_POST_WARMUP_CAMERA_STABLE_MS = 180 +const NAVIGATION_TOOL_CONE_MOVE_COLOR = '#52e8ff' +const NAVIGATION_TOOL_CONE_COPY_COLOR = '#22c55e' +const NAVIGATION_TOOL_CONE_DELETE_COLOR = '#ef4444' +const NAVIGATION_TOOL_CONE_REPAIR_COLOR = '#c2bb00' +const TASK_QUEUE_INACTIVE_ACTION_SHIELD_OPACITY = 0.45 +const ITEM_MOVE_GESTURE_CLIP_OPTIONS = [ + { + clipName: 'Checkout_Gesture', + durationSeconds: 6.4666666984558105, + }, +] as const +const STATIC_SHADOW_SCENE_WARMUP_FRAMES = 240 +const STATIC_SHADOW_DYNAMIC_SETTLE_FRAMES = 18 +const NAVIGATION_GRAPH_CACHE_MAX_ENTRIES = 8 + +function isDebugMovableItem( + node: ItemNode, + nodes: Record, +) { + if (node.asset.attachTo || node.asset.category === 'door' || node.asset.category === 'window') { + return false + } + + if (isNavigationTaskPreviewNodeId(node.id)) { + return false + } + + const parentNode = node.parentId ? nodes[node.parentId] : null + return parentNode?.type !== 'item' +} + +type NavigationItemMoveApproach = { + cellIndex: number + world: [number, number, number] +} + +type NavigationItemFootprintBounds = { + maxX: number + maxZ: number + minX: number + minZ: number +} + +type NavigationItemMovePlan = { + controller: NavigationItemMoveController + dropGesture: NavigationItemMoveGesture + exitPath: NavigationPrecomputedExitPath | null + pickupGesture: NavigationItemMoveGesture + request: NavigationItemMoveRequest + sourceApproach: NavigationItemMoveApproach + sourcePath: NavigationPathResult + targetApproach: NavigationItemMoveApproach + targetPath: NavigationPathResult + targetPlanningGraph: NavigationGraph +} + +type NavigationPrecomputedExitPath = { + destinationCellIndex: number | null + pathResult: NavigationPathResult + planningGraph: NavigationGraph + targetWorldPosition: [number, number, number] +} + +type PendingPascalTruckExitRequest = { + allowQueuedTasks: boolean + requiredTaskLoopToken: number | null +} + +type TaskQueueSourceMarkerSpec = { + color: string + dimensions: [number, number, number] + isActive: boolean + kind: 'copy' | 'delete' | 'move' | 'repair' + opacity: number + position: [number, number, number] + taskId: string +} + +function isNavigationCopyItemMoveRequest(request: NavigationItemMoveRequest | null) { + return Boolean(request?.visualItemId && request.visualItemId !== request.itemId) +} + +function getNavigationQueuedTaskVisualKind(task: NavigationQueuedTask) { + if (task.kind !== 'move') { + return task.kind + } + + return isNavigationCopyItemMoveRequest(task.request) ? 'copy' : 'move' +} + +function getTaskQueueSourceMarkerSpecs( + taskQueue: NavigationQueuedTask[], + activeTaskId: string | null, + enabled: boolean, + robotMode: NavigationRobotMode | null, +): TaskQueueSourceMarkerSpec[] { + if (!(enabled && robotMode === 'task')) { + return [] + } + + return taskQueue.flatMap((task) => { + const taskVisualKind = getNavigationQueuedTaskVisualKind(task) + const color = + taskVisualKind === 'copy' + ? NAVIGATION_TOOL_CONE_COPY_COLOR + : taskVisualKind === 'delete' + ? NAVIGATION_TOOL_CONE_DELETE_COLOR + : taskVisualKind === 'repair' + ? NAVIGATION_TOOL_CONE_REPAIR_COLOR + : taskVisualKind === 'move' + ? NAVIGATION_TOOL_CONE_MOVE_COLOR + : null + if (color === null) { + return [] + } + + const request = task.request + const position = getRenderedFloorItemPosition( + request.levelId, + request.sourcePosition, + request.itemDimensions, + request.sourceRotation, + ) + + return [ + { + color, + dimensions: [...request.itemDimensions] as [number, number, number], + isActive: task.taskId === activeTaskId, + kind: taskVisualKind, + opacity: task.taskId === activeTaskId ? 1 : TASK_QUEUE_INACTIVE_ACTION_SHIELD_OPACITY, + position, + taskId: task.taskId, + }, + ] + }) +} + +function roundWarmupCameraValue(value: number) { + return Math.round(value * 20) / 20 +} + +type ResolvedNavigationItemMovePlan = Pick< + NavigationItemMovePlan, + | 'exitPath' + | 'sourceApproach' + | 'sourcePath' + | 'targetApproach' + | 'targetPath' + | 'targetPlanningGraph' +> + +type NavigationItemMovePreviewPlan = ResolvedNavigationItemMovePlan & { + cacheKey: string +} + +type NavigationItemMoveSequence = NavigationItemMovePlan & { + pickupCarryVisualStartedAt: number | null + dropStartedAt: number | null + dropStartPosition: [number, number, number] | null + dropSettledAt: number | null + pickupStartedAt: number | null + pickupTransferStartedAt: number | null + sourceDisplayPosition: [number, number, number] + stage: 'drop-settle' | 'drop-transfer' | 'pickup-transfer' | 'to-source' | 'to-target' + taskId: string | null + targetDisplayPosition: [number, number, number] + targetRotationY: number +} + +type NavigationItemDeleteSequence = { + deleteStartedAt: number | null + gesture: NavigationItemMoveGesture + request: NavigationItemDeleteRequest + sourceApproach: NavigationItemMoveApproach + stage: 'delete-transfer' | 'to-source' + taskId: string | null +} + +type NavigationItemRepairSequence = { + gesture: NavigationItemMoveGesture + repairStartedAt: number | null + request: NavigationItemRepairRequest + sourceApproach: NavigationItemMoveApproach + stage: 'repair-transfer' | 'to-source' + taskId: string | null +} + +type NavigationSceneSnapshot = ReturnType + +type ItemMoveFrameTraceSample = { + at: number + ghostId: string | null + ghostLivePosition: [number, number, number] | null + ghostLocalPosition: [number, number, number] | null + ghostNodePosition: [number, number, number] | null + ghostWorldDeltaYFromStart: number | null + ghostWorldDeltaZFromStart: number | null + ghostWorldPosition: [number, number, number] | null + sourceId: string | null + sourceLivePosition: [number, number, number] | null + sourceLocalPosition: [number, number, number] | null + sourceNodePosition: [number, number, number] | null + sourceWorldDeltaYFromStart: number | null + sourceWorldDeltaZFromStart: number | null + sourceWorldPosition: [number, number, number] | null + stage: string | null +} + +type TrajectoryShaderHandle = { + uniforms: Record +} + +type TrajectoryMaterialUniforms = { + uTrajectoryAlphaEnabled: { value: number } + uTrajectoryAlphaMax: { value: number } + uTrajectoryAlphaMin: { value: number } + uTrajectoryAlphaPhase: { value: number } + uTrajectoryAlphaWaveCount: { value: number } + uTrajectoryAlphaWaveSpeed: { value: number } + uTrajectoryEndFadeLength: { value: number } + uTrajectoryFrontFadeLength: { value: number } + uTrajectoryReveal: { value: number } + uTrajectoryTime: { value: number } + uTrajectoryVisibleStart: { value: number } +} + +type TrajectoryMaterialHandle = MeshBasicMaterial & { + userData: MeshBasicMaterial['userData'] & { + trajectoryUniforms?: TrajectoryMaterialUniforms + } +} + +type TrajectoryThreadMaterial = MeshBasicNodeMaterial & { + userData: MeshBasicNodeMaterial['userData'] & { + uFadeLength: { value: number } + uOpaque: { value: number } + uReveal: { value: number } + uVisibleStart: { value: number } + } +} + +type RendererShadowMap = { + autoUpdate?: boolean + enabled?: boolean + needsUpdate?: boolean +} +type PathRenderSegment = { + centerT: number + endT: number + geometry: TubeGeometry + material: MeshBasicMaterial + startT: number +} + +type OrbitRibbonVisualState = { + alphaMax: number + alphaMin: number + alphaPhase: number + alphaWaveCount: number + alphaWaveSpeed: number + time: number +} + +type TrajectoryCurvatureSectionKind = 'high' | 'low' + +type TrajectoryCurvatureSection = { + endDistance: number + kind: TrajectoryCurvatureSectionKind + minRadius: number + startDistance: number +} + +type TrajectoryMotionProfile = { + sections: TrajectoryCurvatureSection[] + totalLength: number +} + +type TrajectoryMotionState = { + runBlend: number + section: TrajectoryCurvatureSection | null + sectionKind: TrajectoryCurvatureSectionKind +} + +type ActorLocomotionState = { + moveBlend: number + runBlend: number + runTimeScale: number + sectionKind: TrajectoryCurvatureSectionKind + walkTimeScale: number +} + +type ActorForcedClipState = { + clipName: string + holdLastFrame: boolean + loop: 'once' | 'repeat' + paused: boolean + revealProgress: number + seekTime: number | null + timeScale: number +} + +type NavigationItemMoveGesture = (typeof ITEM_MOVE_GESTURE_CLIP_OPTIONS)[number] + +type NavigationRobotForcedClipPlayback = { + clipName: string + holdLastFrame?: boolean + loop?: 'once' | 'repeat' + playbackToken?: number | string + revealFromStart?: boolean + stabilizeRootMotion?: boolean + timeScale?: number +} + +type ActorMotionState = { + debugTransitionPreview?: { + releasedClipName: string + releasedClipTime: number + releasedClipWeight: number + } | null + destinationCellIndex: number | null + distance: number + forcedClip: ActorForcedClipState | null + locomotion: ActorLocomotionState + moving: boolean + rootMotionOffset: [number, number, number] + speed: number + visibilityRevealProgress?: number | null +} + +type PascalTruckIntroState = { + animationElapsedMs: number + animationStarted: boolean + endPosition: [number, number, number] + finalCellIndex: number | null + handoffPending: boolean + revealElapsedMs: number + revealStarted: boolean + rotationY: number + startPosition: [number, number, number] + warmupWaitElapsedMs: number +} + +type PascalTruckExitState = { + endPosition: [number, number, number] + fadeElapsedMs: number + finalCellIndex: number | null + rotationY: number + stage: 'fade' | 'to-truck' + startPosition: [number, number, number] +} + +function getPolygonCentroid(points: Array<[number, number]>) { + if (points.length === 0) { + return null + } + + let area = 0 + let centroidX = 0 + let centroidY = 0 + + for (let index = 0; index < points.length; index += 1) { + const current = points[index] + const next = points[(index + 1) % points.length] + + if (!(current && next)) { + continue + } + + const cross = current[0] * next[1] - next[0] * current[1] + area += cross + centroidX += (current[0] + next[0]) * cross + centroidY += (current[1] + next[1]) * cross + } + + if (Math.abs(area) <= Number.EPSILON) { + const [sumX, sumY] = points.reduce( + (accumulator, [x, y]) => [accumulator[0] + x, accumulator[1] + y], + [0, 0], + ) + return [sumX / points.length, sumY / points.length] as [number, number] + } + + return [centroidX / (3 * area), centroidY / (3 * area)] as [number, number] +} + +function cross2D(origin: Vector2, pointA: Vector2, pointB: Vector2) { + return ( + (pointA.x - origin.x) * (pointB.y - origin.y) - (pointA.y - origin.y) * (pointB.x - origin.x) + ) +} + +function computeProjectedHull2D(points: Vector2[]) { + if (points.length < 3) { + return points + } + + const sorted = [...points].sort((pointA, pointB) => { + if (Math.abs(pointA.x - pointB.x) > 1e-6) { + return pointA.x - pointB.x + } + return pointA.y - pointB.y + }) + const uniquePoints = sorted.filter((point, index) => { + if (index === 0) { + return true + } + const previousPoint = sorted[index - 1] + if (!previousPoint) { + return true + } + return Math.abs(point.x - previousPoint.x) > 1e-6 || Math.abs(point.y - previousPoint.y) > 1e-6 + }) + + if (uniquePoints.length < 3) { + return uniquePoints + } + + const lowerHull: Vector2[] = [] + for (const point of uniquePoints) { + while (lowerHull.length >= 2) { + const previous = lowerHull[lowerHull.length - 1] + const beforePrevious = lowerHull[lowerHull.length - 2] + if (!(previous && beforePrevious)) { + break + } + if (cross2D(beforePrevious, previous, point) <= 0) { + lowerHull.pop() + continue + } + break + } + lowerHull.push(point) + } + + const upperHull: Vector2[] = [] + for (let index = uniquePoints.length - 1; index >= 0; index -= 1) { + const point = uniquePoints[index] + if (!point) { + continue + } + while (upperHull.length >= 2) { + const previous = upperHull[upperHull.length - 1] + const beforePrevious = upperHull[upperHull.length - 2] + if (!(previous && beforePrevious)) { + break + } + if (cross2D(beforePrevious, previous, point) <= 0) { + upperHull.pop() + continue + } + break + } + upperHull.push(point) + } + + lowerHull.pop() + upperHull.pop() + + return [...lowerHull, ...upperHull] +} + +function getDistanceToSegment2D(point: Vector2, segmentStart: Vector2, segmentEnd: Vector2) { + const segmentVector = segmentEnd.clone().sub(segmentStart) + const segmentLengthSq = segmentVector.lengthSq() + if (segmentLengthSq <= Number.EPSILON) { + return point.distanceTo(segmentStart) + } + + const pointVector = point.clone().sub(segmentStart) + const projectedT = MathUtils.clamp(pointVector.dot(segmentVector) / segmentLengthSq, 0, 1) + return point.distanceTo(segmentStart.clone().add(segmentVector.multiplyScalar(projectedT))) +} + +function isPointInsidePolygon2D(point: Vector2, polygon: Vector2[]) { + if (polygon.length < 3) { + return false + } + + let inside = false + for ( + let index = 0, previousIndex = polygon.length - 1; + index < polygon.length; + previousIndex = index, index += 1 + ) { + const current = polygon[index] + const previous = polygon[previousIndex] + if (!(current && previous)) { + continue + } + + const intersects = + current.y > point.y !== previous.y > point.y && + point.x < + ((previous.x - current.x) * (point.y - current.y)) / (previous.y - current.y || 1e-6) + + current.x + if (intersects) { + inside = !inside + } + } + + return inside +} + +function isObjectVisibleInHierarchy(target: Object3D | null) { + let current: Object3D | null = target + while (current) { + if (!current.visible) { + return false + } + current = current.parent + } + return true +} + +function isVector3Tuple(value: unknown): value is [number, number, number] { + return ( + Array.isArray(value) && + value.length === 3 && + value.every((entry) => typeof entry === 'number' && Number.isFinite(entry)) + ) +} + +function getToolConeTargetSurfaceHit( + target: Object3D | null, + worldPoint: [number, number, number], + cameraPosition: Vector3, +) { + if (!target) { + return null + } + + const rayDirection = new Vector3(worldPoint[0], worldPoint[1], worldPoint[2]).sub(cameraPosition) + const targetDistance = rayDirection.length() + if (!(targetDistance > 1e-5)) { + return null + } + + rayDirection.multiplyScalar(1 / targetDistance) + const raycaster = new Raycaster(cameraPosition, rayDirection, 0.001, targetDistance + 0.25) + const hit = raycaster + .intersectObject(target, true) + .find( + (intersection) => + !hasNavigationApproachTargetExclusion(intersection.object) && + isObjectVisibleInHierarchy(intersection.object), + ) + if (!hit) { + return { + relation: 'no-hit' as const, + surfaceDistanceDelta: null, + surfaceMeshName: null, + surfacePoint: null, + } + } + + const surfaceDistanceDelta = Math.abs(targetDistance - hit.distance) + return { + relation: + surfaceDistanceDelta <= TOOL_CONE_CAMERA_SURFACE_EPSILON + ? ('visible' as const) + : ('occluded' as const), + surfaceDistanceDelta, + surfaceMeshName: hit.object.name || null, + surfacePoint: [hit.point.x, hit.point.y, hit.point.z] as [number, number, number], + } +} + +function toLevelNodeId(levelId: string | null | undefined): LevelNode['id'] | null { + return typeof levelId === 'string' && levelId.startsWith('level_') + ? (levelId as LevelNode['id']) + : null +} + +function getNavigationPointKey(point: [number, number, number] | Vector3) { + const x = point instanceof Vector3 ? point.x : point[0] + const y = point instanceof Vector3 ? point.y : point[1] + const z = point instanceof Vector3 ? point.z : point[2] + return `${x.toFixed(4)}:${y.toFixed(4)}:${z.toFixed(4)}` +} + +function smoothPathWithinCorridor(points: Vector3[], protectedPointKeys?: Set) { + if (points.length <= 2) { + return points.map((point) => point.clone()) + } + + const simplifiedPoints = [points[0]?.clone()].filter((point): point is Vector3 => Boolean(point)) + + for (let index = 1; index < points.length - 1; index += 1) { + const previous = simplifiedPoints[simplifiedPoints.length - 1] + const current = points[index] + const next = points[index + 1] + + if (!(previous && current && next)) { + continue + } + + if (protectedPointKeys?.has(getNavigationPointKey(current))) { + simplifiedPoints.push(current.clone()) + continue + } + + if (previous.distanceToSquared(current) <= Number.EPSILON) { + continue + } + + if (current.distanceToSquared(next) <= Number.EPSILON) { + continue + } + + const incomingDirection = current.clone().sub(previous).normalize() + const outgoingDirection = next.clone().sub(current).normalize() + + if (incomingDirection.dot(outgoingDirection) >= STRAIGHT_PATH_DOT_THRESHOLD) { + continue + } + + simplifiedPoints.push(current.clone()) + } + + const finalPoint = points[points.length - 1] + const lastSimplifiedPoint = simplifiedPoints[simplifiedPoints.length - 1] + if ( + finalPoint && + (!lastSimplifiedPoint || lastSimplifiedPoint.distanceToSquared(finalPoint) > Number.EPSILON) + ) { + simplifiedPoints.push(finalPoint.clone()) + } + + return simplifiedPoints +} + +function isLineSegmentSupported( + start: Vector3, + end: Vector3, + isPointSupported?: (point: Vector3) => boolean, +) { + if (!isPointSupported) { + return true + } + + const segmentLength = start.distanceTo(end) + const sampleCount = Math.max(2, Math.ceil(segmentLength / PATH_SUPPORT_SAMPLE_STEP)) + const samplePoint = new Vector3() + + for (let sampleIndex = 0; sampleIndex <= sampleCount; sampleIndex += 1) { + samplePoint.lerpVectors(start, end, sampleIndex / sampleCount) + if (!isPointSupported(samplePoint)) { + return false + } + } + + return true +} + +function isCurveSegmentSupported( + curve: Curve, + isPointSupported?: (point: Vector3) => boolean, +) { + if (!isPointSupported) { + return true + } + + const sampleCount = Math.max(3, Math.ceil(curve.getLength() / PATH_SUPPORT_SAMPLE_STEP)) + const samplePoint = new Vector3() + + for (let sampleIndex = 0; sampleIndex <= sampleCount; sampleIndex += 1) { + curve.getPointAt(sampleIndex / sampleCount, samplePoint) + if (!isPointSupported(samplePoint)) { + return false + } + } + + return true +} + +function buildRoundedPathCurve(points: Vector3[], isPointSupported?: (point: Vector3) => boolean) { + if (points.length < 2) { + return null + } + + const curvePath = new CurvePath() + let currentPathPoint = points[0]?.clone() + + if (!currentPathPoint) { + return null + } + + const appendLineSegment = (start: Vector3, end: Vector3) => { + if (start.distanceToSquared(end) <= MIN_CURVE_SEGMENT_LENGTH * MIN_CURVE_SEGMENT_LENGTH) { + return true + } + + if (!isLineSegmentSupported(start, end, isPointSupported)) { + return false + } + + curvePath.add(new LineCurve3(start.clone(), end.clone())) + return true + } + + for (let index = 1; index < points.length - 1; index += 1) { + const previous = points[index - 1] + const corner = points[index] + const next = points[index + 1] + + if (!(previous && corner && next)) { + continue + } + + const incomingVector = corner.clone().sub(previous) + const outgoingVector = next.clone().sub(corner) + const incomingLength = incomingVector.length() + const outgoingLength = outgoingVector.length() + + if (incomingLength <= Number.EPSILON || outgoingLength <= Number.EPSILON) { + continue + } + + const incomingDirection = incomingVector.clone().divideScalar(incomingLength) + const outgoingDirection = outgoingVector.clone().divideScalar(outgoingLength) + const turnDot = MathUtils.clamp(incomingDirection.dot(outgoingDirection), -1, 1) + + if (turnDot >= STRAIGHT_PATH_DOT_THRESHOLD) { + if (!appendLineSegment(currentPathPoint, corner)) { + return null + } + currentPathPoint = corner.clone() + continue + } + + const turnAngle = Math.acos(turnDot) + const cornerRadius = Math.min( + PATH_MAX_CORNER_RADIUS, + incomingLength * 0.4, + outgoingLength * 0.4, + ) + + if (turnAngle <= 0.08 || cornerRadius < PATH_MIN_CORNER_RADIUS) { + if (!appendLineSegment(currentPathPoint, corner)) { + return null + } + currentPathPoint = corner.clone() + continue + } + + let appliedCurve = false + let candidateRadius = cornerRadius + + while (candidateRadius >= PATH_MIN_CORNER_RADIUS) { + const entryPoint = corner.clone().addScaledVector(incomingDirection, -candidateRadius) + const exitPoint = corner.clone().addScaledVector(outgoingDirection, candidateRadius) + const candidateCurve = new QuadraticBezierCurve3( + entryPoint.clone(), + corner.clone(), + exitPoint.clone(), + ) + + if ( + !isLineSegmentSupported(currentPathPoint, entryPoint, isPointSupported) || + !isCurveSegmentSupported(candidateCurve, isPointSupported) + ) { + candidateRadius *= 0.5 + continue + } + + if (!appendLineSegment(currentPathPoint, entryPoint)) { + return null + } + curvePath.add(candidateCurve) + currentPathPoint = exitPoint + appliedCurve = true + break + } + + if (!appliedCurve) { + if (!appendLineSegment(currentPathPoint, corner)) { + return null + } + currentPathPoint = corner.clone() + } + } + + const finalPoint = points[points.length - 1] + if (finalPoint && currentPathPoint) { + if (!appendLineSegment(currentPathPoint, finalPoint)) { + return null + } + } + + return curvePath.curves.length > 0 ? curvePath : null +} + +function buildPathCurve( + points: Vector3[], + doorTransitions: NavigationDoorTransition[], + isPointSupported?: (point: Vector3) => boolean, +) { + if (points.length < 2) { + return null + } + const curvePath = new CurvePath() + + const appendLineSegment = (start: Vector3, end: Vector3) => { + if (start.distanceToSquared(end) <= MIN_CURVE_SEGMENT_LENGTH * MIN_CURVE_SEGMENT_LENGTH) { + return true + } + + if (!isLineSegmentSupported(start, end, isPointSupported)) { + return false + } + + curvePath.add(new LineCurve3(start.clone(), end.clone())) + return true + } + + const appendSpan = (spanPoints: Vector3[]) => { + if (spanPoints.length < 2) { + return true + } + + const spanStart = spanPoints[0] + const spanEnd = spanPoints[spanPoints.length - 1] + if (!(spanStart && spanEnd)) { + return true + } + + if (spanPoints.length === 2) { + return appendLineSegment(spanStart, spanEnd) + } + + const spline = new CatmullRomCurve3( + spanPoints.map((point) => point.clone()), + false, + 'centripetal', + ) + if (isCurveSegmentSupported(spline, isPointSupported)) { + curvePath.add(spline) + return true + } + + const roundedSpanCurve = buildRoundedPathCurve(spanPoints, isPointSupported) + if (roundedSpanCurve) { + for (const curve of roundedSpanCurve.curves) { + curvePath.add(curve) + } + return true + } + + for (let index = 0; index < spanPoints.length - 1; index += 1) { + const start = spanPoints[index] + const end = spanPoints[index + 1] + if (!(start && end && appendLineSegment(start, end))) { + return false + } + } + + return true + } + + if (doorTransitions.length === 0) { + return appendSpan(points) && curvePath.curves.length > 0 ? curvePath : null + } + + const findPointIndex = (target: [number, number, number], startIndex: number): number | null => { + const targetKey = getNavigationPointKey(target) + for (let index = startIndex; index < points.length; index += 1) { + const point = points[index] + if (point && getNavigationPointKey(point) === targetKey) { + return index + } + } + return null + } + + const doorRuns: Array<{ endIndex: number; startIndex: number }> = [] + let searchIndex = 0 + for (const transition of doorTransitions) { + const approachIndex = findPointIndex(transition.approachWorld, searchIndex) + if (approachIndex === null) { + continue + } + + const entryIndex = findPointIndex(transition.entryWorld, approachIndex) + const worldIndex = entryIndex === null ? null : findPointIndex(transition.world, entryIndex) + const exitIndex = worldIndex === null ? null : findPointIndex(transition.exitWorld, worldIndex) + const departureIndex = + exitIndex === null ? null : findPointIndex(transition.departureWorld, exitIndex) + + if ( + entryIndex === null || + worldIndex === null || + exitIndex === null || + departureIndex === null + ) { + continue + } + + doorRuns.push({ + endIndex: departureIndex, + startIndex: approachIndex, + }) + searchIndex = departureIndex + } + + if (doorRuns.length === 0) { + return appendSpan(points) && curvePath.curves.length > 0 ? curvePath : null + } + + let cursor = 0 + + for (const doorRun of doorRuns) { + const spanStartIndex = Math.max(cursor, doorRun.startIndex - 1) + if (spanStartIndex > cursor) { + const leadingSpanPoints = points.slice(cursor, spanStartIndex + 1) + if (!appendSpan(leadingSpanPoints)) { + return null + } + } + + const spanEndIndex = Math.min(points.length - 1, doorRun.endIndex + 1) + const doorSpanPoints = points.slice(spanStartIndex, spanEndIndex + 1) + if (!appendSpan(doorSpanPoints)) { + return null + } + + cursor = spanEndIndex + } + + if (cursor < points.length - 1) { + const trailingSpanPoints = points.slice(cursor) + if (!appendSpan(trailingSpanPoints)) { + return null + } + } + + return curvePath.curves.length > 0 ? curvePath : null +} + +function buildPolylineCurve(points: Vector3[]) { + if (points.length < 2) { + return null + } + + const curvePath = new CurvePath() + + for (let index = 0; index < points.length - 1; index += 1) { + const start = points[index] + const end = points[index + 1] + + if (!(start && end)) { + continue + } + + if (start.distanceToSquared(end) <= MIN_CURVE_SEGMENT_LENGTH * MIN_CURVE_SEGMENT_LENGTH) { + continue + } + + curvePath.add(new LineCurve3(start.clone(), end.clone())) + } + + return curvePath.curves.length > 0 ? curvePath : null +} + +function estimateCurveRadiusAtDistance( + curve: Curve, + totalLength: number, + distance: number, +) { + if (totalLength <= Number.EPSILON) { + return Number.POSITIVE_INFINITY + } + + const sampleStart = Math.max(0, distance - TRAJECTORY_CURVATURE_WINDOW_DISTANCE) + const sampleEnd = Math.min(totalLength, distance + TRAJECTORY_CURVATURE_WINDOW_DISTANCE) + const sampleSpan = sampleEnd - sampleStart + if (sampleSpan <= Number.EPSILON) { + return Number.POSITIVE_INFINITY + } + + const startT = MathUtils.clamp(sampleStart / totalLength, 0, 1) + const endT = MathUtils.clamp(sampleEnd / totalLength, 0, 1) + const startTangent = curve.getTangentAt(startT, new Vector3()).normalize() + const endTangent = curve.getTangentAt(endT, new Vector3()).normalize() + const turnAngle = Math.acos(MathUtils.clamp(startTangent.dot(endTangent), -1, 1)) + + if (turnAngle <= 1e-4) { + return Number.POSITIVE_INFINITY + } + + return sampleSpan / turnAngle +} + +function buildTrajectoryMotionProfile( + curve: Curve | null, + totalLength: number, +): TrajectoryMotionProfile | null { + if (!(curve && totalLength > Number.EPSILON)) { + return null + } + + const intervalCount = Math.max(1, Math.ceil(totalLength / TRAJECTORY_CURVATURE_SAMPLE_STEP)) + const intervalLength = totalLength / intervalCount + const sections: TrajectoryCurvatureSection[] = [] + + for (let intervalIndex = 0; intervalIndex < intervalCount; intervalIndex += 1) { + const startDistance = intervalIndex * intervalLength + const endDistance = + intervalIndex === intervalCount - 1 ? totalLength : (intervalIndex + 1) * intervalLength + const midpointDistance = (startDistance + endDistance) * 0.5 + const radius = estimateCurveRadiusAtDistance(curve, totalLength, midpointDistance) + const kind: TrajectoryCurvatureSectionKind = + radius < TRAJECTORY_SMALL_RADIUS_THRESHOLD ? 'high' : 'low' + const previousSection = sections[sections.length - 1] + + if (previousSection?.kind === kind) { + previousSection.endDistance = endDistance + previousSection.minRadius = Math.min(previousSection.minRadius, radius) + continue + } + + sections.push({ + endDistance, + kind, + minRadius: radius, + startDistance, + }) + } + + return { + sections, + totalLength, + } +} + +function getTrajectoryMotionState( + profile: TrajectoryMotionProfile | null, + distance: number, +): TrajectoryMotionState { + if (!(profile && profile.sections.length > 0)) { + return { + runBlend: 0, + section: null, + sectionKind: 'high', + } + } + + const clampedDistance = MathUtils.clamp(distance, 0, profile.totalLength) + const section = + profile.sections.find( + (candidate) => + clampedDistance >= candidate.startDistance && clampedDistance <= candidate.endDistance, + ) ?? profile.sections[profile.sections.length - 1]! + + if (section.kind === 'high') { + return { + runBlend: 0, + section, + sectionKind: section.kind, + } + } + + const sectionLength = section.endDistance - section.startDistance + if (sectionLength < TRAJECTORY_RUN_MIN_SECTION_LENGTH) { + return { + runBlend: 0, + section, + sectionKind: section.kind, + } + } + + const distanceSinceStart = clampedDistance - section.startDistance + const distanceUntilEnd = section.endDistance - clampedDistance + const accelerationBlend = smoothstep01(distanceSinceStart / TRAJECTORY_RUN_ACCELERATION_DISTANCE) + const lookaheadBlend = smoothstep01( + (distanceUntilEnd - + (TRAJECTORY_RUN_LOOKAHEAD_DISTANCE - TRAJECTORY_RUN_DECELERATION_DISTANCE)) / + TRAJECTORY_RUN_DECELERATION_DISTANCE, + ) + + return { + runBlend: Math.min(accelerationBlend, lookaheadBlend), + section, + sectionKind: section.kind, + } +} + +function createActorLocomotionState( + sectionKind: TrajectoryCurvatureSectionKind = 'high', +): ActorLocomotionState { + return { + moveBlend: 0, + runBlend: 0, + runTimeScale: ACTOR_RUN_ANIMATION_SPEED_SCALE, + sectionKind, + walkTimeScale: ACTOR_WALK_ANIMATION_SPEED_SCALE, + } +} + +function createActorMotionState(): ActorMotionState { + return { + debugTransitionPreview: null, + destinationCellIndex: null, + distance: 0, + forcedClip: null, + locomotion: createActorLocomotionState(), + moving: false, + rootMotionOffset: [0, 0, 0], + speed: 0, + visibilityRevealProgress: null, + } +} + +function getRandomItemMoveGesture(): NavigationItemMoveGesture { + const randomIndex = Math.floor(Math.random() * ITEM_MOVE_GESTURE_CLIP_OPTIONS.length) + return ITEM_MOVE_GESTURE_CLIP_OPTIONS[randomIndex] ?? ITEM_MOVE_GESTURE_CLIP_OPTIONS[0] +} + +function getItemInteractionGestureDurationMs(gesture: NavigationItemMoveGesture) { + return gesture.durationSeconds * 1000 * ITEM_INTERACTION_GESTURE_DURATION_SCALE +} + +function getNavigationItemMoveVisualItemId(request: NavigationItemMoveRequest) { + return request.visualItemId ?? request.itemId +} + +function getNavigationItemMoveCommitTargetId(request: NavigationItemMoveRequest) { + if (isNavigationCopyItemMoveRequest(request)) { + return request.targetPreviewItemId ?? request.visualItemId ?? request.itemId + } + + return request.itemId +} + +function shouldDelayPickupCarryUntilCheckoutComplete(request: NavigationItemMoveRequest) { + return true +} + +function getNavigationItemMovePickupSourceVisualState( + request: NavigationItemMoveRequest, +): ItemMoveVisualState { + return isNavigationCopyItemMoveRequest(request) ? 'copy-source-pending' : 'source-pending' +} + +function markNavigationItemMovePickupSourcePending(request: NavigationItemMoveRequest) { + navigationVisualsStore + .getState() + .setItemMoveVisualState(request.itemId, getNavigationItemMovePickupSourceVisualState(request)) +} + +function clearNavigationItemMovePickupSourcePending(request: NavigationItemMoveRequest) { + const navigationVisuals = navigationVisualsStore.getState() + const sourceState = navigationVisuals.itemMoveVisualStates[request.itemId] ?? null + if (sourceState === 'copy-source-pending' || sourceState === 'source-pending') { + navigationVisuals.setItemMoveVisualState(request.itemId, null) + } +} + +function createNavigationItemMoveFallbackController( + request: NavigationItemMoveRequest, +): NavigationItemMoveController { + const visualItemId = getNavigationItemMoveVisualItemId(request) + + return { + itemId: request.itemId, + beginCarry: () => { + if (isNavigationCopyItemMoveRequest(request)) { + const sourceRotationY = request.sourceRotation[1] ?? 0 + useLiveTransforms.getState().set(visualItemId, { + position: [...request.sourcePosition] as [number, number, number], + rotation: sourceRotationY, + }) + appendTaskModeTrace('navigation.copyCarrySeededFromSource', { + itemId: request.itemId, + sourcePosition: request.sourcePosition, + sourceRotationY, + visualItemId, + }) + } + navigationVisualsStore.getState().setItemMoveVisualState(visualItemId, 'carried') + }, + cancel: () => { + navigationVisualsStore.getState().setItemMoveVisualState(visualItemId, null) + navigationVisualsStore.getState().setNodeVisibilityOverride(visualItemId, null) + useLiveTransforms.getState().clear(visualItemId) + }, + commit: (finalUpdate, finalCarryTransform) => { + const sceneState = useScene.getState() + const viewerState = useViewer.getState() + const commitTargetId = getNavigationItemMoveCommitTargetId(request) + const commitTargetNode = sceneState.nodes[commitTargetId as AnyNodeId] + + if (commitTargetNode?.type === 'item') { + sceneState.updateNode(commitTargetId as AnyNodeId, { + ...finalUpdate, + metadata: stripTransient(commitTargetNode.metadata) as ItemNode['metadata'], + visible: true, + }) + } else if (request.itemId !== commitTargetId) { + return + } else { + const sourceNode = sceneState.nodes[request.itemId as AnyNodeId] + if (sourceNode?.type !== 'item') { + return + } + + sceneState.updateNode(request.itemId as AnyNodeId, { + ...finalUpdate, + metadata: stripTransient(sourceNode.metadata) as ItemNode['metadata'], + visible: true, + }) + } + + if (finalCarryTransform) { + useLiveTransforms.getState().set(visualItemId, finalCarryTransform) + } + navigationVisualsStore.getState().setItemMoveVisualState(visualItemId, null) + navigationVisualsStore.getState().setNodeVisibilityOverride(visualItemId, null) + useLiveTransforms.getState().clear(visualItemId) + clearRuntimeItemMoveVisualState(visualItemId) + }, + updateCarryTransform: (position, rotationY) => { + useLiveTransforms.getState().set(visualItemId, { + position, + rotation: rotationY, + }) + }, + } +} + +function clearRuntimeItemMoveVisualState(itemId: string | null | undefined) { + if (!itemId) { + return + } + + navigationVisualsStore.getState().setItemMoveVisualState(itemId, null) +} + +function setRuntimeItemMoveVisualState( + itemId: string | null | undefined, + state: ItemMoveVisualState | null, +) { + if (!itemId) { + return + } + + navigationVisualsStore.getState().setItemMoveVisualState(itemId, state) +} + +function isNavigationTaskPreviewNodeId(itemId: string | null | undefined) { + if (!itemId) { + return false + } + + const taskPreviewNodeIds = navigationVisualsStore.getState().taskPreviewNodeIds + return ( + taskPreviewNodeIds[itemId] === true || + itemId.startsWith('item_debug_move_preview_') || + itemId.startsWith('item_debug_copy_preview_') + ) +} + +function registerNavigationTaskPreviewNode(itemId: string | null | undefined) { + if (!itemId) { + return + } + + navigationVisualsStore.getState().registerTaskPreviewNode(itemId) +} + +function unregisterNavigationTaskPreviewNode(itemId: string | null | undefined) { + if (!itemId) { + return + } + + navigationVisualsStore.getState().unregisterTaskPreviewNode(itemId) +} + +function removeTransientNavigationPreviewNode(itemId: string | null | undefined) { + if (!itemId) { + return + } + + const node = useScene.getState().nodes[itemId as AnyNode['id']] + if (node?.type !== 'item') { + return + } + + if (!isNavigationTaskPreviewNodeId(itemId)) { + return + } + + appendTaskModeTrace('navigation.removeTransientPreviewNode', { + itemId, + }) + useScene.getState().deleteNode(itemId as AnyNode['id']) + unregisterNavigationTaskPreviewNode(itemId) +} + +function ensureQueuedNavigationMoveGhostNode(request: NavigationItemMoveRequest) { + const previewId = request.targetPreviewItemId + const targetPosition = request.finalUpdate.position + if (!previewId || !targetPosition) { + appendTaskModeTrace('navigation.ensureQueuedGhostSkipped', { + itemId: request.itemId, + previewId: previewId ?? null, + reason: !previewId ? 'missing-preview-id' : 'missing-target-position', + }) + return null + } + + const sceneState = useScene.getState() + const sourceNode = sceneState.nodes[request.itemId as AnyNode['id']] + if (sourceNode?.type !== 'item') { + appendTaskModeTrace('navigation.ensureQueuedGhostSkipped', { + itemId: request.itemId, + previewId, + reason: 'missing-source-node', + }) + return null + } + + const targetRotation = request.finalUpdate.rotation ?? request.sourceRotation + const targetParentId = + (typeof request.finalUpdate.parentId === 'string' + ? request.finalUpdate.parentId + : (request.levelId ?? sourceNode.parentId)) ?? null + if (!targetParentId) { + appendTaskModeTrace('navigation.ensureQueuedGhostSkipped', { + itemId: request.itemId, + previewId, + reason: 'missing-target-parent', + }) + return null + } + + const previewMetadata = stripTransient(sourceNode.metadata) as ItemNode['metadata'] + registerNavigationTaskPreviewNode(previewId) + const existingPreviewNode = sceneState.nodes[previewId as AnyNode['id']] + if (existingPreviewNode?.type === 'item') { + sceneState.updateNode(previewId as AnyNode['id'], { + metadata: previewMetadata, + parentId: targetParentId, + position: [...targetPosition] as [number, number, number], + rotation: [...targetRotation] as [number, number, number], + side: sourceNode.side, + visible: true, + }) + appendTaskModeTrace('navigation.ensureQueuedGhostUpdated', { + itemId: request.itemId, + previewId, + targetParentId, + }) + return previewId + } + + const previewNode = ItemNode.parse({ + asset: sourceNode.asset, + id: previewId, + metadata: previewMetadata, + name: sourceNode.name, + parentId: targetParentId, + position: [...targetPosition] as [number, number, number], + rotation: [...targetRotation] as [number, number, number], + scale: [...sourceNode.scale] as [number, number, number], + side: sourceNode.side, + visible: true, + }) + + sceneState.createNode(previewNode, targetParentId as AnyNodeId) + appendTaskModeTrace('navigation.ensureQueuedGhostCreated', { + itemId: request.itemId, + previewId, + targetParentId, + }) + return previewId +} + +function TaskQueueSourceMarker({ marker }: { marker: TaskQueueSourceMarkerSpec }) { + const [fallbackWidth, fallbackHeight, fallbackDepth] = marker.dimensions + const shieldText = useLoader( + FileLoader, + TASK_SOURCE_SHIELD_MESH_URL, + configureTaskSourceShieldTextLoader, + ) as string + const { shieldEdgeObject, shieldFaceObject } = useMemo( + () => ({ + shieldEdgeObject: new OBJLoader().parse( + stripTaskSourceShieldFaceRecords(shieldText), + ) as Group, + shieldFaceObject: new OBJLoader().parse( + stripTaskSourceShieldLineRecords(shieldText), + ) as Group, + }), + [shieldText], + ) + const fadeStartedAtMsRef = useRef(null) + const primaryShieldGroupRef = useRef(null) + const secondaryShieldGroupRef = useRef(null) + const lineMaterial = useMemo(() => { + return new LineBasicMaterial({ + color: new Color(marker.color).multiplyScalar(TASK_SOURCE_SHIELD_EDGE_COLOR_MULTIPLIER), + depthTest: true, + depthWrite: false, + opacity: 0, + toneMapped: false, + transparent: true, + }) + }, [marker.color]) + const meshMaterial = useMemo(() => { + return new MeshBasicMaterial({ + color: marker.color, + polygonOffset: true, + polygonOffsetFactor: 1, + polygonOffsetUnits: 1, + opacity: 0, + side: DoubleSide, + toneMapped: false, + transparent: true, + }) + }, [marker.color]) + const { + baseRadius, + primaryShieldModel, + secondaryShieldModel, + shieldCenter, + shieldHeight, + targetRadius, + } = useMemo(() => { + const boundsSource = shieldFaceObject.clone(true) as Group + const bounds = new Box3().setFromObject(boundsSource) + const center = bounds.getCenter(new Vector3()) + const size = bounds.getSize(new Vector3()) + const fittedRadius = Math.max(fallbackWidth, fallbackHeight, fallbackDepth) / 2 + const fittedCenter = new Vector3(0, fallbackHeight / 2, 0) + + const materializeShieldModel = () => { + const clone = new Group() + const faceClone = shieldFaceObject.clone(true) as Group + faceClone.position.sub(center) + faceClone.traverse((child) => { + if (!(child as Mesh).isMesh) { + return + } + + const mesh = child as Mesh + mesh.castShadow = false + mesh.frustumCulled = false + mesh.material = meshMaterial + mesh.receiveShadow = false + mesh.renderOrder = 3 + mesh.userData.pascalExcludeFromOutline = true + }) + clone.add(faceClone) + + const edgeClone = shieldEdgeObject.clone(true) as Group + edgeClone.position.sub(center) + edgeClone.traverse((child) => { + const lineChild = child as typeof child & { + isLine?: boolean + isLineLoop?: boolean + isLineSegments?: boolean + material?: unknown + } + if (!(lineChild.isLine || lineChild.isLineLoop || lineChild.isLineSegments)) { + return + } + + child.frustumCulled = false + child.renderOrder = 4 + child.userData.pascalExcludeFromOutline = true + lineChild.material = lineMaterial + }) + clone.add(edgeClone) + + return clone + } + + return { + baseRadius: Math.max(size.x, size.y, size.z) / 2, + primaryShieldModel: materializeShieldModel(), + secondaryShieldModel: materializeShieldModel(), + shieldCenter: [fittedCenter.x, fittedCenter.y, fittedCenter.z] as [number, number, number], + shieldHeight: size.y, + targetRadius: fittedRadius * TASK_SOURCE_SHIELD_SCALE_MULTIPLIER, + } + }, [ + fallbackDepth, + fallbackHeight, + fallbackWidth, + lineMaterial, + meshMaterial, + shieldEdgeObject, + shieldFaceObject, + ]) + + useEffect(() => { + fadeStartedAtMsRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now() + }, [marker.taskId]) + + useEffect(() => { + return () => { + lineMaterial.dispose() + meshMaterial.dispose() + } + }, [lineMaterial, meshMaterial]) + + useFrame((_, delta) => { + const fadeStartedAtMs = fadeStartedAtMsRef.current + if (fadeStartedAtMs === null) { + return + } + + const fadeProgress = MathUtils.clamp( + ((typeof performance !== 'undefined' ? performance.now() : Date.now()) - fadeStartedAtMs) / + TASK_SOURCE_SHIELD_FADE_IN_MS, + 0, + 1, + ) + const nextVisibility = 1 - (1 - fadeProgress) ** 2 + lineMaterial.opacity = TASK_SOURCE_SHIELD_OPACITY * marker.opacity * nextVisibility + meshMaterial.opacity = TASK_SOURCE_SHIELD_OPACITY * marker.opacity * nextVisibility + + if (primaryShieldGroupRef.current) { + primaryShieldGroupRef.current.rotation.y += delta * TASK_SOURCE_SHIELD_SPIN_SPEED + } + + if (secondaryShieldGroupRef.current) { + secondaryShieldGroupRef.current.rotation.y -= delta * TASK_SOURCE_SHIELD_SPIN_SPEED + } + }) + + const shieldScale = baseRadius > Number.EPSILON ? targetRadius / baseRadius : 1 + const primaryShieldScale: [number, number, number] = [ + shieldScale * 1.1, + shieldScale, + shieldScale * 1.1, + ] + const secondaryShieldScale: [number, number, number] = [ + shieldScale * 1.1 * TASK_SOURCE_SHIELD_SECONDARY_SCALE_MULTIPLIER, + shieldScale * TASK_SOURCE_SHIELD_SECONDARY_SCALE_MULTIPLIER, + shieldScale * 1.1 * TASK_SOURCE_SHIELD_SECONDARY_SCALE_MULTIPLIER, + ] + const primaryShieldYOffset = + shieldHeight * shieldScale * TASK_SOURCE_SHIELD_VERTICAL_OFFSET_MULTIPLIER + const secondaryShieldYOffset = + shieldHeight * + shieldScale * + TASK_SOURCE_SHIELD_SECONDARY_SCALE_MULTIPLIER * + TASK_SOURCE_SHIELD_VERTICAL_OFFSET_MULTIPLIER + + return ( + + + + + + + + + ) +} + +function hasSupportedNavigationSegment( + graph: NavigationGraph, + startPoint: [number, number, number], + endPoint: [number, number, number], + componentId: number | null, +) { + const distance = Math.hypot( + endPoint[0] - startPoint[0], + endPoint[1] - startPoint[1], + endPoint[2] - startPoint[2], + ) + const sampleCount = Math.max(2, Math.ceil(distance / Math.max(graph.cellSize * 0.45, 0.08))) + + for (let sampleIndex = 0; sampleIndex <= sampleCount; sampleIndex += 1) { + const t = sampleIndex / sampleCount + const samplePoint: [number, number, number] = [ + MathUtils.lerp(startPoint[0], endPoint[0], t), + MathUtils.lerp(startPoint[1], endPoint[1], t), + MathUtils.lerp(startPoint[2], endPoint[2], t), + ] + + if (!isNavigationPointSupported(graph, samplePoint, componentId)) { + return false + } + } + + return true +} + +function createNavigationItemMovePlanCacheKey( + request: NavigationItemMoveRequest, + actorStartCellIndex: number, + graphSnapshotKey: string | null, + buildingId: string | null, +) { + return JSON.stringify({ + actorStartCellIndex, + buildingId, + graphSnapshotKey, + itemId: request.itemId, + sourcePosition: request.sourcePosition, + sourceRotation: request.sourceRotation, + targetPosition: request.finalUpdate.position ?? null, + targetPreviewItemId: request.targetPreviewItemId ?? null, + targetRotation: request.finalUpdate.rotation ?? null, + visualItemId: request.visualItemId ?? null, + }) +} + +function findClosestSupportedNavigationCell( + graph: NavigationGraph, + point: [number, number, number], + preferredLevelId?: LevelNode['id'] | null, + componentId?: number | null, +) { + return measureNavigationPerf('navigation.findClosestSupportedCellMs', () => { + const fallbackCellIndex = findClosestNavigationCell(graph, point, preferredLevelId, componentId) + const targetLevelId = preferredLevelId ?? null + const targetComponentId = componentId ?? null + const [x, y, z] = point + const gridX = Math.round((x - graph.cellSize / 2) / graph.cellSize) + const gridY = Math.round((z - graph.cellSize / 2) / graph.cellSize) + let bestCellIndex: number | null = null + let bestDistanceSquared = Number.POSITIVE_INFINITY + + const updateBestCell = (cellIndex: number | null | undefined) => { + if (cellIndex === null || cellIndex === undefined) { + return false + } + + const cell = graph.cells[cellIndex] + if (!cell) { + return false + } + + if (targetLevelId && cell.levelId !== targetLevelId) { + return false + } + + const candidateComponentId = graph.componentIdByCell[cell.cellIndex] ?? null + if ( + targetComponentId !== null && + targetComponentId !== undefined && + candidateComponentId !== targetComponentId + ) { + return false + } + + if (!hasSupportedNavigationSegment(graph, cell.center, point, candidateComponentId)) { + return false + } + + const dx = cell.center[0] - x + const dy = (cell.center[1] - y) * 1.5 + const dz = cell.center[2] - z + const distanceSquared = dx * dx + dy * dy + dz * dz + if (distanceSquared < bestDistanceSquared) { + bestDistanceSquared = distanceSquared + bestCellIndex = cell.cellIndex + return true + } + + return false + } + + if (updateBestCell(fallbackCellIndex)) { + return bestCellIndex + } + + const seenCellIndices = new Set() + if (fallbackCellIndex !== null) { + seenCellIndices.add(fallbackCellIndex) + } + + const nearbySearchRadiusCells = 4 + for (let radius = 0; radius <= nearbySearchRadiusCells; radius += 1) { + for (let offsetX = -radius; offsetX <= radius; offsetX += 1) { + for (let offsetY = -radius; offsetY <= radius; offsetY += 1) { + if (Math.max(Math.abs(offsetX), Math.abs(offsetY)) !== radius) { + continue + } + + const candidateIndices = graph.cellIndicesByKey.get( + `${gridX + offsetX},${gridY + offsetY}`, + ) + if (!candidateIndices) { + continue + } + + for (const candidateIndex of candidateIndices) { + if (seenCellIndices.has(candidateIndex)) { + continue + } + + seenCellIndices.add(candidateIndex) + updateBestCell(candidateIndex) + } + } + } + + if (bestCellIndex !== null) { + return bestCellIndex + } + } + + return fallbackCellIndex + }) +} + +function findClosestCurveProgress(curve: Curve, target: Vector3, sampleCount: number) { + const samplePoint = new Vector3() + let closestT = 0 + let closestDistanceSq = Number.POSITIVE_INFINITY + + for (let sampleIndex = 0; sampleIndex <= sampleCount; sampleIndex += 1) { + const t = sampleCount <= 0 ? 0 : sampleIndex / sampleCount + curve.getPointAt(t, samplePoint) + const distanceSq = samplePoint.distanceToSquared(target) + if (distanceSq < closestDistanceSq) { + closestDistanceSq = distanceSq + closestT = t + } + } + + return closestT +} + +function buildOrbitPathCurve(baseCurve: Curve, sampleCount: number, phaseOffset: number) { + const orbitPoints: Vector3[] = [] + const worldUp = new Vector3(0, 1, 0) + const fallbackSide = new Vector3(1, 0, 0) + + for (let sampleIndex = 0; sampleIndex <= sampleCount; sampleIndex += 1) { + const t = sampleCount > 0 ? sampleIndex / sampleCount : 0 + const point = baseCurve.getPointAt(t, new Vector3()) + const tangent = baseCurve.getTangentAt(Math.min(0.999, t + 0.0001), new Vector3()).normalize() + const side = new Vector3().crossVectors(worldUp, tangent) + + if (side.lengthSq() <= Number.EPSILON) { + side.copy(fallbackSide) + } else { + side.normalize() + fallbackSide.copy(side) + } + + const normal = new Vector3().crossVectors(tangent, side).normalize() + const waveAngle = t * Math.PI * 2 * PATH_RENDER_ORBIT_WAVE_COUNT + phaseOffset + const offset = side + .clone() + .multiplyScalar(Math.cos(waveAngle) * PATH_RENDER_ORBIT_OFFSET) + .add( + normal + .clone() + .multiplyScalar( + Math.sin(waveAngle) * PATH_RENDER_ORBIT_OFFSET * PATH_RENDER_ORBIT_VERTICAL_SCALE, + ), + ) + + orbitPoints.push(point.add(offset)) + } + + return orbitPoints.length >= 2 ? new CatmullRomCurve3(orbitPoints, false, 'centripetal') : null +} + +function buildRibbonPathGeometry( + curve: Curve, + segmentCount: number, + width: number, + twistOffset = 0, +) { + if (segmentCount < 1 || width <= Number.EPSILON) { + return null + } + + const geometry = new BufferGeometry() + const positions: number[] = [] + const uvs: number[] = [] + const indices: number[] = [] + const halfWidth = width * 0.5 + const worldUp = new Vector3(0, 1, 0) + const fallbackSide = new Vector3(1, 0, 0) + const point = new Vector3() + const tangent = new Vector3() + const side = new Vector3() + const normal = new Vector3() + const ribbonAxis = new Vector3() + + for (let sampleIndex = 0; sampleIndex <= segmentCount; sampleIndex += 1) { + const t = segmentCount > 0 ? sampleIndex / segmentCount : 0 + curve.getPointAt(t, point) + curve.getTangentAt(Math.min(0.999, t + 0.0001), tangent).normalize() + side.crossVectors(worldUp, tangent) + + if (side.lengthSq() <= Number.EPSILON) { + side.copy(fallbackSide) + } else { + side.normalize() + fallbackSide.copy(side) + } + + normal.crossVectors(tangent, side).normalize() + + const twistAngle = t * Math.PI * 2 * PATH_RENDER_ORBIT_RIBBON_TWIST_COUNT + twistOffset + ribbonAxis + .copy(side) + .multiplyScalar(Math.cos(twistAngle)) + .addScaledVector(normal, Math.sin(twistAngle)) + .normalize() + + positions.push( + point.x + ribbonAxis.x * halfWidth, + point.y + ribbonAxis.y * halfWidth, + point.z + ribbonAxis.z * halfWidth, + point.x - ribbonAxis.x * halfWidth, + point.y - ribbonAxis.y * halfWidth, + point.z - ribbonAxis.z * halfWidth, + ) + uvs.push(t, 0, t, 1) + + if (sampleIndex >= segmentCount) { + continue + } + + const baseIndex = sampleIndex * 2 + indices.push( + baseIndex, + baseIndex + 1, + baseIndex + 2, + baseIndex + 1, + baseIndex + 3, + baseIndex + 2, + ) + } + + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) + geometry.setIndex(indices) + geometry.computeBoundingSphere() + return geometry +} + +function buildFlatPathRibbonGeometry(curve: Curve, segmentCount: number, width: number) { + if (segmentCount < 1 || width <= Number.EPSILON) { + return null + } + + const geometry = new BufferGeometry() + const positions: number[] = [] + const uvs: number[] = [] + const indices: number[] = [] + const halfWidth = width * 0.5 + const worldUp = new Vector3(0, 1, 0) + const fallbackSide = new Vector3(1, 0, 0) + const point = new Vector3() + const tangent = new Vector3() + const side = new Vector3() + + for (let sampleIndex = 0; sampleIndex <= segmentCount; sampleIndex += 1) { + const t = segmentCount > 0 ? sampleIndex / segmentCount : 0 + curve.getPointAt(t, point) + curve.getTangentAt(Math.min(0.999, t + 0.0001), tangent).normalize() + side.crossVectors(worldUp, tangent) + + if (side.lengthSq() <= Number.EPSILON) { + side.copy(fallbackSide) + } else { + side.normalize() + fallbackSide.copy(side) + } + + positions.push( + point.x + side.x * halfWidth, + point.y, + point.z + side.z * halfWidth, + point.x - side.x * halfWidth, + point.y, + point.z - side.z * halfWidth, + ) + uvs.push(t, 0, t, 1) + + if (sampleIndex >= segmentCount) { + continue + } + + const baseIndex = sampleIndex * 2 + indices.push( + baseIndex, + baseIndex + 1, + baseIndex + 2, + baseIndex + 1, + baseIndex + 3, + baseIndex + 2, + ) + } + + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) + geometry.setIndex(indices) + geometry.computeBoundingSphere() + return geometry +} + +function populateOrbitRibbonGeometry( + geometry: BufferGeometry, + baseCurve: Curve, + segmentCount: number, + width: number, + orbitPhase: number, + visualState?: OrbitRibbonVisualState, +) { + if (segmentCount < 1 || width <= Number.EPSILON) { + return false + } + + const vertexCount = (segmentCount + 1) * 2 + const positionCount = vertexCount * 3 + const uvCount = vertexCount * 2 + const colorCount = vertexCount * 3 + const indexCount = segmentCount * 6 + const halfWidth = width * 0.5 + const worldUp = new Vector3(0, 1, 0) + const fallbackSide = new Vector3(1, 0, 0) + const point = new Vector3() + const tangent = new Vector3() + const side = new Vector3() + const normal = new Vector3() + const offsetPoint = new Vector3() + const ribbonAxis = new Vector3() + + const currentPositionAttribute = geometry.getAttribute('position') + const positionAttribute = + currentPositionAttribute instanceof Float32BufferAttribute && + currentPositionAttribute.array.length === positionCount + ? currentPositionAttribute + : new Float32BufferAttribute(new Float32Array(positionCount), 3) + const currentUvAttribute = geometry.getAttribute('uv') + const uvAttribute = + currentUvAttribute instanceof Float32BufferAttribute && + currentUvAttribute.array.length === uvCount + ? currentUvAttribute + : new Float32BufferAttribute(new Float32Array(uvCount), 2) + const currentColorAttribute = geometry.getAttribute('color') + const colorAttribute = + currentColorAttribute instanceof Float32BufferAttribute && + currentColorAttribute.array.length === colorCount + ? currentColorAttribute + : new Float32BufferAttribute(new Float32Array(colorCount), 3) + const positions = positionAttribute.array as Float32Array + const uvs = uvAttribute.array as Float32Array + const colors = colorAttribute.array as Float32Array + + for (let sampleIndex = 0; sampleIndex <= segmentCount; sampleIndex += 1) { + const t = segmentCount > 0 ? sampleIndex / segmentCount : 0 + baseCurve.getPointAt(t, point) + baseCurve.getTangentAt(Math.min(0.999, t + 0.0001), tangent).normalize() + side.crossVectors(worldUp, tangent) + + if (side.lengthSq() <= Number.EPSILON) { + side.copy(fallbackSide) + } else { + side.normalize() + fallbackSide.copy(side) + } + + normal.crossVectors(tangent, side).normalize() + + const waveAngle = t * Math.PI * 2 * PATH_RENDER_ORBIT_WAVE_COUNT + orbitPhase + offsetPoint + .copy(point) + .addScaledVector(side, Math.cos(waveAngle) * PATH_RENDER_ORBIT_OFFSET) + .addScaledVector( + normal, + Math.sin(waveAngle) * PATH_RENDER_ORBIT_OFFSET * PATH_RENDER_ORBIT_VERTICAL_SCALE, + ) + + const twistAngle = t * Math.PI * 2 * PATH_RENDER_ORBIT_RIBBON_TWIST_COUNT + orbitPhase + ribbonAxis + .copy(side) + .multiplyScalar(Math.cos(twistAngle)) + .addScaledVector(normal, Math.sin(twistAngle)) + .normalize() + + const positionOffset = sampleIndex * 6 + positions[positionOffset] = offsetPoint.x + ribbonAxis.x * halfWidth + positions[positionOffset + 1] = offsetPoint.y + ribbonAxis.y * halfWidth + positions[positionOffset + 2] = offsetPoint.z + ribbonAxis.z * halfWidth + positions[positionOffset + 3] = offsetPoint.x - ribbonAxis.x * halfWidth + positions[positionOffset + 4] = offsetPoint.y - ribbonAxis.y * halfWidth + positions[positionOffset + 5] = offsetPoint.z - ribbonAxis.z * halfWidth + + const uvOffset = sampleIndex * 4 + uvs[uvOffset] = t + uvs[uvOffset + 1] = 0 + uvs[uvOffset + 2] = t + uvs[uvOffset + 3] = 1 + + const alphaWave = + visualState === undefined + ? 0 + : MathUtils.lerp( + visualState.alphaMin, + visualState.alphaMax, + 0.5 + + 0.5 * + Math.sin( + t * Math.PI * 2 * visualState.alphaWaveCount - + visualState.time * visualState.alphaWaveSpeed + + visualState.alphaPhase, + ), + ) + const brightness = visualState === undefined ? 0 : MathUtils.clamp(alphaWave, 0, 1) + const colorOffset = sampleIndex * 6 + colors[colorOffset] = brightness + colors[colorOffset + 1] = brightness + colors[colorOffset + 2] = brightness + colors[colorOffset + 3] = brightness + colors[colorOffset + 4] = brightness + colors[colorOffset + 5] = brightness + } + + if (geometry.index?.count !== indexCount) { + const indices: number[] = [] + for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) { + const baseIndex = segmentIndex * 2 + indices.push( + baseIndex, + baseIndex + 1, + baseIndex + 2, + baseIndex + 1, + baseIndex + 3, + baseIndex + 2, + ) + } + geometry.setIndex(indices) + } + + if (geometry.getAttribute('position') !== positionAttribute) { + geometry.setAttribute('position', positionAttribute) + } + if (geometry.getAttribute('uv') !== uvAttribute) { + geometry.setAttribute('uv', uvAttribute) + } + if (geometry.getAttribute('color') !== colorAttribute) { + geometry.setAttribute('color', colorAttribute) + } + + positionAttribute.needsUpdate = true + uvAttribute.needsUpdate = true + colorAttribute.needsUpdate = true + geometry.computeBoundingSphere() + return true +} + +function buildOrbitRibbonGeometry( + baseCurve: Curve, + segmentCount: number, + width: number, + orbitPhase: number, +) { + const geometry = new BufferGeometry() + return populateOrbitRibbonGeometry(geometry, baseCurve, segmentCount, width, orbitPhase) + ? geometry + : null +} + +function buildPathRenderSegments( + baseCurve: Curve, + tubularSegments: number, + radius: number, +) { + const segmentCount = Math.max( + PATH_STATIC_PREVIEW_FADE_SEGMENT_COUNT, + Math.ceil(tubularSegments / 2), + ) + const curveSampleCount = Math.max(3, Math.ceil(tubularSegments / segmentCount) + 1) + const tubeSegmentCount = Math.max(3, Math.ceil(tubularSegments / segmentCount)) + const segments: PathRenderSegment[] = [] + + for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) { + const startT = segmentIndex / segmentCount + const endT = (segmentIndex + 1) / segmentCount + const points: Vector3[] = [] + + for (let sampleIndex = 0; sampleIndex <= curveSampleCount; sampleIndex += 1) { + const t = MathUtils.lerp(startT, endT, sampleIndex / curveSampleCount) + points.push(baseCurve.getPointAt(t)) + } + + if (points.length < 2) { + continue + } + + const segmentCurve = + points.length >= 3 + ? new CatmullRomCurve3(points, false, 'centripetal') + : new LineCurve3(points[0]!, points[points.length - 1]!) + const material = new MeshBasicMaterial({ + color: new Color('#000000'), + depthTest: false, + depthWrite: false, + opacity: 0, + side: DoubleSide, + transparent: true, + }) + + material.toneMapped = false + + segments.push({ + centerT: (startT + endT) * 0.5, + endT, + geometry: new TubeGeometry( + segmentCurve, + tubeSegmentCount, + radius, + PATH_RENDER_MAIN_RADIAL_SEGMENTS, + false, + ), + material, + startT, + }) + } + + return segments +} + +function updateIndexedGeometryDrawRange( + geometry: BufferGeometry | null, + segmentCount: number, + clipStart: number, + indexStridePerSegment: number, +) { + const indexCount = geometry?.index?.count + if (!geometry || indexCount === undefined) { + return + } + + const clampedStart = MathUtils.clamp(clipStart, 0, 1) + const startSegment = Math.min(segmentCount, Math.floor(clampedStart * segmentCount)) + const startIndex = Math.min(indexCount, startSegment * indexStridePerSegment) + geometry.setDrawRange(startIndex, Math.max(0, indexCount - startIndex)) +} + +function createTrajectoryThreadMaterial() { + const visibleStart = uniform(0) + const fadeLength = uniform(1) + const reveal = uniform(1) + const opaque = uniform(0) + const fadeRange = fadeLength.max(float(0.0001)) + const fadeOpacity = uv().x.sub(visibleStart).div(fadeRange).clamp(0, 1) + const material = new MeshBasicNodeMaterial({ + colorNode: color(PATH_RENDER_THREAD_COLOR), + depthTest: false, + depthWrite: false, + opacityNode: mix(fadeOpacity, float(1), opaque).mul(reveal), + side: DoubleSide, + transparent: true, + userData: { + uFadeLength: fadeLength, + uOpaque: opaque, + uReveal: reveal, + uVisibleStart: visibleStart, + }, + }) + material.alphaTest = 0.001 + material.fog = false + material.toneMapped = false + return material as TrajectoryThreadMaterial +} + +function configureTrajectoryMaterial( + material: MeshBasicMaterial, + shaderRef: { current: TrajectoryShaderHandle | null }, + options: { + alphaEnabled?: boolean + alphaMax?: number + alphaMin?: number + alphaPhase?: number + alphaWaveCount?: number + alphaWaveSpeed?: number + discardHidden?: boolean + endFadeLength?: number + frontFadeLength: number + programKey: string + }, +) { + material.defines = { + ...(material.defines ?? {}), + USE_UV: '', + } + const trajectoryMaterial = material as TrajectoryMaterialHandle + const trajectoryUniforms = trajectoryMaterial.userData.trajectoryUniforms ?? { + uTrajectoryAlphaEnabled: { value: options.alphaEnabled ? 1 : 0 }, + uTrajectoryAlphaMax: { value: options.alphaMax ?? 1 }, + uTrajectoryAlphaMin: { value: options.alphaMin ?? 1 }, + uTrajectoryAlphaPhase: { value: options.alphaPhase ?? 0 }, + uTrajectoryAlphaWaveCount: { value: options.alphaWaveCount ?? 0 }, + uTrajectoryAlphaWaveSpeed: { value: options.alphaWaveSpeed ?? 0 }, + uTrajectoryEndFadeLength: { value: options.endFadeLength ?? 0 }, + uTrajectoryFrontFadeLength: { value: options.frontFadeLength }, + uTrajectoryReveal: { value: 0 }, + uTrajectoryTime: { value: 0 }, + uTrajectoryVisibleStart: { value: 0 }, + } + trajectoryMaterial.userData.trajectoryUniforms = trajectoryUniforms + material.onBeforeCompile = (shader) => { + shader.uniforms.uTrajectoryReveal = trajectoryUniforms.uTrajectoryReveal + shader.uniforms.uTrajectoryVisibleStart = trajectoryUniforms.uTrajectoryVisibleStart + shader.uniforms.uTrajectoryFrontFadeLength = trajectoryUniforms.uTrajectoryFrontFadeLength + shader.uniforms.uTrajectoryEndFadeLength = trajectoryUniforms.uTrajectoryEndFadeLength + shader.uniforms.uTrajectoryTime = trajectoryUniforms.uTrajectoryTime + shader.uniforms.uTrajectoryAlphaEnabled = trajectoryUniforms.uTrajectoryAlphaEnabled + shader.uniforms.uTrajectoryAlphaMin = trajectoryUniforms.uTrajectoryAlphaMin + shader.uniforms.uTrajectoryAlphaMax = trajectoryUniforms.uTrajectoryAlphaMax + shader.uniforms.uTrajectoryAlphaWaveCount = trajectoryUniforms.uTrajectoryAlphaWaveCount + shader.uniforms.uTrajectoryAlphaWaveSpeed = trajectoryUniforms.uTrajectoryAlphaWaveSpeed + shader.uniforms.uTrajectoryAlphaPhase = trajectoryUniforms.uTrajectoryAlphaPhase + + shaderRef.current = shader as unknown as TrajectoryShaderHandle + shader.fragmentShader = + ` +uniform float uTrajectoryReveal; +uniform float uTrajectoryVisibleStart; +uniform float uTrajectoryFrontFadeLength; +uniform float uTrajectoryEndFadeLength; +uniform float uTrajectoryTime; +uniform float uTrajectoryAlphaEnabled; +uniform float uTrajectoryAlphaMin; +uniform float uTrajectoryAlphaMax; +uniform float uTrajectoryAlphaWaveCount; +uniform float uTrajectoryAlphaWaveSpeed; +uniform float uTrajectoryAlphaPhase; +` + + shader.fragmentShader.replace( + '#include ', + `#include +float pathU = clamp(vUv.x, 0.0, 1.0); +float frontFade = uTrajectoryFrontFadeLength <= 0.0001 + ? (pathU >= uTrajectoryVisibleStart ? 1.0 : 0.0) + : uTrajectoryVisibleStart >= 0.9999 + ? 0.0 + : clamp( + (pathU - uTrajectoryVisibleStart) / max(uTrajectoryFrontFadeLength, 0.0001), + 0.0, + 1.0 + ); +float endFade = uTrajectoryEndFadeLength <= 0.0001 + ? 1.0 + : 1.0 - smoothstep(max(0.0, 1.0 - uTrajectoryEndFadeLength), 1.0, pathU); +float alphaWave = mix( + 1.0, + mix( + uTrajectoryAlphaMin, + uTrajectoryAlphaMax, + 0.5 + 0.5 * sin( + pathU * 6.28318530718 * uTrajectoryAlphaWaveCount - + uTrajectoryTime * uTrajectoryAlphaWaveSpeed + + uTrajectoryAlphaPhase + ) + ), + uTrajectoryAlphaEnabled +); +float trajectoryAlpha = uTrajectoryReveal * frontFade * endFade * alphaWave; +diffuseColor.a *= trajectoryAlpha; +${options.discardHidden ? 'if (pathU < uTrajectoryVisibleStart || diffuseColor.a <= 0.001) { discard; }' : ''} +`, + ) + } + material.customProgramCacheKey = () => options.programKey + return material +} + +function updateTrajectoryMaterialUniforms( + target: TrajectoryMaterialUniforms | TrajectoryShaderHandle | null, + values: { + endFadeLength?: number + frontFadeLength?: number + reveal: number + time: number + visibleStart: number + }, +) { + if (!target) { + return + } + + const uniforms = 'uniforms' in target ? target.uniforms : target + const revealUniform = uniforms.uTrajectoryReveal + const visibleStartUniform = uniforms.uTrajectoryVisibleStart + const timeUniform = uniforms.uTrajectoryTime + const frontFadeUniform = uniforms.uTrajectoryFrontFadeLength + const endFadeUniform = uniforms.uTrajectoryEndFadeLength + + if (revealUniform) { + revealUniform.value = values.reveal + } + if (visibleStartUniform) { + visibleStartUniform.value = values.visibleStart + } + if (timeUniform) { + timeUniform.value = values.time + } + if (values.frontFadeLength !== undefined && frontFadeUniform) { + frontFadeUniform.value = values.frontFadeLength + } + if (values.endFadeLength !== undefined && endFadeUniform) { + endFadeUniform.value = values.endFadeLength + } +} + +function buildPathHighlightTexture() { + const canvas = document.createElement('canvas') + canvas.width = 1024 + canvas.height = 16 + + const context = canvas.getContext('2d') + if (!context) { + return null + } + + const gradient = context.createLinearGradient(0, 0, canvas.width, 0) + const highlightStart = Math.max(0, 0.5 - PATH_MAIN_HIGHLIGHT_LENGTH * 0.5) + const highlightEnd = Math.min(1, 0.5 + PATH_MAIN_HIGHLIGHT_LENGTH * 0.5) + const feather = PATH_MAIN_HIGHLIGHT_FEATHER * 0.5 + + gradient.addColorStop(0, 'rgba(0,0,0,0)') + gradient.addColorStop(Math.max(0, highlightStart - feather), 'rgba(0,0,0,0)') + gradient.addColorStop(highlightStart, 'rgba(255,255,255,1)') + gradient.addColorStop(highlightEnd, 'rgba(255,255,255,1)') + gradient.addColorStop(Math.min(1, highlightEnd + feather), 'rgba(0,0,0,0)') + gradient.addColorStop(1, 'rgba(0,0,0,0)') + + context.clearRect(0, 0, canvas.width, canvas.height) + context.fillStyle = gradient + context.fillRect(0, 0, canvas.width, canvas.height) + + const texture = new CanvasTexture(canvas) + texture.wrapS = RepeatWrapping + texture.repeat.x = 1 + texture.offset.x = 0 + texture.needsUpdate = true + return texture +} + +function getShortestAngleDelta(currentAngle: number, targetAngle: number) { + return Math.atan2(Math.sin(targetAngle - currentAngle), Math.cos(targetAngle - currentAngle)) +} + +function getTurnSpeedFactor(yawDelta: number) { + const normalizedTurn = Math.min(1, Math.abs(yawDelta) / (Math.PI * 0.9)) + return normalizedTurn >= 0.92 ? 0 : 1 - normalizedTurn * normalizedTurn +} + +type RuntimeDoorAnimationState = { + closedPosition?: [number, number, number] + closedRotation?: [number, number, number] + localBounds?: { + max: [number, number, number] + min: [number, number, number] + } + openPosition?: [number, number, number] + openRotation?: [number, number, number] + style?: 'overhead' | 'swing' +} + +type ActiveDoorLeafCollisionShape = { + doorId: string + maxY: number + minY: number + polygonXZ: Array<[number, number]> + style: 'overhead' | 'swing' | null +} + +type ActiveDoorLeafCollisionShapeCacheEntry = { + doorId: string + localBoundsMax: [number, number, number] + localBoundsMin: [number, number, number] + matrixWorldElements: Float32Array + shape: ActiveDoorLeafCollisionShape + style: 'overhead' | 'swing' | null +} + +type NavigationPathCollisionAudit = { + blockedObstacleIds: string[] + blockedSampleCount: number + blockedWallIds: string[] +} + +const EMPTY_NAVIGATION_PATH_COLLISION_AUDIT: NavigationPathCollisionAudit = { + blockedObstacleIds: [], + blockedSampleCount: 0, + blockedWallIds: [], +} + +const doorCollisionCornerScratch = Array.from({ length: 4 }, () => new Vector3()) +const doorCollisionPointScratch = new Vector3() +const doorCollisionVerticalMaxScratch = new Vector3() +const doorCollisionVerticalMinScratch = new Vector3() +const activeDoorLeafCollisionShapeCache = new WeakMap< + Object3D, + ActiveDoorLeafCollisionShapeCacheEntry +>() + +function getDoorAnimationActivity( + leafPivot: Object3D, + animationState: RuntimeDoorAnimationState | undefined, +) { + const closedRotation = animationState?.closedRotation ?? [0, 0, 0] + const closedPosition = animationState?.closedPosition ?? [ + leafPivot.position.x, + leafPivot.position.y, + leafPivot.position.z, + ] + + return Math.max( + Math.abs(leafPivot.rotation.x - closedRotation[0]!), + Math.abs(leafPivot.rotation.y - closedRotation[1]!), + Math.abs(leafPivot.rotation.z - closedRotation[2]!), + Math.abs(leafPivot.position.x - closedPosition[0]!), + Math.abs(leafPivot.position.y - closedPosition[1]!), + Math.abs(leafPivot.position.z - closedPosition[2]!), + ) +} + +function hasMatchingDoorCollisionShapeCache( + cacheEntry: ActiveDoorLeafCollisionShapeCacheEntry, + doorId: string, + matrixWorldElements: ArrayLike, + animationState: RuntimeDoorAnimationState, +) { + const localBounds = animationState.localBounds + if ( + !localBounds || + cacheEntry.doorId !== doorId || + cacheEntry.style !== (animationState.style ?? null) + ) { + return false + } + + const { max, min } = localBounds + if ( + cacheEntry.localBoundsMin[0] !== min[0] || + cacheEntry.localBoundsMin[1] !== min[1] || + cacheEntry.localBoundsMin[2] !== min[2] || + cacheEntry.localBoundsMax[0] !== max[0] || + cacheEntry.localBoundsMax[1] !== max[1] || + cacheEntry.localBoundsMax[2] !== max[2] + ) { + return false + } + + for (let index = 0; index < 16; index += 1) { + if (cacheEntry.matrixWorldElements[index] !== matrixWorldElements[index]) { + return false + } + } + + return true +} + +function writeDoorCollisionMatrixWorld( + target: Float32Array, + matrixWorldElements: ArrayLike, +) { + for (let index = 0; index < 16; index += 1) { + target[index] = matrixWorldElements[index] ?? 0 + } +} + +function buildActiveDoorLeafCollisionShape( + doorId: string, + leafPivot: Object3D, + animationState: RuntimeDoorAnimationState, +): ActiveDoorLeafCollisionShape | null { + const localBounds = animationState.localBounds + if (!localBounds) { + return null + } + + const { min, max } = localBounds + const midY = (min[1] + max[1]) / 2 + const corners = [ + [min[0], midY, min[2]], + [min[0], midY, max[2]], + [max[0], midY, max[2]], + [max[0], midY, min[2]], + ] as const + const polygonXZ: Array<[number, number]> = [] + + doorCollisionVerticalMinScratch.set(0, min[1], 0).applyMatrix4(leafPivot.matrixWorld) + doorCollisionVerticalMaxScratch.set(0, max[1], 0).applyMatrix4(leafPivot.matrixWorld) + + for (let cornerIndex = 0; cornerIndex < corners.length; cornerIndex += 1) { + const corner = corners[cornerIndex] + const worldPoint = doorCollisionCornerScratch[cornerIndex] + if (!(corner && worldPoint)) { + continue + } + + worldPoint.set(corner[0], corner[1], corner[2]).applyMatrix4(leafPivot.matrixWorld) + polygonXZ.push([worldPoint.x, worldPoint.z]) + } + + return { + doorId, + maxY: Math.max(doorCollisionVerticalMinScratch.y, doorCollisionVerticalMaxScratch.y), + minY: Math.min(doorCollisionVerticalMinScratch.y, doorCollisionVerticalMaxScratch.y), + polygonXZ, + style: animationState.style ?? null, + } +} + +function getActiveDoorLeafCollisionShapes(doorIds: readonly string[]) { + if (doorIds.length === 0) { + return [] + } + + const activeShapes: ActiveDoorLeafCollisionShape[] = [] + const activeDoorIds = getActiveNavigationDoorIds() + + if (activeDoorIds.size === 0) { + return activeShapes + } + + for (const doorId of doorIds) { + if (!activeDoorIds.has(doorId)) { + continue + } + + const doorRoot = sceneRegistry.nodes.get(doorId) + const leafPivot = doorRoot?.getObjectByName('door-leaf-pivot') + const animationState = leafPivot?.userData.navigationDoor as + | RuntimeDoorAnimationState + | undefined + + if (!leafPivot || !animationState?.localBounds) { + continue + } + + if (getDoorAnimationActivity(leafPivot, animationState) <= DOOR_COLLISION_ACTIVE_EPSILON) { + continue + } + + leafPivot.updateWorldMatrix(true, false) + const matrixWorldElements = leafPivot.matrixWorld.elements + const cachedShape = activeDoorLeafCollisionShapeCache.get(leafPivot) + + if ( + cachedShape && + hasMatchingDoorCollisionShapeCache(cachedShape, doorId, matrixWorldElements, animationState) + ) { + activeShapes.push(cachedShape.shape) + continue + } + + const shape = buildActiveDoorLeafCollisionShape(doorId, leafPivot, animationState) + if (!shape) { + continue + } + + const nextCacheEntry: ActiveDoorLeafCollisionShapeCacheEntry = cachedShape ?? { + doorId, + localBoundsMax: [...animationState.localBounds.max] as [number, number, number], + localBoundsMin: [...animationState.localBounds.min] as [number, number, number], + matrixWorldElements: new Float32Array(16), + shape, + style: animationState.style ?? null, + } + + nextCacheEntry.doorId = doorId + nextCacheEntry.localBoundsMin[0] = animationState.localBounds.min[0] + nextCacheEntry.localBoundsMin[1] = animationState.localBounds.min[1] + nextCacheEntry.localBoundsMin[2] = animationState.localBounds.min[2] + nextCacheEntry.localBoundsMax[0] = animationState.localBounds.max[0] + nextCacheEntry.localBoundsMax[1] = animationState.localBounds.max[1] + nextCacheEntry.localBoundsMax[2] = animationState.localBounds.max[2] + nextCacheEntry.shape = shape + nextCacheEntry.style = animationState.style ?? null + writeDoorCollisionMatrixWorld(nextCacheEntry.matrixWorldElements, matrixWorldElements) + activeDoorLeafCollisionShapeCache.set(leafPivot, nextCacheEntry) + activeShapes.push(shape) + } + + return activeShapes +} + +function isPointInsidePolygonXZ( + pointX: number, + pointZ: number, + polygonXZ: Array<[number, number]>, +) { + let inside = false + + for (let index = 0; index < polygonXZ.length; index += 1) { + const current = polygonXZ[index] + const next = polygonXZ[(index + 1) % polygonXZ.length] + if (!(current && next)) { + continue + } + + const intersects = + current[1] > pointZ !== next[1] > pointZ && + pointX < + ((next[0] - current[0]) * (pointZ - current[1])) / (next[1] - current[1]) + current[0] + + if (intersects) { + inside = !inside + } + } + + return inside +} + +function getPointToSegmentDistanceSqXZ( + pointX: number, + pointZ: number, + start: [number, number], + end: [number, number], +) { + const segmentX = end[0] - start[0] + const segmentZ = end[1] - start[1] + const segmentLengthSq = segmentX * segmentX + segmentZ * segmentZ + if (segmentLengthSq <= Number.EPSILON) { + return (pointX - start[0]) * (pointX - start[0]) + (pointZ - start[1]) * (pointZ - start[1]) + } + + const projection = Math.max( + 0, + Math.min( + 1, + ((pointX - start[0]) * segmentX + (pointZ - start[1]) * segmentZ) / segmentLengthSq, + ), + ) + const closestX = start[0] + segmentX * projection + const closestZ = start[1] + segmentZ * projection + + return (pointX - closestX) * (pointX - closestX) + (pointZ - closestZ) * (pointZ - closestZ) +} + +function circleIntersectsDoorShapeXZ( + pointX: number, + pointZ: number, + radius: number, + shape: ActiveDoorLeafCollisionShape, +) { + if (isPointInsidePolygonXZ(pointX, pointZ, shape.polygonXZ)) { + return true + } + + for (let index = 0; index < shape.polygonXZ.length; index += 1) { + const current = shape.polygonXZ[index] + const next = shape.polygonXZ[(index + 1) % shape.polygonXZ.length] + if (!(current && next)) { + continue + } + + if (getPointToSegmentDistanceSqXZ(pointX, pointZ, current, next) <= radius * radius) { + return true + } + } + + return false +} + +function getBlockingDoorIdsForPoint( + point: Vector3, + activeDoorShapes: ActiveDoorLeafCollisionShape[], +) { + const candidateNavigationY = point.y - PATH_CURVE_OFFSET_Y + const blockingDoorIds: string[] = [] + + for (const shape of activeDoorShapes) { + if ( + shape.minY > candidateNavigationY + ACTOR_DOOR_COLLISION_HEIGHT || + shape.maxY < candidateNavigationY + ) { + continue + } + + if (circleIntersectsDoorShapeXZ(point.x, point.z, ACTOR_COLLISION_RADIUS, shape)) { + blockingDoorIds.push(shape.doorId) + } + } + + return blockingDoorIds +} + +function getPointBlockersForCurve( + graph: NonNullable>, + point: Vector3, + componentId: number | null, +) { + const cellIndex = findClosestNavigationCell(graph, [point.x, point.y, point.z], null, componentId) + const levelId = cellIndex !== null ? (graph.cells[cellIndex]?.levelId ?? null) : null + return getNavigationPointBlockers(graph, [point.x, point.y, point.z], levelId) +} + +function auditNavigationCurveCollisions( + graph: NonNullable> | null, + curve: Curve | null, + componentId: number | null, +): NavigationPathCollisionAudit { + if (!(graph && curve)) { + return EMPTY_NAVIGATION_PATH_COLLISION_AUDIT + } + + const sampleCount = Math.max(2, Math.ceil(curve.getLength() / PATH_SUPPORT_SAMPLE_STEP)) + const blockedWallIds = new Set() + const blockedObstacleIds = new Set() + const samplePoint = new Vector3() + let blockedSampleCount = 0 + const collectAllBlockedSamples = NAVIGATION_AUDIT_DIAGNOSTICS_ENABLED + + for (let sampleIndex = 0; sampleIndex <= sampleCount; sampleIndex += 1) { + curve.getPointAt(sampleIndex / sampleCount, samplePoint) + const blockers = getPointBlockersForCurve(graph, samplePoint, componentId) + if (blockers.wallIds.length === 0 && blockers.obstacleIds.length === 0) { + continue + } + + blockedSampleCount += 1 + for (const wallId of blockers.wallIds) { + blockedWallIds.add(wallId) + } + for (const obstacleId of blockers.obstacleIds) { + blockedObstacleIds.add(obstacleId) + } + + if (!collectAllBlockedSamples) { + break + } + } + + return { + blockedObstacleIds: [...blockedObstacleIds], + blockedSampleCount, + blockedWallIds: [...blockedWallIds], + } +} + +function getPickableNavigationObjects() { + return [...sceneRegistry.byType.slab, ...sceneRegistry.byType.stair] + .map((nodeId) => sceneRegistry.nodes.get(nodeId)) + .filter((object): object is Object3D => Boolean(object)) +} + +function getNavigationOccluderObjects() { + return [ + ...sceneRegistry.byType.item, + ...sceneRegistry.byType.wall, + ...sceneRegistry.byType.window, + ...sceneRegistry.byType.door, + ...sceneRegistry.byType.ceiling, + ...sceneRegistry.byType.roof, + ...sceneRegistry.byType['roof-segment'], + ] + .map((nodeId) => sceneRegistry.nodes.get(nodeId)) + .filter((object): object is Object3D => Boolean(object)) +} + +function objectBelongsToRoots(object: Object3D, roots: Set) { + let current: Object3D | null = object + while (current) { + if (roots.has(current)) { + return true + } + current = current.parent + } + return false +} + +function getRepresentativeCellIndex( + graph: NonNullable>, + indices: number[], +) { + if (indices.length === 0) { + return null + } + + let centroidX = 0 + let centroidY = 0 + let centroidZ = 0 + + for (const index of indices) { + const cell = graph.cells[index] + if (!cell) { + continue + } + + centroidX += cell.center[0] + centroidY += cell.center[1] + centroidZ += cell.center[2] + } + + centroidX /= indices.length + centroidY /= indices.length + centroidZ /= indices.length + + let bestIndex = indices[0] ?? null + let bestDistance = Number.POSITIVE_INFINITY + + for (const index of indices) { + const cell = graph.cells[index] + if (!cell) { + continue + } + + const distance = Math.hypot( + cell.center[0] - centroidX, + cell.center[1] - centroidY, + cell.center[2] - centroidZ, + ) + + if (distance < bestDistance) { + bestDistance = distance + bestIndex = index + } + } + + return bestIndex +} + +function getSpawnSupportScore( + graph: NonNullable>, + cellIndex: number, +) { + const cell = graph.cells[cellIndex] + if (!cell) { + return Number.NEGATIVE_INFINITY + } + + const componentId = graph.componentIdByCell[cellIndex] ?? -1 + let supportScore = 0 + + for ( + let offsetX = -SPAWN_SUPPORT_RADIUS_CELLS; + offsetX <= SPAWN_SUPPORT_RADIUS_CELLS; + offsetX += 1 + ) { + for ( + let offsetY = -SPAWN_SUPPORT_RADIUS_CELLS; + offsetY <= SPAWN_SUPPORT_RADIUS_CELLS; + offsetY += 1 + ) { + const candidateIndices = + graph.cellIndicesByKey.get(`${cell.gridX + offsetX},${cell.gridY + offsetY}`) ?? [] + + for (const candidateIndex of candidateIndices) { + if (candidateIndex === cellIndex) { + continue + } + + const candidate = graph.cells[candidateIndex] + if (!candidate || candidate.levelId !== cell.levelId) { + continue + } + + if ((graph.componentIdByCell[candidateIndex] ?? -1) !== componentId) { + continue + } + + const distance = Math.hypot(offsetX, offsetY) + supportScore += 1 / (1 + distance) + } + } + } + + return supportScore +} + +function getBestSpawnCellIndex( + graph: NonNullable>, + indices: number[], +) { + if (indices.length === 0) { + return null + } + + const representativeCellIndex = getRepresentativeCellIndex(graph, indices) + const representativeCell = + representativeCellIndex !== null ? graph.cells[representativeCellIndex] : null + + let bestIndex = indices[0] ?? null + let bestSupportScore = Number.NEGATIVE_INFINITY + let bestCentroidDistance = Number.POSITIVE_INFINITY + + for (const index of indices) { + const cell = graph.cells[index] + if (!cell) { + continue + } + + const supportScore = getSpawnSupportScore(graph, index) + const centroidDistance = representativeCell + ? Math.hypot( + cell.center[0] - representativeCell.center[0], + cell.center[1] - representativeCell.center[1], + cell.center[2] - representativeCell.center[2], + ) + : 0 + + if ( + supportScore > bestSupportScore + Number.EPSILON || + (Math.abs(supportScore - bestSupportScore) <= Number.EPSILON && + centroidDistance < bestCentroidDistance) + ) { + bestIndex = index + bestSupportScore = supportScore + bestCentroidDistance = centroidDistance + } + } + + return bestIndex +} + +function getInitialActorCellIndex( + graph: NonNullable>, + preferredLevelId?: LevelNode['id'] | null, +) { + if (preferredLevelId) { + const levelIndices = graph.cellsByLevel.get(preferredLevelId) ?? [] + const levelIndicesByComponent = new Map() + + for (const index of levelIndices) { + const componentId = graph.componentIdByCell[index] ?? -1 + const bucket = levelIndicesByComponent.get(componentId) + if (bucket) { + bucket.push(index) + } else { + levelIndicesByComponent.set(componentId, [index]) + } + } + + const dominantLevelComponent = [...levelIndicesByComponent.values()].sort( + (left, right) => right.length - left.length, + )[0] + + if (dominantLevelComponent?.length) { + return getBestSpawnCellIndex(graph, dominantLevelComponent) + } + } + + const largestComponent = graph.components[graph.largestComponentId] ?? [] + return getBestSpawnCellIndex(graph, largestComponent) +} + +function buildPascalTruckIntroState( + graph: NavigationGraph, + sceneNodes: Record, + preferredLevelId: LevelNode['id'] | null, +): Omit< + PascalTruckIntroState, + | 'animationElapsedMs' + | 'animationStarted' + | 'handoffPending' + | 'revealElapsedMs' + | 'revealStarted' + | 'warmupWaitElapsedMs' +> | null { + return measureNavigationPerf('navigation.pascalTruckIntroPlanMs', () => { + const truckNodeCandidate = + sceneNodes[PASCAL_TRUCK_ITEM_NODE_ID] ?? + Object.values(sceneNodes).find( + (node) => node?.type === 'item' && node.asset?.id === PASCAL_TRUCK_ASSET_ID, + ) + + if (!(truckNodeCandidate?.type === 'item' && Array.isArray(truckNodeCandidate.position))) { + return null + } + + const truckLevelId = toLevelNodeId(resolveLevelId(truckNodeCandidate, sceneNodes)) + if (preferredLevelId && truckLevelId && truckLevelId !== preferredLevelId) { + return null + } + + const position = truckNodeCandidate.position as [number, number, number] + const rotation = Array.isArray(truckNodeCandidate.rotation) + ? (truckNodeCandidate.rotation as [number, number, number]) + : [0, 0, 0] + const scale = Array.isArray(truckNodeCandidate.scale) + ? (truckNodeCandidate.scale as [number, number, number]) + : [1, 1, 1] + const candidateDimensions = truckNodeCandidate.asset?.dimensions + const dimensions: [number, number, number] = Array.isArray(candidateDimensions) + ? (candidateDimensions as [number, number, number]) + : ((PASCAL_TRUCK_ASSET.dimensions as [number, number, number] | undefined) ?? [ + 4.42, 2.5, 2.28, + ]) + + const yaw = rotation[1] ?? 0 + const length = Math.abs(dimensions[0] * (scale[0] ?? 1)) + const rearLocalStartX = + PASCAL_TRUCK_REAR_LOCAL_X_SIGN * (length * 0.5 - PASCAL_TRUCK_ENTRY_REAR_EDGE_INSET) + const rearLocalEndX = + rearLocalStartX + PASCAL_TRUCK_REAR_LOCAL_X_SIGN * PASCAL_TRUCK_ENTRY_REAR_TRAVEL_DISTANCE + const truckRearDirection = new Vector3(PASCAL_TRUCK_REAR_LOCAL_X_SIGN, 0, 0) + .applyAxisAngle(new Vector3(0, 1, 0), yaw) + .normalize() + const startOffset = new Vector3(rearLocalStartX, 0, 0).applyAxisAngle(new Vector3(0, 1, 0), yaw) + const endOffset = new Vector3(rearLocalEndX, 0, 0).applyAxisAngle(new Vector3(0, 1, 0), yaw) + const startPlanarPoint: [number, number, number] = [ + position[0] + startOffset.x, + position[1], + position[2] + startOffset.z, + ] + const endPlanarPoint: [number, number, number] = [ + position[0] + endOffset.x, + position[1], + position[2] + endOffset.z, + ] + const resolvedLevelId = preferredLevelId ?? truckLevelId ?? null + const endGroundPoint: [number, number, number] = [ + endPlanarPoint[0], + position[1], + endPlanarPoint[2], + ] + const startGroundPoint: [number, number, number] = [ + startPlanarPoint[0], + position[1], + startPlanarPoint[2], + ] + const finalCellIndex = + measureNavigationPerf('navigation.pascalTruckIntroEndCellMs', () => + findClosestSupportedNavigationCell( + graph, + endGroundPoint, + resolvedLevelId ?? undefined, + null, + ), + ) ?? + measureNavigationPerf('navigation.pascalTruckIntroStartCellMs', () => + findClosestSupportedNavigationCell( + graph, + startGroundPoint, + resolvedLevelId ?? undefined, + null, + ), + ) + const groundY = + finalCellIndex !== null + ? (graph.cells[finalCellIndex]?.center[1] ?? position[1]) + : position[1] + + return { + endPosition: [endPlanarPoint[0], groundY + ACTOR_HOVER_Y, endPlanarPoint[2]], + finalCellIndex, + rotationY: Math.atan2(truckRearDirection.x, truckRearDirection.z), + startPosition: [startPlanarPoint[0], groundY + ACTOR_HOVER_Y, startPlanarPoint[2]], + } + }) +} + +function findItemMoveApproach( + graph: NavigationGraph, + { + dimensions, + footprintBounds, + levelId, + position, + rotation, + }: { + position: [number, number, number] + rotation: [number, number, number] + dimensions: [number, number, number] + footprintBounds?: NavigationItemFootprintBounds | null + levelId: string | null + }, + componentId: number | null, + startCellIndex: number | null, + referenceWorld?: [number, number, number] | null, +) { + const yaw = rotation[1] ?? 0 + const [width, , depth] = dimensions + const [x, y, z] = position + const forwardX = Math.sin(yaw) + const forwardZ = Math.cos(yaw) + const rightX = Math.cos(yaw) + const rightZ = -Math.sin(yaw) + const sourceBounds = footprintBounds ?? { + maxX: width / 2, + maxZ: depth / 2, + minX: -width / 2, + minZ: -depth / 2, + } + const expandedMinX = sourceBounds.minX - ITEM_MOVE_APPROACH_MARGIN + const expandedMaxX = sourceBounds.maxX + ITEM_MOVE_APPROACH_MARGIN + const expandedMinZ = sourceBounds.minZ - ITEM_MOVE_APPROACH_MARGIN + const expandedMaxZ = sourceBounds.maxZ + ITEM_MOVE_APPROACH_MARGIN + const expandedMidX = (expandedMinX + expandedMaxX) * 0.5 + const expandedMidZ = (expandedMinZ + expandedMaxZ) * 0.5 + const candidateLevelId = toLevelNodeId(levelId) + const candidatePoints: Array<{ penalty: number; world: [number, number, number] }> = [] + const pathCostByCellIndex = new Map() + const seenCandidateKeys = new Set() + const localToWorld = (localX: number, localZ: number): [number, number, number] => [ + x + rightX * localX + forwardX * localZ, + y, + z + rightZ * localX + forwardZ * localZ, + ] + const worldToLocal = (world: [number, number, number]) => { + const dx = world[0] - x + const dz = world[2] - z + return { + x: dx * rightX + dz * rightZ, + z: dx * forwardX + dz * forwardZ, + } + } + const addCandidate = (localX: number, localZ: number, penalty: number) => { + const clampedLocalX = MathUtils.clamp(localX, expandedMinX, expandedMaxX) + const clampedLocalZ = MathUtils.clamp(localZ, expandedMinZ, expandedMaxZ) + const key = `${clampedLocalX.toFixed(3)}:${clampedLocalZ.toFixed(3)}` + if (seenCandidateKeys.has(key)) { + return + } + + seenCandidateKeys.add(key) + candidatePoints.push({ + penalty, + world: localToWorld(clampedLocalX, clampedLocalZ), + }) + } + const sampleEdge = ( + startLocal: [number, number], + endLocal: [number, number], + penalty: number, + ) => { + const edgeLength = Math.hypot(endLocal[0] - startLocal[0], endLocal[1] - startLocal[1]) + const stepCount = Math.max(1, Math.ceil(edgeLength / 0.24)) + for (let stepIndex = 0; stepIndex <= stepCount; stepIndex += 1) { + const t = stepCount === 0 ? 0 : stepIndex / stepCount + addCandidate( + MathUtils.lerp(startLocal[0], endLocal[0], t), + MathUtils.lerp(startLocal[1], endLocal[1], t), + penalty, + ) + } + } + const getClosestPerimeterLocalPoint = (world: [number, number, number]) => { + const local = worldToLocal(world) + let localX = MathUtils.clamp(local.x, expandedMinX, expandedMaxX) + let localZ = MathUtils.clamp(local.z, expandedMinZ, expandedMaxZ) + const insideX = local.x > expandedMinX && local.x < expandedMaxX + const insideZ = local.z > expandedMinZ && local.z < expandedMaxZ + + if (insideX && insideZ) { + const distanceToLeft = Math.abs(local.x - expandedMinX) + const distanceToRight = Math.abs(expandedMaxX - local.x) + const distanceToBack = Math.abs(local.z - expandedMinZ) + const distanceToFront = Math.abs(expandedMaxZ - local.z) + const nearestEdgeDistance = Math.min( + distanceToLeft, + distanceToRight, + distanceToBack, + distanceToFront, + ) + + if (nearestEdgeDistance === distanceToLeft) { + localX = expandedMinX + } else if (nearestEdgeDistance === distanceToRight) { + localX = expandedMaxX + } else if (nearestEdgeDistance === distanceToBack) { + localZ = expandedMinZ + } else { + localZ = expandedMaxZ + } + } else if (insideX) { + localZ = local.z < expandedMidZ ? expandedMinZ : expandedMaxZ + } else if (insideZ) { + localX = local.x < expandedMidX ? expandedMinX : expandedMaxX + } + + return [localX, localZ] as [number, number] + } + + if (referenceWorld) { + const [closestLocalX, closestLocalZ] = getClosestPerimeterLocalPoint(referenceWorld) + addCandidate(closestLocalX, closestLocalZ, 0) + const tangentOffset = 0.24 + const verticalEdgeDistance = Math.min( + Math.abs(closestLocalX - expandedMinX), + Math.abs(expandedMaxX - closestLocalX), + ) + const horizontalEdgeDistance = Math.min( + Math.abs(closestLocalZ - expandedMinZ), + Math.abs(expandedMaxZ - closestLocalZ), + ) + if (verticalEdgeDistance <= horizontalEdgeDistance) { + addCandidate(closestLocalX, closestLocalZ - tangentOffset, 0.01) + addCandidate(closestLocalX, closestLocalZ + tangentOffset, 0.01) + } else { + addCandidate(closestLocalX - tangentOffset, closestLocalZ, 0.01) + addCandidate(closestLocalX + tangentOffset, closestLocalZ, 0.01) + } + } + + sampleEdge([expandedMinX, expandedMaxZ], [expandedMaxX, expandedMaxZ], 0.02) + sampleEdge([expandedMaxX, expandedMaxZ], [expandedMaxX, expandedMinZ], 0.02) + sampleEdge([expandedMaxX, expandedMinZ], [expandedMinX, expandedMinZ], 0.02) + sampleEdge([expandedMinX, expandedMinZ], [expandedMinX, expandedMaxZ], 0.02) + + let best: { approach: NavigationItemMoveApproach; score: number } | null = null + + for (const candidate of candidatePoints) { + const cellIndex = findClosestNavigationCell( + graph, + candidate.world, + candidateLevelId ?? undefined, + componentId, + ) + if (cellIndex === null) { + continue + } + + const cell = graph.cells[cellIndex] + if (!cell) { + continue + } + + const snapDistance = Math.hypot( + cell.center[0] - candidate.world[0], + (cell.center[1] - candidate.world[1]) * 1.5, + cell.center[2] - candidate.world[2], + ) + if (snapDistance > ITEM_MOVE_APPROACH_MAX_SNAP_DISTANCE) { + continue + } + + let pathCost = pathCostByCellIndex.get(cellIndex) + if (pathCost === undefined) { + const pathResult = + startCellIndex !== null ? findNavigationPath(graph, startCellIndex, cellIndex) : null + if (!pathResult) { + continue + } + + pathCost = pathResult.cost + pathCostByCellIndex.set(cellIndex, pathCost) + } + + const referenceDistance = referenceWorld + ? Math.hypot( + candidate.world[0] - referenceWorld[0], + (candidate.world[1] - referenceWorld[1]) * 1.5, + candidate.world[2] - referenceWorld[2], + ) + : 0 + const score = pathCost + snapDistance * 0.8 + referenceDistance * 0.05 + candidate.penalty + if (!best || score < best.score) { + best = { + approach: { + cellIndex, + world: [...cell.center] as [number, number, number], + }, + score, + } + } + } + + return best?.approach ?? null +} + +function clamp01(value: number) { + return Math.min(1, Math.max(0, value)) +} + +function smoothstep01(t: number) { + const clampedT = clamp01(t) + return clampedT * clampedT * (3 - 2 * clampedT) +} + +function getLeadingTransferProgress(t: number) { + return smoothstep01(1 - (1 - clamp01(t)) ** 2) +} + +function getTrailingTransferProgress(t: number) { + return smoothstep01(clamp01(t) ** 2) +} + +function lerpNumber(start: number, end: number, t: number) { + return start + (end - start) * t +} + +function interpolateYaw(start: number, end: number, t: number) { + return start + getShortestAngleDelta(start, end) * t +} + +function quadraticBezierNumber(start: number, control: number, end: number, t: number) { + const inverseT = 1 - t + return inverseT * inverseT * start + 2 * inverseT * t * control + t * t * end +} + +function getCarryAnchorPosition( + actorPosition: [number, number, number], + actorRotationY: number, + itemDimensions: [number, number, number], + now: number, + wobbleEnabled: boolean, +) { + const itemHeightOffset = Math.min( + itemDimensions[1] * ITEM_MOVE_CARRY_ITEM_HEIGHT_SCALE, + ITEM_MOVE_CARRY_ITEM_HEIGHT_MAX, + ) + const carryHeight = Math.max( + actorPosition[1] + 0.18, + actorPosition[1] + + ITEM_MOVE_ROBOT_HEIGHT_ESTIMATE + + ITEM_MOVE_CARRY_HEAD_CLEARANCE + + itemHeightOffset, + ) + const forwardX = Math.sin(actorRotationY) + const forwardZ = Math.cos(actorRotationY) + const rightX = Math.cos(actorRotationY) + const rightZ = -Math.sin(actorRotationY) + const lateralOffset = wobbleEnabled + ? Math.sin(now * ITEM_MOVE_CARRY_WOBBLE_SPEED) * ITEM_MOVE_CARRY_WOBBLE_LATERAL + : 0 + const verticalOffset = wobbleEnabled + ? Math.cos(now * ITEM_MOVE_CARRY_WOBBLE_SPEED * 0.82) * ITEM_MOVE_CARRY_WOBBLE_VERTICAL + : 0 + + return { + position: [ + actorPosition[0] + forwardX * ITEM_MOVE_CARRY_FORWARD_DISTANCE + rightX * lateralOffset, + carryHeight + verticalOffset, + actorPosition[2] + forwardZ * ITEM_MOVE_CARRY_FORWARD_DISTANCE + rightZ * lateralOffset, + ] as [number, number, number], + } +} + +function getPickupTransferTransform( + actorPosition: [number, number, number], + actorRotationY: number, + itemDimensions: [number, number, number], + sourcePosition: [number, number, number], + sourceRotationY: number, + now: number, + progress: number, +) { + const carryAnchor = getCarryAnchorPosition( + actorPosition, + actorRotationY, + itemDimensions, + now, + false, + ) + const horizontalProgress = getTrailingTransferProgress(progress) + const verticalProgress = getLeadingTransferProgress(progress) + const raisedHeight = + Math.max( + sourcePosition[1], + carryAnchor.position[1], + actorPosition[1] + ITEM_MOVE_ROBOT_HEIGHT_ESTIMATE + ITEM_MOVE_CARRY_HEAD_CLEARANCE, + ) + + ITEM_MOVE_PICKUP_ARC_HEIGHT + + Math.min(itemDimensions[1] * 0.08, 0.12) + + return { + position: [ + lerpNumber(sourcePosition[0], carryAnchor.position[0], horizontalProgress), + quadraticBezierNumber( + sourcePosition[1], + raisedHeight, + carryAnchor.position[1], + verticalProgress, + ), + lerpNumber(sourcePosition[2], carryAnchor.position[2], horizontalProgress), + ] as [number, number, number], + rotationY: sourceRotationY, + } +} + +function getDropTransferTransform( + startPosition: [number, number, number], + targetPosition: [number, number, number], + sourceRotationY: number, + targetRotationY: number, + progress: number, +) { + const horizontalProgress = getLeadingTransferProgress(progress) + const verticalProgress = getTrailingTransferProgress(progress) + const rotationProgress = smoothstep01(progress) + return { + position: [ + lerpNumber(startPosition[0], targetPosition[0], horizontalProgress), + lerpNumber(startPosition[1], targetPosition[1], verticalProgress), + lerpNumber(startPosition[2], targetPosition[2], horizontalProgress), + ] as [number, number, number], + rotationY: interpolateYaw(sourceRotationY, targetRotationY, rotationProgress), + } +} + +function getRenderedFloorItemPosition( + levelId: string | null, + position: [number, number, number], + itemDimensions: [number, number, number], + rotation: [number, number, number], +) { + const resolvedLevelId = toLevelNodeId(levelId) + if (!resolvedLevelId) { + return position + } + + const slabElevation = spatialGridManager.getSlabElevationForItem( + resolvedLevelId, + position, + itemDimensions, + rotation, + ) + + return [position[0], position[1] + slabElevation, position[2]] as [number, number, number] +} + +function hasNavigationApproachTargetExclusion(target: Object3D | null) { + let current: Object3D | null = target + while (current) { + if ( + typeof current.userData === 'object' && + current.userData !== null && + current.userData.pascalExcludeFromToolConeTarget === true + ) { + return true + } + current = current.parent + } + return false +} + +function extractObjectLocalFootprintBounds( + root: Object3D | null, +): NavigationItemFootprintBounds | null { + if (!root) { + return null + } + + root.updateWorldMatrix(true, true) + + const rotationOnlyWorldMatrix = new Matrix4() + const rotationOnlyWorldInverse = new Matrix4() + const rootWorldPosition = new Vector3() + const rootWorldQuaternion = new Quaternion() + root.matrixWorld.decompose(rootWorldPosition, rootWorldQuaternion, new Vector3()) + rotationOnlyWorldMatrix.compose(rootWorldPosition, rootWorldQuaternion, new Vector3(1, 1, 1)) + rotationOnlyWorldInverse.copy(rotationOnlyWorldMatrix).invert() + + const scratchLocalPoint = new Vector3() + const scratchWorldPoint = new Vector3() + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + root.traverse((child) => { + const mesh = child as Mesh + if (!mesh.isMesh || !mesh.geometry || hasNavigationApproachTargetExclusion(mesh)) { + return + } + + const positionAttribute = mesh.geometry.getAttribute('position') + if (!positionAttribute) { + return + } + + for (let index = 0; index < positionAttribute.count; index += 1) { + scratchLocalPoint.fromBufferAttribute(positionAttribute, index) + scratchWorldPoint.copy(scratchLocalPoint).applyMatrix4(mesh.matrixWorld) + scratchLocalPoint.copy(scratchWorldPoint).applyMatrix4(rotationOnlyWorldInverse) + + if (!Number.isFinite(scratchLocalPoint.x) || !Number.isFinite(scratchLocalPoint.z)) { + continue + } + + minX = Math.min(minX, scratchLocalPoint.x) + maxX = Math.max(maxX, scratchLocalPoint.x) + minZ = Math.min(minZ, scratchLocalPoint.z) + maxZ = Math.max(maxZ, scratchLocalPoint.z) + } + }) + + if (![minX, maxX, minZ, maxZ].every((value) => Number.isFinite(value))) { + return null + } + + return { + maxX, + maxZ, + minX, + minZ, + } +} + +function isPascalTruckRuntimeNode(node: AnyNode | null | undefined) { + if (node?.type !== 'item') { + return false + } + + const asset = node.asset + return ( + node.id === PASCAL_TRUCK_ITEM_NODE_ID || + asset.id === PASCAL_TRUCK_ASSET_ID || + asset.src === PASCAL_TRUCK_ASSET.src || + (typeof asset.src === 'string' && asset.src.endsWith(PASCAL_TRUCK_ASSET.src)) + ) +} + +function hasTransientNavigationMetadata(node: AnyNode | null | undefined) { + const metadata = node?.metadata + return ( + typeof metadata === 'object' && + metadata !== null && + !Array.isArray(metadata) && + (metadata as Record).isTransient === true + ) +} + +function isTransientNavigationNode(node: AnyNode | null | undefined) { + return ( + hasTransientNavigationMetadata(node) || + isNavigationTaskPreviewNodeId(node?.id) || + isPascalTruckRuntimeNode(node) + ) +} + +function buildNavigationSceneSnapshot( + nodes: Record, + rootNodeIds: string[], + ignoredNodeIds: string[] = [], +): { + key: string + nodes: Record + rootNodeIds: string[] +} { + const ignoredNodeIdSet = new Set(ignoredNodeIds) + const transientNodeIds = new Set() + + for (const [nodeId, node] of Object.entries(nodes)) { + if (ignoredNodeIdSet.has(nodeId) || isTransientNavigationNode(node)) { + transientNodeIds.add(nodeId) + } + } + + const snapshotNodes: Record = {} + const orderedSnapshotNodes: AnyNode[] = [] + const orderedSnapshotKeyNodes: AnyNode[] = [] + + const getSnapshotKeyNode = (node: AnyNode) => { + if (!('metadata' in node)) { + return node + } + + const { metadata: _metadata, ...snapshotKeyNode } = node + return snapshotKeyNode as AnyNode + } + + for (const nodeId of Object.keys(nodes).sort()) { + if (transientNodeIds.has(nodeId)) { + continue + } + + const node = nodes[nodeId] + if (!node) { + continue + } + + let snapshotNode = node + const childIds = (node as { children?: string[] }).children + if (Array.isArray(childIds)) { + const filteredChildren = childIds.filter((childId) => !transientNodeIds.has(childId)) + if (filteredChildren.length !== childIds.length) { + snapshotNode = { ...node, children: filteredChildren } as AnyNode + } + } + + snapshotNodes[nodeId] = snapshotNode + orderedSnapshotNodes.push(snapshotNode) + orderedSnapshotKeyNodes.push(getSnapshotKeyNode(snapshotNode)) + } + + const snapshotRootNodeIds = rootNodeIds.filter((nodeId) => !transientNodeIds.has(nodeId)) + const effectiveIgnoredNodeIds = [...ignoredNodeIdSet] + .filter((nodeId) => { + const node = nodes[nodeId] + return Boolean(node) && !isTransientNavigationNode(node) + }) + .sort() + + return { + key: JSON.stringify({ + ignoredNodeIds: effectiveIgnoredNodeIds, + nodes: orderedSnapshotKeyNodes, + rootNodeIds: snapshotRootNodeIds, + }), + nodes: snapshotNodes, + rootNodeIds: snapshotRootNodeIds, + } +} + +export function NavigationSystem() { + const { + activeTaskId, + advanceTaskQueue, + beginTaskLoopReset, + enabled, + followRobotEnabled, + itemDeleteRequest, + itemMoveControllers, + itemMoveLocked, + itemMoveRequest, + itemRepairRequest, + queueRestartToken, + robotModel, + robotMode, + removeQueuedTask, + requestItemDelete, + requestItemMove, + requestItemRepair, + setActorAvailable, + setActorWorldPosition, + setItemMoveLocked, + taskQueue, + taskLoopSettledToken, + taskLoopToken, + walkableOverlayVisible, + } = useNavigation( + useShallow((state) => ({ + activeTaskId: state.activeTaskId, + advanceTaskQueue: state.advanceTaskQueue, + beginTaskLoopReset: state.beginTaskLoopReset, + enabled: state.enabled, + followRobotEnabled: state.followRobotEnabled, + itemDeleteRequest: state.itemDeleteRequest, + itemMoveControllers: state.itemMoveControllers, + itemMoveLocked: state.itemMoveLocked, + itemMoveRequest: state.itemMoveRequest, + itemRepairRequest: state.itemRepairRequest, + queueRestartToken: state.queueRestartToken, + robotModel: state.robotModel, + robotMode: state.robotMode, + removeQueuedTask: state.removeQueuedTask, + requestItemDelete: state.requestItemDelete, + requestItemMove: state.requestItemMove, + requestItemRepair: state.requestItemRepair, + setActorAvailable: state.setActorAvailable, + setActorWorldPosition: state.setActorWorldPosition, + setItemMoveLocked: state.setItemMoveLocked, + taskQueue: state.taskQueue, + taskLoopSettledToken: state.taskLoopSettledToken, + taskLoopToken: state.taskLoopToken, + walkableOverlayVisible: state.walkableOverlayVisible, + })), + ) + const headItemMoveController = useMemo(() => { + if (!itemMoveRequest) { + return null + } + + // Task-mode execution must not depend on live move-tool component state. + // Once a task is queued, run it from the frozen request payload so later + // queued tasks cannot inherit stale controller closures from earlier items. + if (robotMode === 'task') { + return createNavigationItemMoveFallbackController(itemMoveRequest) + } + + return ( + itemMoveControllers[itemMoveRequest.itemId] ?? + createNavigationItemMoveFallbackController(itemMoveRequest) + ) + }, [itemMoveControllers, itemMoveRequest, robotMode]) + const activeToolConeColor = itemDeleteRequest + ? NAVIGATION_TOOL_CONE_DELETE_COLOR + : itemRepairRequest + ? NAVIGATION_TOOL_CONE_REPAIR_COLOR + : itemMoveRequest + ? isNavigationCopyItemMoveRequest(itemMoveRequest) + ? NAVIGATION_TOOL_CONE_COPY_COLOR + : NAVIGATION_TOOL_CONE_MOVE_COLOR + : NAVIGATION_TOOL_CONE_MOVE_COLOR + const itemMoveControllerCount = useMemo( + () => Object.keys(itemMoveControllers).length, + [itemMoveControllers], + ) + const movingItemNode = useEditor((state) => + state.movingNode?.type === 'item' ? (state.movingNode as ItemNode) : null, + ) + const movingItemId = movingItemNode?.id ?? null + const selection = useViewer((state) => state.selection) + const itemMovePreview = useNavigationVisuals((state) => state.itemMovePreview) + const cameraDragging = useViewer((state) => state.cameraDragging) + const navigationPostWarmupRequestToken = useNavigationVisuals( + (state) => state.navigationPostWarmupRequestToken, + ) + const navigationPostWarmupCompletedToken = useNavigationVisuals( + (state) => state.navigationPostWarmupCompletedToken, + ) + const { camera, gl, scene, set: setThreeState } = useThree() + const canvasSize = useThree((state) => state.size) + const sceneState = useScene( + useShallow((state) => ({ + nodes: state.nodes as Record, + rootNodeIds: state.rootNodeIds as string[], + })), + ) + const activeDoorCollisionCandidateIds = useMemo( + () => + Object.values(sceneState.nodes) + .filter( + ( + node, + ): node is + | { asset?: { category?: string }; id: string; type: 'door' } + | { asset?: { category?: string }; id: string; type: 'item' } => + node?.type === 'door' || (node?.type === 'item' && node.asset?.category === 'door'), + ) + .map((node) => node.id), + [sceneState.nodes], + ) + + const actorPointRef = useRef(new Vector3()) + const actorTangentRef = useRef(new Vector3()) + const actorTangentAheadRef = useRef(new Vector3()) + const actorFallbackPointRef = useRef(new Vector3()) + const navigationRuntimeActive = enabled || walkableOverlayVisible + const [releasedNavigationItemId, setReleasedNavigationItemId] = useState(null) + const [pendingTaskGraphSyncKey, setPendingTaskGraphSyncKey] = useState(null) + const navigationSceneSnapshotCacheRef = useRef<{ + ignoredItemId: string | null + nodes: Record + rootNodeIds: string[] + snapshot: NavigationSceneSnapshot + } | null>(null) + const stableNavigationSceneSnapshotRef = useRef(null) + const navigationIgnoredItemId = releasedNavigationItemId + const itemMovePreviewActive = Boolean(movingItemId && !itemMoveLocked) + + const navigationSceneSnapshot = useMemo(() => { + const buildCachedSnapshot = () => { + const cachedSnapshot = navigationSceneSnapshotCacheRef.current + if ( + cachedSnapshot && + cachedSnapshot.nodes === sceneState.nodes && + cachedSnapshot.rootNodeIds === sceneState.rootNodeIds && + cachedSnapshot.ignoredItemId === navigationIgnoredItemId + ) { + return cachedSnapshot.snapshot + } + + const nextSnapshot = measureNavigationPerf('navigation.sceneSnapshotMs', () => + buildNavigationSceneSnapshot( + sceneState.nodes, + sceneState.rootNodeIds, + navigationIgnoredItemId ? [navigationIgnoredItemId] : [], + ), + ) + navigationSceneSnapshotCacheRef.current = { + ignoredItemId: navigationIgnoredItemId, + nodes: sceneState.nodes, + rootNodeIds: sceneState.rootNodeIds, + snapshot: nextSnapshot, + } + return nextSnapshot + } + + if (itemMovePreviewActive && stableNavigationSceneSnapshotRef.current) { + return stableNavigationSceneSnapshotRef.current + } + + const nextSnapshot = buildCachedSnapshot() + if (!itemMovePreviewActive) { + stableNavigationSceneSnapshotRef.current = nextSnapshot + } + return nextSnapshot + }, [itemMovePreviewActive, navigationIgnoredItemId, sceneState.nodes, sceneState.rootNodeIds]) + const graphCacheRef = useRef( + new Map< + string, + { + graph: NavigationGraph | null + } + >(), + ) + const navigationGraphWarmWorkerRef = useRef(null) + const navigationGraphWarmRequestIdRef = useRef(0) + const navigationGraphWarmPendingKeyRef = useRef(null) + const navigationGraphWarmPendingRequestsRef = useRef( + new Map(), + ) + const prewarmedGraphCacheKeyRef = useRef(null) + const [prewarmedGraphState, setPrewarmedGraphState] = useState(null) + const [prewarmedGraphStateKey, setPrewarmedGraphStateKey] = useState(null) + const getNavigationGraphCacheKey = useCallback( + (snapshot: NavigationSceneSnapshot) => { + const buildingId = selection.buildingId ?? null + return `${buildingId ?? 'null'}::${snapshot.key}` + }, + [selection.buildingId], + ) + const getCachedNavigationGraphForSnapshot = useCallback( + (snapshot: NavigationSceneSnapshot, perfMetricName: string) => { + const graphCacheKey = getNavigationGraphCacheKey(snapshot) + const cachedGraph = graphCacheRef.current.get(graphCacheKey) + if (cachedGraph) { + graphCacheRef.current.delete(graphCacheKey) + graphCacheRef.current.set(graphCacheKey, cachedGraph) + return cachedGraph.graph + } + + const nextGraph = measureNavigationPerf(perfMetricName, () => + buildNavigationGraph(snapshot.nodes, snapshot.rootNodeIds, selection.buildingId), + ) + graphCacheRef.current.set(graphCacheKey, { + graph: nextGraph, + }) + while (graphCacheRef.current.size > NAVIGATION_GRAPH_CACHE_MAX_ENTRIES) { + const oldestKey = graphCacheRef.current.keys().next().value + if (oldestKey === undefined) { + break + } + graphCacheRef.current.delete(oldestKey) + } + + return nextGraph + }, + [getNavigationGraphCacheKey, selection.buildingId], + ) + useEffect(() => { + if (typeof Worker === 'undefined') { + navigationGraphWarmWorkerRef.current = null + return + } + + const worker = new Worker( + new URL('../../workers/navigation-graph-worker.ts', import.meta.url), + { + type: 'module', + }, + ) + worker.onmessage = (event) => { + const data = event.data as { + error?: string + graph?: NavigationGraph | null + requestId?: number + } | null + const requestId = typeof data?.requestId === 'number' ? data.requestId : null + if (requestId === null) { + return + } + + const pendingRequest = navigationGraphWarmPendingRequestsRef.current.get(requestId) + if (!pendingRequest) { + return + } + + navigationGraphWarmPendingRequestsRef.current.delete(requestId) + if (navigationGraphWarmPendingKeyRef.current === pendingRequest.cacheKey) { + navigationGraphWarmPendingKeyRef.current = null + } + + if (typeof data?.error === 'string' && data.error.length > 0) { + recordNavigationPerfMark('navigation.graphWarmWorkerError', { + cacheKey: pendingRequest.cacheKey, + error: data.error, + }) + return + } + + const nextGraph = data?.graph ?? null + graphCacheRef.current.set(pendingRequest.cacheKey, { + graph: nextGraph, + }) + while (graphCacheRef.current.size > NAVIGATION_GRAPH_CACHE_MAX_ENTRIES) { + const oldestKey = graphCacheRef.current.keys().next().value + if (oldestKey === undefined) { + break + } + graphCacheRef.current.delete(oldestKey) + } + + recordNavigationPerfSample( + 'navigation.graphWarmWorkerRoundTripMs', + performance.now() - pendingRequest.requestedAtMs, + { + cacheKey: pendingRequest.cacheKey, + }, + ) + + if (prewarmedGraphCacheKeyRef.current === pendingRequest.cacheKey) { + setPrewarmedGraphState(nextGraph) + setPrewarmedGraphStateKey(pendingRequest.cacheKey) + } + } + navigationGraphWarmWorkerRef.current = worker + + return () => { + navigationGraphWarmPendingRequestsRef.current.clear() + navigationGraphWarmPendingKeyRef.current = null + navigationGraphWarmWorkerRef.current = null + worker.terminate() + } + }, []) + + const shouldSyncPrewarmGraph = + prewarmedGraphState === null || + itemMovePreviewActive || + itemMoveRequest !== null || + itemDeleteRequest !== null || + itemRepairRequest !== null + + useEffect(() => { + if (!navigationSceneSnapshot) { + navigationGraphWarmPendingKeyRef.current = null + navigationGraphWarmPendingRequestsRef.current.clear() + prewarmedGraphCacheKeyRef.current = null + setPrewarmedGraphState(null) + setPrewarmedGraphStateKey(null) + return + } + + const nextCacheKey = getNavigationGraphCacheKey(navigationSceneSnapshot) + const cachedGraph = graphCacheRef.current.get(nextCacheKey)?.graph ?? null + if (cachedGraph) { + navigationGraphWarmPendingKeyRef.current = null + prewarmedGraphCacheKeyRef.current = nextCacheKey + setPrewarmedGraphState(cachedGraph) + setPrewarmedGraphStateKey(nextCacheKey) + return + } + + if (shouldSyncPrewarmGraph || !navigationGraphWarmWorkerRef.current) { + const nextGraph = getCachedNavigationGraphForSnapshot( + navigationSceneSnapshot, + 'navigation.graphWarmBuildMs', + ) + navigationGraphWarmPendingKeyRef.current = null + prewarmedGraphCacheKeyRef.current = nextCacheKey + setPrewarmedGraphState(nextGraph) + setPrewarmedGraphStateKey(nextCacheKey) + return + } + + if (navigationGraphWarmPendingKeyRef.current === nextCacheKey) { + return + } + + const requestId = ++navigationGraphWarmRequestIdRef.current + navigationGraphWarmPendingKeyRef.current = nextCacheKey + navigationGraphWarmPendingRequestsRef.current.set(requestId, { + cacheKey: nextCacheKey, + requestedAtMs: performance.now(), + }) + recordNavigationPerfMark('navigation.graphWarmWorkerRequest', { + cacheKey: nextCacheKey, + requestId, + }) + navigationGraphWarmWorkerRef.current.postMessage({ + buildingId: selection.buildingId ?? null, + nodes: navigationSceneSnapshot.nodes, + requestId, + rootNodeIds: navigationSceneSnapshot.rootNodeIds, + }) + prewarmedGraphCacheKeyRef.current = nextCacheKey + }, [ + getCachedNavigationGraphForSnapshot, + getNavigationGraphCacheKey, + navigationSceneSnapshot, + shouldSyncPrewarmGraph, + selection.buildingId, + ]) + + const prewarmedGraph = prewarmedGraphState + const currentNavigationGraphCacheKey = navigationSceneSnapshot + ? getNavigationGraphCacheKey(navigationSceneSnapshot) + : null + const navigationGraphCurrent = + currentNavigationGraphCacheKey !== null && + prewarmedGraphStateKey !== null && + prewarmedGraphStateKey === currentNavigationGraphCacheKey + const taskQueueGraphSettled = + robotMode !== 'task' || + pendingTaskGraphSyncKey === null || + (navigationGraphCurrent && navigationSceneSnapshot?.key === pendingTaskGraphSyncKey) + const taskLoopSceneSettled = robotMode !== 'task' || taskLoopSettledToken === taskLoopToken + const taskQueuePlanningReady = taskQueueGraphSettled && taskLoopSceneSettled + const previousTaskQueuePlanningReadyRef = useRef(taskQueuePlanningReady) + const graph = useMemo( + () => (navigationRuntimeActive ? prewarmedGraph : null), + [navigationRuntimeActive, prewarmedGraph], + ) + const buildItemMoveTargetSceneSnapshot = useCallback( + (request: NavigationItemMoveRequest) => + measureNavigationPerf('navigation.itemMoveTargetSceneSnapshotMs', () => { + const sourceNode = sceneState.nodes[request.itemId] + const targetPosition = request.finalUpdate.position + const targetRotation = request.finalUpdate.rotation + + if ( + sourceNode?.type !== 'item' || + !targetPosition || + !targetRotation || + !Array.isArray(targetPosition) || + !Array.isArray(targetRotation) + ) { + return buildNavigationSceneSnapshot(sceneState.nodes, sceneState.rootNodeIds) + } + + const snapshotNodes: Record = { ...sceneState.nodes } + const targetParentId = + typeof request.finalUpdate.parentId === 'string' + ? request.finalUpdate.parentId + : (request.levelId ?? sourceNode.parentId) + + const updateParentChildren = ( + parentId: string | null | undefined, + transform: (children: string[]) => string[], + ) => { + if (!parentId) { + return + } + + const parentNode = snapshotNodes[parentId] + const parentChildren = (parentNode as { children?: string[] } | undefined)?.children + if (!(parentNode && Array.isArray(parentChildren))) { + return + } + + snapshotNodes[parentId] = { + ...parentNode, + children: transform(parentChildren), + } as AnyNode + } + + if (isNavigationCopyItemMoveRequest(request)) { + const plannedCopyId = `__navigation-planned-copy__:${request.itemId}` as ItemNode['id'] + snapshotNodes[plannedCopyId] = { + ...sourceNode, + id: plannedCopyId, + metadata: setItemMoveVisualMetadata(sourceNode.metadata, null) as ItemNode['metadata'], + parentId: targetParentId, + position: [...targetPosition] as [number, number, number], + rotation: [...targetRotation] as [number, number, number], + } as ItemNode as AnyNode + + updateParentChildren(targetParentId, (children) => + children.includes(plannedCopyId) ? children : [...children, plannedCopyId], + ) + } else { + const sourceParentId = sourceNode.parentId + snapshotNodes[sourceNode.id] = { + ...sourceNode, + metadata: setItemMoveVisualMetadata(sourceNode.metadata, null) as ItemNode['metadata'], + parentId: targetParentId, + position: [...targetPosition] as [number, number, number], + rotation: [...targetRotation] as [number, number, number], + } as ItemNode as AnyNode + + if (sourceParentId !== targetParentId) { + updateParentChildren(sourceParentId, (children) => + children.filter((childId) => childId !== sourceNode.id), + ) + updateParentChildren(targetParentId, (children) => + children.includes(sourceNode.id) ? children : [...children, sourceNode.id], + ) + } + } + + return buildNavigationSceneSnapshot(snapshotNodes, sceneState.rootNodeIds) + }), + [sceneState.nodes, sceneState.rootNodeIds], + ) + const cacheItemMovePreviewPlan = useCallback((plan: NavigationItemMovePreviewPlan) => { + const previewPlanCache = itemMovePreviewPlanCacheRef.current + previewPlanCache.delete(plan.cacheKey) + previewPlanCache.set(plan.cacheKey, plan) + while (previewPlanCache.size > ITEM_MOVE_PREVIEW_PLAN_CACHE_MAX_ENTRIES) { + const oldestKey = previewPlanCache.keys().next().value + if (oldestKey === undefined) { + break + } + previewPlanCache.delete(oldestKey) + } + }, []) + const cancelItemMovePreviewPlanWarmup = useCallback(() => { + if (itemMovePreviewPlanWarmTimeoutRef.current !== null) { + window.clearTimeout(itemMovePreviewPlanWarmTimeoutRef.current) + itemMovePreviewPlanWarmTimeoutRef.current = null + } + }, []) + const resolveItemMovePlan = useCallback( + ( + request: NavigationItemMoveRequest, + actorStartCellIndex: number, + actorNavigationPoint: [number, number, number] | null, + actorComponentIdOverride: number | null, + { + recordFallbackMeta = true, + targetGraphPerfMetricName = 'navigation.itemMoveTargetGraphBuildMs', + }: { + recordFallbackMeta?: boolean + targetGraphPerfMetricName?: string + } = {}, + ): ResolvedNavigationItemMovePlan | null => { + if (!graph) { + return null + } + + const nearestLiveActorCellIndexWithoutComponentFilter = + actorNavigationPoint !== null + ? findClosestNavigationCell( + graph, + actorNavigationPoint, + selection.levelId ?? toLevelNodeId(request.levelId) ?? undefined, + null, + ) + : null + + const targetPosition = request.finalUpdate.position + const targetRotation = request.finalUpdate.rotation ?? request.sourceRotation + if (!targetPosition || !targetRotation) { + return null + } + + const sourceFootprintBounds = extractObjectLocalFootprintBounds( + sceneRegistry.nodes.get(request.itemId) ?? null, + ) + + const sourceApproach = findItemMoveApproach( + graph, + { + dimensions: request.itemDimensions, + footprintBounds: sourceFootprintBounds, + levelId: request.levelId, + position: request.sourcePosition, + rotation: request.sourceRotation, + }, + actorComponentIdOverride, + actorStartCellIndex, + actorNavigationPoint, + ) + if (!sourceApproach) { + return null + } + + const sourcePath = findNavigationPath(graph, actorStartCellIndex, sourceApproach.cellIndex) + if (!sourcePath) { + return null + } + + const targetPlanningSnapshot = buildItemMoveTargetSceneSnapshot(request) + const targetPlanningGraph = getCachedNavigationGraphForSnapshot( + targetPlanningSnapshot, + targetGraphPerfMetricName, + ) + if (!targetPlanningGraph) { + return null + } + + const releasedSourceCellIndex = findClosestNavigationCell( + targetPlanningGraph, + sourceApproach.world, + toLevelNodeId(request.levelId), + null, + ) + if (releasedSourceCellIndex === null) { + return null + } + + const targetApproach = findItemMoveApproach( + targetPlanningGraph, + { + dimensions: request.itemDimensions, + footprintBounds: sourceFootprintBounds, + levelId: request.levelId, + position: targetPosition, + rotation: targetRotation, + }, + null, + releasedSourceCellIndex, + sourceApproach.world, + ) + if (!targetApproach) { + return null + } + + const targetPath = findNavigationPath( + targetPlanningGraph, + releasedSourceCellIndex, + targetApproach.cellIndex, + ) + if (!targetPath) { + return null + } + + const usedDerivedTargetGraph = false + let usedTargetGraphFallback = false + + let exitPath: NavigationPrecomputedExitPath | null = null + const exitPlan = pascalTruckIntroPlanRef.current + if (exitPlan && targetApproach && targetPlanningGraph) { + const exitTargetWorldPosition: [number, number, number] = [ + exitPlan.endPosition[0], + exitPlan.endPosition[1] - ACTOR_HOVER_Y, + exitPlan.endPosition[2], + ] + const exitTargetLevelId = + exitPlan.finalCellIndex !== null + ? (toLevelNodeId(targetPlanningGraph.cells[exitPlan.finalCellIndex]?.levelId) ?? + selection.levelId ?? + null) + : (selection.levelId ?? null) + const exitTargetCellIndex = findClosestNavigationCell( + targetPlanningGraph, + exitTargetWorldPosition, + exitTargetLevelId ?? undefined, + null, + ) + if (exitTargetCellIndex !== null) { + const exitPathResult = findNavigationPath( + targetPlanningGraph, + targetApproach.cellIndex, + exitTargetCellIndex, + ) + if (exitPathResult) { + exitPath = { + destinationCellIndex: exitTargetCellIndex, + pathResult: exitPathResult, + planningGraph: targetPlanningGraph, + targetWorldPosition: exitTargetWorldPosition, + } + } + } + } + + if (recordFallbackMeta) { + mergeNavigationPerfMeta({ + navigationItemMoveUsedDerivedTargetGraph: usedDerivedTargetGraph, + navigationItemMoveUsedTargetGraphFallback: usedTargetGraphFallback, + }) + } + + lastItemMovePlanDebugRef.current = { + actorComponentIdOverride, + actorNavigationPoint, + actorStartCellIndexWithoutComponentFilter: nearestLiveActorCellIndexWithoutComponentFilter, + actorStartCellCenter: graph.cells[actorStartCellIndex]?.center ?? null, + actorStartCellIndex, + exitPath: + exitPath === null + ? null + : { + destinationCellCenter: + exitPath.destinationCellIndex !== null + ? (exitPath.planningGraph.cells[exitPath.destinationCellIndex]?.center ?? null) + : null, + destinationCellIndex: exitPath.destinationCellIndex, + indices: [...exitPath.pathResult.indices], + planningGraphCellCount: exitPath.planningGraph.cells.length, + targetWorldPosition: exitPath.targetWorldPosition, + }, + graphCellCount: graph.cells.length, + request: { + finalPosition: targetPosition, + finalRotation: targetRotation, + itemId: request.itemId, + levelId: request.levelId, + sourcePosition: request.sourcePosition, + sourceRotation: request.sourceRotation, + }, + releasedSourceCellCenter: + targetPlanningGraph.cells[releasedSourceCellIndex]?.center ?? null, + releasedSourceCellIndex, + sourceApproach: { + cellCenter: graph.cells[sourceApproach.cellIndex]?.center ?? null, + cellIndex: sourceApproach.cellIndex, + world: sourceApproach.world, + }, + sourcePath: { + indices: [...sourcePath.indices], + length: sourcePath.indices.length, + }, + targetApproach: { + cellCenter: targetPlanningGraph.cells[targetApproach.cellIndex]?.center ?? null, + cellIndex: targetApproach.cellIndex, + world: targetApproach.world, + }, + targetPath: { + indices: [...targetPath.indices], + length: targetPath.indices.length, + }, + liveGraphCacheKey: prewarmedGraphStateKey, + liveGraphCurrent: navigationGraphCurrent, + navigationSceneSnapshotKey: summarizeDebugSnapshotKey(navigationSceneSnapshot?.key ?? null), + targetPlanningGraphCellCount: targetPlanningGraph.cells.length, + targetPlanningSnapshotKey: targetPlanningSnapshot.key, + usedDerivedTargetGraph, + usedTargetGraphFallback, + } + + return { + exitPath, + sourceApproach, + sourcePath, + targetApproach, + targetPath, + targetPlanningGraph, + } + }, + [ + buildItemMoveTargetSceneSnapshot, + getCachedNavigationGraphForSnapshot, + graph, + navigationGraphCurrent, + navigationSceneSnapshot?.key, + prewarmedGraphStateKey, + selection.levelId, + ], + ) + + useEffect(() => { + if (previousTaskQueuePlanningReadyRef.current === taskQueuePlanningReady) { + return + } + + previousTaskQueuePlanningReadyRef.current = taskQueuePlanningReady + appendTaskModeTrace('navigation.taskQueuePlanningReadyChanged', { + pendingTaskGraphSyncKey: summarizeDebugSnapshotKey(pendingTaskGraphSyncKey), + taskLoopSceneSettled, + taskQueueGraphSettled, + taskQueuePlanningReady, + }) + }, [pendingTaskGraphSyncKey, taskLoopSceneSettled, taskQueueGraphSettled, taskQueuePlanningReady]) + + useEffect(() => { + if (!enabled || robotMode !== 'task') { + taskLoopBaselineSnapshotKeyRef.current = null + pendingTaskLoopResetBeforeIntroRef.current = false + pendingTaskLoopIntroAfterResetTokenRef.current = null + pendingTaskLoopGraphSyncTokenRef.current = null + setPendingTaskGraphSyncKey(null) + return + } + + if (taskLoopBaselineSnapshotKeyRef.current === null && navigationSceneSnapshot?.key) { + taskLoopBaselineSnapshotKeyRef.current = navigationSceneSnapshot.key + } + + if (pendingTaskGraphSyncKey !== null && navigationGraphCurrent) { + setPendingTaskGraphSyncKey(null) + } + }, [ + enabled, + navigationGraphCurrent, + navigationSceneSnapshot?.key, + pendingTaskGraphSyncKey, + robotMode, + ]) + + useEffect(() => { + if (taskLoopToken === taskLoopSettledToken) { + pendingTaskLoopGraphSyncTokenRef.current = null + return + } + + if (!enabled || robotMode !== 'task') { + pendingTaskLoopGraphSyncTokenRef.current = null + useNavigation.getState().setTaskLoopSettledToken(taskLoopToken) + return + } + + const baselineSnapshotKey = + taskLoopBaselineSnapshotKeyRef.current ?? navigationSceneSnapshot?.key ?? null + if (!baselineSnapshotKey) { + pendingTaskLoopGraphSyncTokenRef.current = null + useNavigation.getState().setTaskLoopSettledToken(taskLoopToken) + return + } + + taskLoopBaselineSnapshotKeyRef.current = baselineSnapshotKey + if (navigationGraphCurrent && navigationSceneSnapshot?.key === baselineSnapshotKey) { + pendingTaskLoopGraphSyncTokenRef.current = null + if (pendingTaskGraphSyncKey !== null) { + setPendingTaskGraphSyncKey(null) + } + useNavigation.getState().setTaskLoopSettledToken(taskLoopToken) + return + } + + pendingTaskLoopGraphSyncTokenRef.current = taskLoopToken + if (pendingTaskGraphSyncKey !== baselineSnapshotKey) { + setPendingTaskGraphSyncKey(baselineSnapshotKey) + } + }, [ + enabled, + navigationGraphCurrent, + navigationSceneSnapshot?.key, + pendingTaskGraphSyncKey, + robotMode, + taskLoopSettledToken, + taskLoopToken, + ]) + + useEffect(() => { + const pendingTaskLoopGraphSyncToken = pendingTaskLoopGraphSyncTokenRef.current + if (pendingTaskLoopGraphSyncToken === null || pendingTaskGraphSyncKey !== null) { + return + } + + pendingTaskLoopGraphSyncTokenRef.current = null + useNavigation.getState().setTaskLoopSettledToken(pendingTaskLoopGraphSyncToken) + }, [pendingTaskGraphSyncKey]) + + useEffect(() => { + mergeNavigationPerfMeta({ + navigationCellCount: graph?.cells.length ?? 0, + navigationComponentCount: graph?.components.length ?? 0, + navigationDoorBridgeEdgeCount: graph?.doorBridgeEdgeCount ?? 0, + navigationLargestComponentSize: graph?.largestComponentSize ?? 0, + navigationStairSurfaceCount: graph?.stairSurfaceCount ?? 0, + navigationStairTransitionEdgeCount: graph?.stairTransitionEdgeCount ?? 0, + navigationWalkableCellCount: graph?.walkableCellCount ?? 0, + }) + }, [graph]) + + useEffect(() => { + const removeAfterEffect = addAfterEffect(() => { + mergeNavigationPerfMeta({ + navigationRenderCalls: gl.info.render.calls, + navigationRenderLines: gl.info.render.lines, + navigationRenderPoints: gl.info.render.points, + navigationRenderTriangles: gl.info.render.triangles, + }) + }) + + return removeAfterEffect + }, [gl]) + + useEffect(() => { + if (!NAVIGATION_AUDIT_DIAGNOSTICS_ENABLED) { + return + } + + const renderer = gl as typeof gl & { + backend?: { + __pascalOriginalCreateProgram?: (program: unknown) => unknown + __pascalOriginalCreateRenderPipeline?: ( + renderObject: unknown, + promises?: unknown, + ) => unknown + __pascalProfilePatched?: boolean + createProgram?: (program: unknown) => unknown + createRenderPipeline?: (renderObject: unknown, promises?: unknown) => unknown + device?: { + __pascalOriginalCreatePipelineLayout?: (descriptor: unknown) => unknown + __pascalOriginalCreateRenderPipeline?: (descriptor: unknown) => unknown + __pascalOriginalCreateRenderPipelineAsync?: (descriptor: unknown) => Promise + __pascalOriginalCreateShaderModule?: (descriptor: unknown) => unknown + __pascalProfilePatched?: boolean + createPipelineLayout?: (descriptor: unknown) => unknown + createRenderPipeline?: (descriptor: unknown) => unknown + createRenderPipelineAsync?: (descriptor: unknown) => Promise + createShaderModule?: (descriptor: unknown) => unknown + } | null + } | null + _pipelines?: { + __pascalOriginalGetForRender?: (renderObject: unknown, promises?: unknown) => unknown + __pascalProfilePatched?: boolean + _needsRenderUpdate?: (renderObject: unknown) => boolean + get?: (renderObject: unknown) => { pipeline?: Record } | undefined + getForRender?: (renderObject: unknown, promises?: unknown) => unknown + } + } + const backend = renderer.backend + const pipelines = renderer._pipelines + const device = backend?.device + if ( + !backend || + !pipelines || + !device || + typeof pipelines.getForRender !== 'function' || + typeof backend.createProgram !== 'function' || + typeof backend.createRenderPipeline !== 'function' || + typeof device.createShaderModule !== 'function' || + typeof device.createPipelineLayout !== 'function' || + typeof device.createRenderPipeline !== 'function' || + backend.__pascalProfilePatched || + pipelines.__pascalProfilePatched + ) { + return + } + + type RenderObjectDiagnostic = { + geometry?: { type?: string } | null + getNodeBuilderState?: () => { fragmentShader?: string; vertexShader?: string } | null + material?: { + id?: number + name?: string + side?: number + transparent?: boolean + type?: string + } | null + object?: { + id?: number + isSkinnedMesh?: boolean + morphTargetInfluences?: unknown + name?: string + type?: string + } | null + pipeline?: { + cacheKey?: string + fragmentProgram?: { id?: number; name?: string } | null + vertexProgram?: { id?: number; name?: string } | null + } | null + } + type ProgramDiagnostic = { + code?: string + id?: number + name?: string + stage?: string + } + + const compileContextStack: Array> = [] + const getCurrentCompileContext = () => { + const currentContext = compileContextStack[compileContextStack.length - 1] + return currentContext ? { ...currentContext } : null + } + const withCompileContext = (meta: Record, run: () => T) => { + compileContextStack.push(meta) + try { + return run() + } finally { + compileContextStack.pop() + } + } + const buildObjectHierarchyPath = (object: Object3D | null) => { + if (!object) { + return null + } + + const segments: string[] = [] + let current: Object3D | null = object + while (current) { + const label = + current.name && current.name.length > 0 ? current.name : current.type || 'Object3D' + segments.push(label) + current = current.parent + } + + return segments.reverse().join(' > ') + } + + const buildRenderObjectPerfMeta = ( + renderObject: unknown, + extraMeta?: Record, + ) => { + const renderObjectRecord = renderObject as RenderObjectDiagnostic + const object = (renderObjectRecord.object ?? null) as + | (Object3D & { + castShadow?: boolean + frustumCulled?: boolean + isSkinnedMesh?: boolean + morphTargetInfluences?: unknown + receiveShadow?: boolean + }) + | null + const material = renderObjectRecord.material ?? null + const geometry = renderObjectRecord.geometry ?? null + const pipeline = renderObjectRecord.pipeline ?? null + const objectId = typeof object?.id === 'number' ? object.id : null + const nodeBuilderState = + typeof renderObjectRecord.getNodeBuilderState === 'function' + ? renderObjectRecord.getNodeBuilderState() + : null + return { + actorRelated: objectId !== null ? actorObjectIdSetRef.current.has(objectId) : null, + cacheKey: + typeof pipeline?.cacheKey === 'string' && pipeline.cacheKey.length > 0 + ? pipeline.cacheKey + : null, + fragmentProgramId: + typeof pipeline?.fragmentProgram?.id === 'number' ? pipeline.fragmentProgram.id : null, + fragmentProgramName: pipeline?.fragmentProgram?.name ?? null, + fragmentShaderLength: + typeof nodeBuilderState?.fragmentShader === 'string' + ? nodeBuilderState.fragmentShader.length + : null, + geometryType: geometry?.type ?? null, + materialId: typeof material?.id === 'number' ? material.id : null, + materialName: material?.name || null, + materialSide: typeof material?.side === 'number' ? material.side : null, + materialTransparent: + typeof material?.transparent === 'boolean' ? material.transparent : null, + materialType: material?.type ?? null, + objectCastShadow: typeof object?.castShadow === 'boolean' ? object.castShadow : null, + objectExcludedFromOutline: object?.userData?.pascalExcludeFromOutline === true, + objectExcludedFromToolReveal: object?.userData?.pascalExcludeFromToolReveal === true, + objectFrustumCulled: + typeof object?.frustumCulled === 'boolean' ? object.frustumCulled : null, + objectHierarchyPath: buildObjectHierarchyPath(object), + objectId, + objectLayersMask: + object?.layers && typeof object.layers.mask === 'number' ? object.layers.mask : null, + objectName: object?.name || null, + objectReceiveShadow: + typeof object?.receiveShadow === 'boolean' ? object.receiveShadow : null, + objectRenderOrder: typeof object?.renderOrder === 'number' ? object.renderOrder : null, + objectSkinned: object?.isSkinnedMesh === true, + objectType: object?.type ?? null, + objectUsesMorphTargets: Array.isArray(object?.morphTargetInfluences), + objectVisible: typeof object?.visible === 'boolean' ? object.visible : null, + ...(extraMeta ?? {}), + vertexProgramId: + typeof pipeline?.vertexProgram?.id === 'number' ? pipeline.vertexProgram.id : null, + vertexProgramName: pipeline?.vertexProgram?.name ?? null, + vertexShaderLength: + typeof nodeBuilderState?.vertexShader === 'string' + ? nodeBuilderState.vertexShader.length + : null, + } + } + const buildProgramPerfMeta = (program: unknown, extraMeta?: Record) => { + const programRecord = program as ProgramDiagnostic + return { + codeLength: typeof programRecord.code === 'string' ? programRecord.code.length : null, + programId: typeof programRecord.id === 'number' ? programRecord.id : null, + programName: programRecord.name || null, + programStage: programRecord.stage || null, + ...(extraMeta ?? {}), + } + } + const recordCreateSample = ( + name: string, + startTimeMs: number, + meta: Record | null, + ) => { + recordNavigationPerfSample(name, performance.now() - startTimeMs, meta ?? undefined) + } + + const originalGetForRender = pipelines.getForRender.bind(pipelines) + const originalCreateProgram = backend.createProgram.bind(backend) + const originalBackendCreateRenderPipeline = backend.createRenderPipeline.bind(backend) + const originalCreateShaderModule = device.createShaderModule.bind(device) + const originalCreatePipelineLayout = device.createPipelineLayout.bind(device) + const originalDeviceCreateRenderPipeline = device.createRenderPipeline.bind(device) + const originalCreateRenderPipelineAsync = + typeof device.createRenderPipelineAsync === 'function' + ? device.createRenderPipelineAsync.bind(device) + : null + const lastActorPipelineSignatureByObjectId = new Map() + pipelines.__pascalOriginalGetForRender = originalGetForRender + backend.__pascalOriginalCreateProgram = originalCreateProgram + backend.__pascalOriginalCreateRenderPipeline = originalBackendCreateRenderPipeline + device.__pascalOriginalCreateShaderModule = originalCreateShaderModule + device.__pascalOriginalCreatePipelineLayout = originalCreatePipelineLayout + device.__pascalOriginalCreateRenderPipeline = originalDeviceCreateRenderPipeline + if (originalCreateRenderPipelineAsync) { + device.__pascalOriginalCreateRenderPipelineAsync = originalCreateRenderPipelineAsync + } + backend.__pascalProfilePatched = true + device.__pascalProfilePatched = true + pipelines.__pascalProfilePatched = true + + backend.createProgram = (program: unknown) => { + const programMeta = buildProgramPerfMeta(program, { + ...(getCurrentCompileContext() ?? {}), + contextKind: 'backend-create-program', + }) + return withCompileContext(programMeta, () => { + const startTimeMs = performance.now() + const result = originalCreateProgram(program) + recordCreateSample('navigation.webgpu.backendCreateProgramMs', startTimeMs, programMeta) + return result + }) + } + + backend.createRenderPipeline = (renderObject: unknown, promises?: unknown) => { + const renderMeta = buildRenderObjectPerfMeta(renderObject, { + contextKind: 'backend-create-render-pipeline', + }) + return withCompileContext(renderMeta, () => { + const startTimeMs = performance.now() + const result = originalBackendCreateRenderPipeline(renderObject, promises) + recordCreateSample( + 'navigation.webgpu.backendCreateRenderPipelineMs', + startTimeMs, + renderMeta, + ) + return result + }) + } + + device.createShaderModule = (descriptor: unknown) => { + const descriptorRecord = descriptor as { + code?: string + label?: string + } | null + const currentContext = getCurrentCompileContext() + const startTimeMs = performance.now() + const result = originalCreateShaderModule(descriptor) + recordCreateSample('navigation.webgpu.deviceCreateShaderModuleMs', startTimeMs, { + ...(currentContext ?? {}), + contextKind: 'device-create-shader-module', + descriptorLabel: descriptorRecord?.label ?? null, + descriptorShaderCodeLength: + typeof descriptorRecord?.code === 'string' ? descriptorRecord.code.length : null, + }) + return result + } + + device.createPipelineLayout = (descriptor: unknown) => { + const descriptorRecord = descriptor as { + bindGroupLayouts?: unknown[] + label?: string + } | null + const currentContext = getCurrentCompileContext() + const startTimeMs = performance.now() + const result = originalCreatePipelineLayout(descriptor) + recordCreateSample('navigation.webgpu.deviceCreatePipelineLayoutMs', startTimeMs, { + ...(currentContext ?? {}), + bindGroupLayoutCount: Array.isArray(descriptorRecord?.bindGroupLayouts) + ? descriptorRecord.bindGroupLayouts.length + : null, + contextKind: 'device-create-pipeline-layout', + descriptorLabel: descriptorRecord?.label ?? null, + }) + return result + } + + device.createRenderPipeline = (descriptor: unknown) => { + const descriptorRecord = descriptor as { + fragment?: { targets?: unknown[] } | null + label?: string + multisample?: { count?: number } | null + primitive?: { topology?: string } | null + } | null + const currentContext = getCurrentCompileContext() + const startTimeMs = performance.now() + const result = originalDeviceCreateRenderPipeline(descriptor) + recordCreateSample('navigation.webgpu.deviceCreateRenderPipelineMs', startTimeMs, { + ...(currentContext ?? {}), + contextKind: 'device-create-render-pipeline', + descriptorLabel: descriptorRecord?.label ?? null, + primitiveTopology: descriptorRecord?.primitive?.topology ?? null, + renderTargetCount: Array.isArray(descriptorRecord?.fragment?.targets) + ? descriptorRecord.fragment.targets.length + : null, + sampleCount: + typeof descriptorRecord?.multisample?.count === 'number' + ? descriptorRecord.multisample.count + : null, + }) + return result + } + + if (originalCreateRenderPipelineAsync) { + device.createRenderPipelineAsync = async (descriptor: unknown) => { + const descriptorRecord = descriptor as { + fragment?: { targets?: unknown[] } | null + label?: string + multisample?: { count?: number } | null + primitive?: { topology?: string } | null + } | null + const currentContext = getCurrentCompileContext() + const startTimeMs = performance.now() + try { + return await originalCreateRenderPipelineAsync(descriptor) + } finally { + recordCreateSample('navigation.webgpu.deviceCreateRenderPipelineAsyncMs', startTimeMs, { + ...(currentContext ?? {}), + contextKind: 'device-create-render-pipeline-async', + descriptorLabel: descriptorRecord?.label ?? null, + primitiveTopology: descriptorRecord?.primitive?.topology ?? null, + renderTargetCount: Array.isArray(descriptorRecord?.fragment?.targets) + ? descriptorRecord.fragment.targets.length + : null, + sampleCount: + typeof descriptorRecord?.multisample?.count === 'number' + ? descriptorRecord.multisample.count + : null, + }) + } + } + } + + pipelines.getForRender = (renderObject: unknown, promises?: unknown) => { + const pipelineProbe = pipelines as typeof pipelines & { + get: (renderObject: unknown) => { pipeline?: Record } | undefined + } + const dataBefore = + typeof pipelineProbe.get === 'function' ? (pipelineProbe.get(renderObject) ?? null) : null + const hadPipeline = Boolean(dataBefore?.pipeline) + const requiredUpdate = + typeof pipelines._needsRenderUpdate === 'function' + ? pipelines._needsRenderUpdate(renderObject) + : null + + const result = withCompileContext( + buildRenderObjectPerfMeta(renderObject, { + contextKind: 'pipelines-get-for-render', + }), + () => originalGetForRender(renderObject, promises), + ) + + const dataAfter = + typeof pipelineProbe.get === 'function' ? (pipelineProbe.get(renderObject) ?? null) : null + const createdPipeline = !hadPipeline && Boolean(dataAfter?.pipeline) + const updatedPipeline = + hadPipeline && + Boolean(dataBefore?.pipeline) && + Boolean(dataAfter?.pipeline) && + dataBefore?.pipeline !== dataAfter?.pipeline + + if (createdPipeline || updatedPipeline || requiredUpdate === true) { + const renderObjectRecord = renderObject as RenderObjectDiagnostic + const object = renderObjectRecord.object ?? null + const objectId = typeof object?.id === 'number' ? object.id : null + const material = renderObjectRecord.material ?? null + if (material?.name === 'ShadowMaterial') { + return result + } + const pipeline = (dataAfter?.pipeline ?? null) as { + cacheKey?: string + fragmentProgram?: { id?: number } + vertexProgram?: { id?: number } + } | null + const pipelineEvent = createdPipeline ? 'created' : updatedPipeline ? 'updated' : 'refresh' + const signature = JSON.stringify({ + cacheKey: + typeof pipeline?.cacheKey === 'string' && pipeline.cacheKey.length > 0 + ? pipeline.cacheKey + : null, + fragmentProgramId: + typeof pipeline?.fragmentProgram?.id === 'number' ? pipeline.fragmentProgram.id : null, + materialId: typeof material?.id === 'number' ? material.id : null, + objectId, + pipelineEvent, + vertexProgramId: + typeof pipeline?.vertexProgram?.id === 'number' ? pipeline.vertexProgram.id : null, + }) + const signatureKey = objectId ?? -1 + if (lastActorPipelineSignatureByObjectId.get(signatureKey) === signature) { + return result + } + lastActorPipelineSignatureByObjectId.set(signatureKey, signature) + recordNavigationPerfMark( + 'navigation.renderPipelineCreate', + buildRenderObjectPerfMeta(renderObject, { + pipelineEvent, + }), + ) + } + + return result + } + + return () => { + if (renderer.backend?.__pascalOriginalCreateProgram) { + renderer.backend.createProgram = renderer.backend.__pascalOriginalCreateProgram + } + if (renderer.backend?.__pascalOriginalCreateRenderPipeline) { + renderer.backend.createRenderPipeline = + renderer.backend.__pascalOriginalCreateRenderPipeline + } + if (renderer.backend?.device?.__pascalOriginalCreateShaderModule) { + renderer.backend.device.createShaderModule = + renderer.backend.device.__pascalOriginalCreateShaderModule + } + if (renderer.backend?.device?.__pascalOriginalCreatePipelineLayout) { + renderer.backend.device.createPipelineLayout = + renderer.backend.device.__pascalOriginalCreatePipelineLayout + } + if (renderer.backend?.device?.__pascalOriginalCreateRenderPipeline) { + renderer.backend.device.createRenderPipeline = + renderer.backend.device.__pascalOriginalCreateRenderPipeline + } + if (renderer.backend?.device?.__pascalOriginalCreateRenderPipelineAsync) { + renderer.backend.device.createRenderPipelineAsync = + renderer.backend.device.__pascalOriginalCreateRenderPipelineAsync + } + if (renderer.backend) { + delete renderer.backend.__pascalOriginalCreateProgram + delete renderer.backend.__pascalOriginalCreateRenderPipeline + delete renderer.backend.__pascalProfilePatched + } + if (renderer.backend?.device) { + delete renderer.backend.device.__pascalOriginalCreateShaderModule + delete renderer.backend.device.__pascalOriginalCreatePipelineLayout + delete renderer.backend.device.__pascalOriginalCreateRenderPipeline + delete renderer.backend.device.__pascalOriginalCreateRenderPipelineAsync + delete renderer.backend.device.__pascalProfilePatched + } + if ( + renderer._pipelines && + typeof renderer._pipelines.__pascalOriginalGetForRender === 'function' + ) { + renderer._pipelines.getForRender = renderer._pipelines.__pascalOriginalGetForRender + } + if (renderer._pipelines) { + delete renderer._pipelines.__pascalOriginalGetForRender + delete renderer._pipelines.__pascalProfilePatched + } + } + }, [gl]) + + const [actorCellIndex, setActorCellIndex] = useState(null) + const [actorMoving, setActorMoving] = useState(false) + const [pathIndices, setPathIndices] = useState([]) + const [pathAnchorWorldPosition, setPathAnchorWorldPosition] = useState< + [number, number, number] | null + >(null) + const [pathTargetWorldPosition, setPathTargetWorldPosition] = useState< + [number, number, number] | null + >(null) + const [pathGraphOverride, setPathGraphOverride] = useState(null) + const pathGraph = pathGraphOverride ?? graph + + const actorGroupRef = useRef(null) + const actorObjectIdSetRef = useRef>(new Set()) + const debugDoorTransitionsRef = useRef([]) + const debugPathCurveRef = useRef | null>(null) + const trajectoryDebugOpaqueRef = useRef(false) + const trajectoryDebugDistanceRef = useRef(null) + const trajectoryDebugModeRef = useRef<'fade' | 'hidden' | 'live' | 'opaque'>('live') + const trajectoryDebugPauseRef = useRef(false) + const basePathShaderRef = useRef(null) + const highlightPathShaderRef = useRef(null) + const orbitPathShaderARef = useRef(null) + const orbitPathShaderBRef = useRef(null) + const lastItemMovePlanDebugRef = useRef | null>(null) + const lastCommittedPathDebugRef = useRef | null>(null) + const lastPublishedActorPositionRef = useRef<[number, number, number] | null>(null) + const lastPublishedActorPositionAtRef = useRef(0) + const raycasterRef = useRef(new Raycaster()) + const pointerRef = useRef(new Vector2()) + const motionRef = useRef(createActorMotionState()) + const motionWriteSourceRef = useRef('initial') + const pendingMotionRef = useRef<{ + destinationCellIndex: number | null + moving: boolean + speed: number + } | null>(null) + const doorCollisionStateRef = useRef<{ + blocked: boolean + doorIds: string[] + }>({ + blocked: false, + doorIds: [], + }) + const itemDeleteSequenceRef = useRef(null) + const itemMoveSequenceRef = useRef(null) + const itemMovePreviewPlanRef = useRef(null) + const itemMovePreviewPlanCacheRef = useRef(new Map()) + const itemMovePreviewPlanWarmTimeoutRef = useRef(null) + const itemRepairSequenceRef = useRef(null) + const itemMoveStageHistoryRef = useRef>([]) + const itemMoveTraceCooldownFramesRef = useRef(0) + const itemMoveTraceGhostBaselineRef = useRef<[number, number, number] | null>(null) + const itemMoveTraceSourceBaselineRef = useRef<[number, number, number] | null>(null) + const itemMoveTraceSourceIdRef = useRef(null) + const itemMoveFrameTraceRef = useRef([]) + const carriedVisualItemIdRef = useRef(null) + const pascalTruckIntroPlanRef = useRef>(null) + const toolInteractionPhaseRef = useRef(null) + const toolInteractionTargetItemIdRef = useRef(null) + const actorPositionInitializedRef = useRef(false) + const actorRobotDebugStateRef = useRef | null>(null) + const introAnimationTraceCaptureActiveRef = useRef(false) + const pascalTruckIntroRef = useRef(null) + const pascalTruckExitRef = useRef(null) + const pascalTruckIntroPlaybackTokenRef = useRef(0) + const pascalTruckIntroPendingSettlePositionRef = useRef<[number, number, number] | null>(null) + const shadowControllerRef = useRef({ + currentAutoUpdate: null as boolean | null, + currentEnabled: null as boolean | null, + dynamicSettleFrames: STATIC_SHADOW_SCENE_WARMUP_FRAMES, + lastDynamicUpdateAtMs: 0, + }) + const actorRenderVisibleOverrideRef = useRef(null) + const robotSkinnedMeshVisibleOverrideRef = useRef(null) + const robotStaticMeshVisibleOverrideRef = useRef(null) + const robotToolAttachmentsVisibleOverrideRef = useRef(null) + const robotMaterialDebugModeOverrideRef = useRef(null) + const shadowMapOverrideEnabledRef = useRef(null) + const [itemMoveForcedClipPlayback, setItemMoveForcedClipPlayback] = + useState(null) + const [introAnimationDebugActive, setIntroAnimationDebugActive] = useState(false) + const [pascalTruckIntroActive, setPascalTruckIntroActive] = useState(false) + const [pascalTruckExitActive, setPascalTruckExitActive] = useState(false) + const [pascalTruckIntroCompleted, setPascalTruckIntroCompleted] = useState(false) + const [pascalTruckIntroTaskReady, setPascalTruckIntroTaskReady] = useState(false) + const [actorRobotWarmupReady, setActorRobotWarmupReady] = useState(false) + const actorRobotWarmupReadyRef = useRef(false) + const [toolCarryItemId, setToolCarryItemId] = useState(null) + const pascalTruckIntroTaskReadyTimeoutRef = useRef(null) + const pascalTruckIntroPostWarmupTokenRef = useRef(null) + const navigationPostWarmupCameraPositionRef = useRef(new Vector3()) + const navigationPostWarmupCameraQuaternionRef = useRef(new Quaternion()) + const navigationPostWarmupPendingCameraSignatureRef = useRef(null) + const navigationPostWarmupPendingCameraSinceRef = useRef(0) + const navigationPostWarmupCameraSignatureRef = useRef('uninitialized') + const [navigationPostWarmupCameraSignature, setNavigationPostWarmupCameraSignature] = + useState('uninitialized') + const pendingPascalTruckExitRef = useRef(null) + const precomputedPascalTruckExitRef = useRef(null) + const deferredItemMoveCommitFrameRef = useRef(null) + const deferredItemMoveCommitIdleRef = useRef(null) + const deferredItemMoveCommitTimeoutRef = useRef(null) + const previousRobotModeRef = useRef(robotMode) + const processedQueueRestartTokenRef = useRef(queueRestartToken) + const taskLoopBaselineSnapshotKeyRef = useRef(null) + const pendingTaskLoopResetBeforeIntroRef = useRef(false) + const pendingTaskLoopIntroAfterResetTokenRef = useRef(null) + const pendingTaskLoopGraphSyncTokenRef = useRef(null) + const taskQueueSyncedMoveVisualStatesRef = useRef>>( + {}, + ) + const taskQueueSyncedDeleteIdsRef = useRef>(new Set()) + const debugPascalTruckIntroAttemptCountRef = useRef(0) + const debugPascalTruckIntroStartCountRef = useRef(0) + const normalRobotRuntimeActive = + robotMode === 'normal' && + pascalTruckIntroCompleted && + actorCellIndex !== null && + Boolean(graph?.cells[actorCellIndex]) + const shouldForceContinuousFrames = + enabled && + robotMode !== null && + (pascalTruckIntroActive || + pascalTruckExitActive || + normalRobotRuntimeActive || + actorMoving || + taskQueue.length > 0 || + itemMoveRequest !== null || + itemDeleteRequest !== null || + itemRepairRequest !== null) + + useEffect(() => { + setThreeState({ frameloop: shouldForceContinuousFrames ? 'always' : 'demand' }) + }, [setThreeState, shouldForceContinuousFrames]) + + const clearNavigationItemMoveVisualResidue = useCallback( + ( + request: NavigationItemMoveRequest | null, + options?: { preserveDestinationGhost?: boolean }, + ) => { + const viewerState = useViewer.getState() + const navigationVisuals = navigationVisualsStore.getState() + const preview = navigationVisuals.itemMovePreview + const previewSelectedIds = [...viewerState.previewSelectedIds] + const visualIdsToClear = new Set() + const preserveLiveTransformIds = new Set() + const removedTransientPreviewIds: string[] = [] + const preservedDestinationGhostId = + options?.preserveDestinationGhost === true ? (request?.targetPreviewItemId ?? null) : null + + for (const previewId of previewSelectedIds) { + if (previewId && previewId !== preservedDestinationGhostId) { + visualIdsToClear.add(previewId) + } + } + + if (request) { + visualIdsToClear.add(request.itemId) + visualIdsToClear.add(getNavigationItemMoveVisualItemId(request)) + if ( + request.targetPreviewItemId && + request.targetPreviewItemId !== preservedDestinationGhostId + ) { + visualIdsToClear.add(request.targetPreviewItemId) + } + preserveLiveTransformIds.add(request.itemId) + preserveLiveTransformIds.add(getNavigationItemMoveVisualItemId(request)) + } + + if ( + preview && + (!request || + preview.sourceItemId === request.itemId || + preview.id === request.targetPreviewItemId || + preview.id === getNavigationItemMoveVisualItemId(request)) + ) { + if (preview.id !== preservedDestinationGhostId) { + visualIdsToClear.add(preview.id) + } + visualIdsToClear.add(preview.sourceItemId) + navigationVisuals.setItemMovePreview(null) + } + + if (previewSelectedIds.length > 0) { + viewerState.setPreviewSelectedIds([]) + } + + if (carriedVisualItemIdRef.current) { + visualIdsToClear.add(carriedVisualItemIdRef.current) + } + + for (const visualId of visualIdsToClear) { + if (!visualId) { + continue + } + + navigationVisuals.setItemMoveVisualState(visualId, null) + navigationVisuals.setNodeVisibilityOverride(visualId, null) + if (!preserveLiveTransformIds.has(visualId)) { + useLiveTransforms.getState().clear(visualId) + } + clearRuntimeItemMoveVisualState(visualId) + } + + if (request) { + clearRuntimeItemMoveVisualState(request.itemId) + clearRuntimeItemMoveVisualState(request.visualItemId) + if ( + request.targetPreviewItemId && + request.targetPreviewItemId !== preservedDestinationGhostId + ) { + if (isNavigationTaskPreviewNodeId(request.targetPreviewItemId)) { + removedTransientPreviewIds.push(request.targetPreviewItemId) + useScene.getState().deleteNode(request.targetPreviewItemId as AnyNodeId) + unregisterNavigationTaskPreviewNode(request.targetPreviewItemId) + } + } + } + + if (preservedDestinationGhostId) { + registerNavigationTaskPreviewNode(preservedDestinationGhostId) + navigationVisuals.setItemMoveVisualState(preservedDestinationGhostId, 'destination-ghost') + } + + appendTaskModeTrace('navigation.clearItemMoveVisualResidue', { + itemId: request?.itemId ?? null, + preservedDestinationGhostId, + removedTransientPreviewIds, + visualIdsCleared: [...visualIdsToClear], + }) + }, + [], + ) + + const resetTaskQueueVisuals = useCallback(() => { + const viewerState = useViewer.getState() + const navigationState = useNavigation.getState() + appendTaskModeTrace('navigation.resetTaskQueueVisualsStart', { + activeTaskId: navigationState.activeTaskId, + queueLength: navigationState.taskQueue.length, + }) + if (viewerState.previewSelectedIds.length > 0) { + viewerState.setPreviewSelectedIds([]) + } + viewerState.setHoveredId(null) + viewerState.outliner.selectedObjects.length = 0 + viewerState.outliner.hoveredObjects.length = 0 + + const queuedMoveRequests = navigationState.taskQueue + .filter( + (task): task is Extract<(typeof navigationState.taskQueue)[number], { kind: 'move' }> => + task.kind === 'move', + ) + .map((task) => task.request) + const moveRequestsToClear = navigationState.itemMoveRequest + ? [...queuedMoveRequests, navigationState.itemMoveRequest] + : queuedMoveRequests + + for (const request of moveRequestsToClear) { + const visualIds = new Set() + visualIds.add(request.itemId) + visualIds.add(getNavigationItemMoveVisualItemId(request)) + if (request.visualItemId) { + visualIds.add(request.visualItemId) + } + if (request.targetPreviewItemId) { + visualIds.add(request.targetPreviewItemId) + } + + for (const visualId of visualIds) { + navigationVisualsStore.getState().setItemMoveVisualState(visualId, null) + navigationVisualsStore.getState().setNodeVisibilityOverride(visualId, null) + useLiveTransforms.getState().clear(visualId) + clearRuntimeItemMoveVisualState(visualId) + removeTransientNavigationPreviewNode(visualId) + } + } + + clearNavigationItemMoveVisualResidue(null) + navigationVisualsStore.getState().resetTaskQueueVisuals() + taskQueueSyncedMoveVisualStatesRef.current = {} + taskQueueSyncedDeleteIdsRef.current = new Set() + appendTaskModeTrace('navigation.resetTaskQueueVisualsComplete', { + activeTaskId: navigationState.activeTaskId, + queueLength: navigationState.taskQueue.length, + }) + }, [clearNavigationItemMoveVisualResidue]) + + const getTaskModeSnapshot = useCallback( + (label = 'snapshot') => { + const navigationState = useNavigation.getState() + const visualState = navigationVisualsStore.getState() + const sceneNodes = useScene.getState().nodes as Record + const relevantIds = new Set() + const queueTasks = navigationState.taskQueue.map((task) => { + const derivedKind = getNavigationQueuedTaskVisualKind(task) + const moveRequest = task.kind === 'move' ? task.request : null + const sourceId = task.request.itemId + const previewId = moveRequest?.targetPreviewItemId ?? null + const visualId = moveRequest?.visualItemId ?? null + + relevantIds.add(sourceId) + if (previewId) { + relevantIds.add(previewId) + } + if (visualId && visualId !== sourceId) { + relevantIds.add(visualId) + } + + return { + derivedKind, + itemId: sourceId, + previewId, + sourcePosition: task.request.sourcePosition, + taskId: task.taskId, + visualId, + } + }) + + const nodeSummaries = Array.from(relevantIds).map((id) => { + const node = sceneNodes[id] + return { + id, + isTaskPreview: isNavigationTaskPreviewNodeId(id), + liveTransform: useLiveTransforms.getState().get(id) ?? null, + nodeType: node?.type ?? null, + sceneVisible: + node && typeof node === 'object' && 'visible' in node + ? ((node as ItemNode).visible ?? null) + : null, + viewerVisibilityOverride: visualState.nodeVisibilityOverrides[id] ?? null, + visualState: visualState.itemMoveVisualStates[id] ?? null, + } + }) + return { + activeTaskId: navigationState.activeTaskId, + actorAvailable: navigationState.actorAvailable, + actorCellIndex, + actorMoving, + actorRobotWarmupReady, + itemMoveSequenceStage: itemMoveSequenceRef.current?.stage ?? null, + label, + nodeSummaries, + pascalTruckExitActive, + pascalTruckIntroActive: Boolean(pascalTruckIntroRef.current), + pascalTruckIntroCompleted, + pascalTruckIntroTaskReady, + pascalTruckIntroWarmupWaitElapsedMs: + pascalTruckIntroRef.current?.warmupWaitElapsedMs ?? null, + pendingMotion: + pendingMotionRef.current === null + ? null + : { + destinationCellIndex: pendingMotionRef.current.destinationCellIndex, + moving: pendingMotionRef.current.moving, + speed: pendingMotionRef.current.speed, + }, + pendingTaskGraphSyncKey: summarizeDebugSnapshotKey(pendingTaskGraphSyncKey), + queueRestartToken: navigationState.queueRestartToken, + queueTasks, + robotMode: navigationState.robotMode, + taskQueueSourceMarkers: getTaskQueueSourceMarkerSpecs( + navigationState.taskQueue, + navigationState.activeTaskId, + navigationState.enabled, + navigationState.robotMode, + ), + taskLoopSettledToken: navigationState.taskLoopSettledToken, + taskLoopToken: navigationState.taskLoopToken, + taskQueuePlanningReady, + toolCarryItemId: carriedVisualItemIdRef.current ?? toolCarryItemId, + toolConeIsolatedOverlayHullPointCount: + visualState.toolConeIsolatedOverlay?.hullPoints.length ?? 0, + toolConeIsolatedOverlayVisible: visualState.toolConeIsolatedOverlay?.visible ?? false, + toolInteractionPhase: toolInteractionPhaseRef.current, + toolInteractionTargetItemId: toolInteractionTargetItemIdRef.current, + } + }, + [ + actorCellIndex, + actorMoving, + actorRobotWarmupReady, + pascalTruckExitActive, + pascalTruckIntroCompleted, + pascalTruckIntroTaskReady, + pendingTaskGraphSyncKey, + taskQueuePlanningReady, + toolCarryItemId, + ], + ) + + const recordTaskModeTrace = useCallback( + ( + type: string, + payload: Record = {}, + options?: { includeSnapshot?: boolean; label?: string }, + ) => { + void getTaskModeSnapshot + void options + appendTaskModeTrace(type, { + ...payload, + }) + }, + [getTaskModeSnapshot], + ) + + const actorComponentId = + graph && actorCellIndex !== null ? (graph.componentIdByCell[actorCellIndex] ?? null) : null + const actorCell = graph && actorCellIndex !== null ? graph.cells[actorCellIndex] : null + const defaultActorSpawnPosition = useMemo( + () => + actorCell + ? ([actorCell.center[0], actorCell.center[1] + ACTOR_HOVER_Y, actorCell.center[2]] as [ + number, + number, + number, + ]) + : null, + [actorCell], + ) + const pascalTruckIntroPlan = useMemo( + () => + prewarmedGraph + ? buildPascalTruckIntroState(prewarmedGraph, sceneState.nodes, selection.levelId) + : null, + [prewarmedGraph, sceneState.nodes, selection.levelId], + ) + useEffect(() => { + pascalTruckIntroPlanRef.current = pascalTruckIntroPlan + }, [pascalTruckIntroPlan]) + + useEffect(() => { + const navigationVisuals = navigationVisualsStore.getState() + const previousMoveVisualStates = taskQueueSyncedMoveVisualStatesRef.current + const previousDeleteIds = taskQueueSyncedDeleteIdsRef.current + const nextMoveVisualStates: Partial> = {} + const nextDeleteIds = new Set() + const moveTaskVisualRequests = new Map() + const moveSourceIds = new Set() + + if (enabled && robotMode === 'task') { + for (const task of taskQueue) { + const taskVisualKind = getNavigationQueuedTaskVisualKind(task) + if (task.kind === 'move') { + moveTaskVisualRequests.set(task.taskId, task.request) + moveSourceIds.add(task.request.itemId) + } + } + + if (activeTaskId) { + const activeTask = taskQueue.find((task) => task.taskId === activeTaskId) ?? null + if (activeTask?.kind === 'move') { + moveTaskVisualRequests.set(activeTask.taskId, activeTask.request) + moveSourceIds.add(activeTask.request.itemId) + } else if (activeTask) { + const activeTaskVisualKind = getNavigationQueuedTaskVisualKind(activeTask) + if (activeTaskVisualKind === 'delete') { + nextDeleteIds.add(activeTask.request.itemId) + } + } + } + + for (const request of moveTaskVisualRequests.values()) { + navigationVisuals.setItemMoveVisualState(request.itemId, null) + clearRuntimeItemMoveVisualState(request.itemId) + const queuedGhostIds = new Set() + const ensuredGhostId = ensureQueuedNavigationMoveGhostNode(request) + if (ensuredGhostId) { + queuedGhostIds.add(ensuredGhostId) + } + if (request.targetPreviewItemId) { + queuedGhostIds.add(request.targetPreviewItemId) + } + + for (const ghostId of queuedGhostIds) { + nextMoveVisualStates[ghostId] = 'destination-ghost' + } + } + + for (const sourceItemId of moveSourceIds) { + navigationVisuals.setItemMoveVisualState(sourceItemId, null) + clearRuntimeItemMoveVisualState(sourceItemId) + } + } + + for (const [itemId, previousState] of Object.entries(previousMoveVisualStates)) { + const nextState = nextMoveVisualStates[itemId] ?? null + const currentState = navigationVisualsStore.getState().itemMoveVisualStates[itemId] ?? null + if (nextState === previousState && currentState === nextState) { + continue + } + + if (currentState === previousState) { + navigationVisuals.setItemMoveVisualState(itemId, nextState) + } + if (nextState === null) { + clearRuntimeItemMoveVisualState(itemId) + if (previousState === 'destination-ghost') { + removeTransientNavigationPreviewNode(itemId) + } + } + } + + for (const [itemId, nextState] of Object.entries(nextMoveVisualStates)) { + if (!nextState) { + continue + } + + const previousState = previousMoveVisualStates[itemId] ?? null + const currentState = navigationVisualsStore.getState().itemMoveVisualStates[itemId] ?? null + if (previousState === nextState && currentState === nextState) { + continue + } + + if ( + currentState === null || + currentState === 'copy-source-pending' || + currentState === 'destination-ghost' || + currentState === 'destination-preview' || + currentState === 'source-pending' + ) { + navigationVisuals.setItemMoveVisualState(itemId, nextState) + } + setRuntimeItemMoveVisualState(itemId, nextState) + } + + for (const itemId of previousDeleteIds) { + if (!nextDeleteIds.has(itemId)) { + navigationVisuals.clearItemDelete(itemId) + } + } + + for (const itemId of nextDeleteIds) { + if (!navigationVisuals.itemDeleteActivations[itemId]) { + navigationVisuals.activateItemDelete(itemId) + } + } + + taskQueueSyncedMoveVisualStatesRef.current = nextMoveVisualStates + taskQueueSyncedDeleteIdsRef.current = nextDeleteIds + recordTaskModeTrace( + 'navigation.taskQueueVisualSync', + { + activeTaskId, + deleteCount: nextDeleteIds.size, + ghostIds: Object.entries(nextMoveVisualStates) + .filter(([, state]) => state === 'destination-ghost') + .map(([id]) => id), + moveVisualCount: Object.keys(nextMoveVisualStates).length, + queueLength: taskQueue.length, + }, + { includeSnapshot: true }, + ) + }, [ + activeTaskId, + enabled, + recordTaskModeTrace, + robotMode, + taskLoopSettledToken, + taskLoopToken, + taskQueue, + ]) + + const taskQueueSourceMarkerSpecs = useMemo( + () => getTaskQueueSourceMarkerSpecs(taskQueue, activeTaskId, enabled, robotMode), + [activeTaskId, enabled, robotMode, taskQueue], + ) + + const actorSpawnPosition = + enabled && + (pascalTruckIntroActive || introAnimationDebugActive) && + !pascalTruckIntroCompleted && + pascalTruckIntroPlan + ? pascalTruckIntroPlan.startPosition + : defaultActorSpawnPosition + const pascalTruckEntryClipPlayback = useMemo( + () => + pascalTruckIntroActive || introAnimationDebugActive + ? { + clipName: PASCAL_TRUCK_ENTRY_CLIP_NAME, + holdLastFrame: true, + loop: 'once' as const, + playbackToken: pascalTruckIntroPlaybackTokenRef.current, + revealFromStart: true, + timeScale: 1, + } + : null, + [introAnimationDebugActive, pascalTruckIntroActive], + ) + const actorForcedClipPlayback = pascalTruckEntryClipPlayback ?? itemMoveForcedClipPlayback + useEffect(() => { + const actorObjectIds = new Set() + actorGroupRef.current?.traverse((object) => { + if (typeof object.id === 'number') { + actorObjectIds.add(object.id) + } + }) + actorObjectIdSetRef.current = actorObjectIds + }, [actorRobotWarmupReady]) + + useEffect(() => { + const actorGroup = actorGroupRef.current + if (!actorGroup) { + return + } + + actorGroup.userData.pascalNavigationActorRoot = true + return () => { + delete actorGroup.userData.pascalNavigationActorRoot + } + }, []) + + useEffect(() => { + const warmupScope = async (run: () => void | Promise) => { + const actorGroup = actorGroupRef.current + if (!actorGroup) { + return false + } + + const introPlan = pascalTruckIntroPlanRef.current + const previousVisible = actorGroup.visible + const previousPosition = actorGroup.position.clone() + const previousRotationY = actorGroup.rotation.y + const shadowMap = (gl as typeof gl & { shadowMap?: RendererShadowMap }).shadowMap + const previousShadowAutoUpdate = shadowMap?.autoUpdate ?? null + const previousShadowEnabled = shadowMap?.enabled ?? null + const previousShadowNeedsUpdate = shadowMap?.needsUpdate ?? null + actorGroup.visible = true + if (introPlan) { + actorGroup.position.set( + introPlan.startPosition[0], + introPlan.startPosition[1], + introPlan.startPosition[2], + ) + actorGroup.rotation.y = introPlan.rotationY + } + if (shadowMap) { + shadowMap.enabled = true + shadowMap.autoUpdate = false + shadowMap.needsUpdate = true + } + try { + actorGroup.updateMatrixWorld(true) + await run() + return true + } finally { + actorGroup.visible = previousVisible + actorGroup.position.copy(previousPosition) + actorGroup.rotation.y = previousRotationY + if (shadowMap) { + shadowMap.autoUpdate = previousShadowAutoUpdate ?? shadowMap.autoUpdate + shadowMap.enabled = previousShadowEnabled ?? shadowMap.enabled + shadowMap.needsUpdate = previousShadowNeedsUpdate ?? false + } + actorGroup.updateMatrixWorld(true) + } + } + + navigationVisualsStore.getState().setNavigationPostWarmupScope(warmupScope) + return () => { + if (navigationVisualsStore.getState().navigationPostWarmupScope === warmupScope) { + navigationVisualsStore.getState().setNavigationPostWarmupScope(null) + } + } + }, [gl]) + + useFrame(() => { + if (navigationRuntimeActive) { + return + } + + camera.getWorldPosition(navigationPostWarmupCameraPositionRef.current) + camera.getWorldQuaternion(navigationPostWarmupCameraQuaternionRef.current) + const position = navigationPostWarmupCameraPositionRef.current + const quaternion = navigationPostWarmupCameraQuaternionRef.current + const cameraSignature = [ + roundWarmupCameraValue(position.x), + roundWarmupCameraValue(position.y), + roundWarmupCameraValue(position.z), + roundWarmupCameraValue(quaternion.x), + roundWarmupCameraValue(quaternion.y), + roundWarmupCameraValue(quaternion.z), + roundWarmupCameraValue(quaternion.w), + 'fov' in camera ? roundWarmupCameraValue(camera.fov) : 0, + 'zoom' in camera ? roundWarmupCameraValue(camera.zoom) : 1, + ].join(',') + + const now = performance.now() + if (cameraSignature !== navigationPostWarmupPendingCameraSignatureRef.current) { + navigationPostWarmupPendingCameraSignatureRef.current = cameraSignature + navigationPostWarmupPendingCameraSinceRef.current = now + return + } + + if (cameraDragging) { + return + } + + if ( + now - navigationPostWarmupPendingCameraSinceRef.current < + NAVIGATION_POST_WARMUP_CAMERA_STABLE_MS + ) { + return + } + + if (navigationPostWarmupCameraSignatureRef.current === cameraSignature) { + return + } + + navigationPostWarmupCameraSignatureRef.current = cameraSignature + startTransition(() => { + setNavigationPostWarmupCameraSignature(cameraSignature) + }) + }) + + const navigationPostWarmupIntroSignature = pascalTruckIntroPlan + ? [ + ...pascalTruckIntroPlan.startPosition.map(roundWarmupCameraValue), + roundWarmupCameraValue(pascalTruckIntroPlan.rotationY), + ].join(',') + : 'no-intro-plan' + const navigationPostWarmupRequestKey = [ + actorRobotWarmupReady ? '1' : '0', + navigationPostWarmupCameraSignature, + navigationPostWarmupIntroSignature, + selection.buildingId ?? 'null', + sceneState.rootNodeIds.join('|'), + ].join('::') + const lastNavigationPostWarmupRequestKeyRef = useRef(null) + useEffect(() => { + if ( + !( + actorRobotWarmupReady && + actorGroupRef.current && + !navigationRuntimeActive && + navigationPostWarmupCameraSignature !== 'uninitialized' + ) + ) { + return + } + + if (lastNavigationPostWarmupRequestKeyRef.current === navigationPostWarmupRequestKey) { + return + } + + lastNavigationPostWarmupRequestKeyRef.current = navigationPostWarmupRequestKey + const token = navigationVisualsStore.getState().requestNavigationPostWarmup() + recordNavigationPerfMark('navigation.postWarmupRequest', { + token, + trigger: 'baseline', + }) + }, [ + actorRobotWarmupReady, + navigationPostWarmupCameraSignature, + navigationPostWarmupRequestKey, + navigationRuntimeActive, + ]) + + const getResolvedActorWorldPosition = useCallback(() => { + const pendingPascalTruckIntroSettlePosition = pascalTruckIntroPendingSettlePositionRef.current + if (pendingPascalTruckIntroSettlePosition) { + return pendingPascalTruckIntroSettlePosition + } + + const actorGroup = actorGroupRef.current + if (actorGroup && actorPositionInitializedRef.current) { + return [actorGroup.position.x, actorGroup.position.y, actorGroup.position.z] as [ + number, + number, + number, + ] + } + + return lastPublishedActorPositionRef.current ?? actorSpawnPosition + }, [actorSpawnPosition]) + const getResolvedActorVisualWorldPosition = useCallback(() => { + const pendingPascalTruckIntroSettlePosition = pascalTruckIntroPendingSettlePositionRef.current + if (pendingPascalTruckIntroSettlePosition) { + return pendingPascalTruckIntroSettlePosition + } + + const actorGroup = actorGroupRef.current + if (actorGroup && actorPositionInitializedRef.current) { + return [ + actorGroup.position.x + motionRef.current.rootMotionOffset[0], + actorGroup.position.y, + actorGroup.position.z + motionRef.current.rootMotionOffset[2], + ] as [number, number, number] + } + + return lastPublishedActorPositionRef.current ?? actorSpawnPosition + }, [actorSpawnPosition]) + const getActorNavigationPlanningState = useCallback( + (planningGraph: NavigationGraph, preferredLevelId?: LevelNode['id'] | null) => { + // Re-derive the actor on the current planning graph so task-mode graph rebuilds + // cannot reuse a stale cell/component pair from the previous graph. + const actorWorldPosition = getResolvedActorVisualWorldPosition() + const actorNavigationPoint = actorWorldPosition + ? ([ + actorWorldPosition[0], + actorWorldPosition[1] - ACTOR_HOVER_Y, + actorWorldPosition[2], + ] as [number, number, number]) + : null + const actorStartCellIndexWithoutComponentFilter = + actorNavigationPoint !== null + ? findClosestNavigationCell( + planningGraph, + actorNavigationPoint, + preferredLevelId ?? undefined, + null, + ) + : planningGraph === graph + ? actorCellIndex + : null + const actorStartCellIndex = + actorStartCellIndexWithoutComponentFilter ?? + (planningGraph === graph ? actorCellIndex : null) + const actorStartComponentId = + actorStartCellIndex !== null + ? (planningGraph.componentIdByCell[actorStartCellIndex] ?? null) + : null + const actorStartLevelId = + preferredLevelId ?? + (actorStartCellIndex !== null + ? (toLevelNodeId(planningGraph.cells[actorStartCellIndex]?.levelId) ?? null) + : null) + + return { + actorNavigationPoint, + actorStartCellIndex, + actorStartCellIndexWithoutComponentFilter, + actorStartComponentId, + actorStartLevelId, + } + }, + [actorCellIndex, getResolvedActorVisualWorldPosition, graph], + ) + + useEffect(() => { + setIntroAnimationDebugActive(false) + }, []) + + useEffect(() => { + return () => { + cancelItemMovePreviewPlanWarmup() + } + }, [cancelItemMovePreviewPlanWarmup]) + + useEffect(() => { + cancelItemMovePreviewPlanWarmup() + + if (!(enabled && graph && itemMovePreviewActive && movingItemNode && itemMovePreview)) { + itemMovePreviewPlanRef.current = null + return + } + + const robotCopySourceId = getNavigationDraftRobotCopySourceId(movingItemNode.id) + const requestSourceId = robotCopySourceId ?? movingItemNode.id + const requestSourceNode = sceneState.nodes[requestSourceId] + const previewTargetNode = sceneState.nodes[itemMovePreview.id] + if (!(requestSourceNode?.type === 'item' && previewTargetNode?.type === 'item')) { + itemMovePreviewPlanRef.current = null + return + } + + const { actorNavigationPoint, actorStartCellIndex, actorStartComponentId } = + getActorNavigationPlanningState( + graph, + selection.levelId ?? toLevelNodeId(requestSourceNode.parentId) ?? null, + ) + if (actorStartCellIndex === null) { + itemMovePreviewPlanRef.current = null + return + } + + const previewRequest: NavigationItemMoveRequest = { + finalUpdate: { + position: [...previewTargetNode.position] as [number, number, number], + rotation: [...previewTargetNode.rotation] as [number, number, number], + }, + itemDimensions: getScaledDimensions(requestSourceNode), + itemId: requestSourceId, + levelId: requestSourceNode.parentId, + sourcePosition: [...requestSourceNode.position] as [number, number, number], + sourceRotation: [...requestSourceNode.rotation] as [number, number, number], + targetPreviewItemId: robotCopySourceId ? previewTargetNode.id : null, + visualItemId: robotCopySourceId ? previewTargetNode.id : requestSourceId, + } + const previewPlanCacheKey = createNavigationItemMovePlanCacheKey( + previewRequest, + actorStartCellIndex, + navigationSceneSnapshot?.key ?? null, + selection.buildingId ?? null, + ) + if (itemMovePreviewPlanRef.current?.cacheKey === previewPlanCacheKey) { + return + } + const cachedPreviewPlan = itemMovePreviewPlanCacheRef.current.get(previewPlanCacheKey) ?? null + if (cachedPreviewPlan) { + itemMovePreviewPlanRef.current = cachedPreviewPlan + return + } + + let cancelled = false + itemMovePreviewPlanWarmTimeoutRef.current = window.setTimeout(() => { + itemMovePreviewPlanWarmTimeoutRef.current = null + if (cancelled) { + return + } + + const previewPlan = measureNavigationPerf('navigation.itemMovePreviewPlanBuildMs', () => + resolveItemMovePlan( + previewRequest, + actorStartCellIndex, + actorNavigationPoint, + actorStartComponentId, + { + recordFallbackMeta: false, + targetGraphPerfMetricName: 'navigation.itemMovePreviewTargetGraphBuildMs', + }, + ), + ) + + if (!cancelled && previewPlan) { + const resolvedPreviewPlan = { + cacheKey: previewPlanCacheKey, + ...previewPlan, + } + itemMovePreviewPlanRef.current = resolvedPreviewPlan + cacheItemMovePreviewPlan(resolvedPreviewPlan) + } + }, ITEM_MOVE_PREVIEW_PLAN_DEBOUNCE_MS) + + return () => { + cancelled = true + cancelItemMovePreviewPlanWarmup() + } + }, [ + cancelItemMovePreviewPlanWarmup, + enabled, + graph, + getActorNavigationPlanningState, + itemMovePreview, + itemMovePreviewActive, + cacheItemMovePreviewPlan, + movingItemNode, + navigationSceneSnapshot?.key, + resolveItemMovePlan, + sceneState.nodes, + selection.buildingId, + selection.levelId, + ]) + + useEffect(() => { + const clearIntroTaskReadyTimeout = () => { + const timeoutId = pascalTruckIntroTaskReadyTimeoutRef.current + if (timeoutId !== null) { + window.clearTimeout(timeoutId) + pascalTruckIntroTaskReadyTimeoutRef.current = null + } + } + + if (!pascalTruckIntroCompleted) { + clearIntroTaskReadyTimeout() + setPascalTruckIntroTaskReady(false) + return + } + + setPascalTruckIntroTaskReady(false) + pascalTruckIntroTaskReadyTimeoutRef.current = window.setTimeout(() => { + pascalTruckIntroTaskReadyTimeoutRef.current = null + setPascalTruckIntroTaskReady(true) + }, PASCAL_TRUCK_ENTRY_RELEASE_DURATION_MS) + + return clearIntroTaskReadyTimeout + }, [pascalTruckIntroCompleted]) + + useEffect(() => { + void sceneState.nodes + void sceneState.rootNodeIds + shadowControllerRef.current.dynamicSettleFrames = Math.max( + shadowControllerRef.current.dynamicSettleFrames, + STATIC_SHADOW_DYNAMIC_SETTLE_FRAMES, + ) + shadowControllerRef.current.lastDynamicUpdateAtMs = 0 + }, [sceneState.nodes, sceneState.rootNodeIds]) + + useEffect(() => { + const shadowMap = (gl as typeof gl & { shadowMap?: RendererShadowMap }).shadowMap + return () => { + if (!shadowMap) { + return + } + + shadowMap.enabled = true + shadowMap.autoUpdate = true + shadowMap.needsUpdate = true + } + }, [gl]) + + const resetMotion = useCallback((clearActorPosition = false) => { + motionRef.current = createActorMotionState() + motionWriteSourceRef.current = 'resetMotion' + pascalTruckIntroPendingSettlePositionRef.current = null + doorCollisionStateRef.current = { + blocked: false, + doorIds: [], + } + pendingMotionRef.current = null + setActorMoving(false) + setPathGraphOverride(null) + setPathIndices([]) + setPathAnchorWorldPosition(null) + + if (clearActorPosition) { + actorPositionInitializedRef.current = false + lastPublishedActorPositionRef.current = null + setActorAvailable(false) + setActorWorldPosition(null) + navigationEmitter.emit('navigation:actor-transform', { + moving: false, + position: null, + rotationY: 0, + }) + } + }, []) + + const setMotionState = useCallback((nextMotionState: ActorMotionState, source: string) => { + const introActive = pascalTruckIntroRef.current !== null + const allowDuringIntro = + source === 'pascalTruckIntro:start' || + source === 'pascalTruckIntro:frame' || + source === 'pascalTruckIntro:complete' + if (introActive && !allowDuringIntro) { + return false + } + + motionRef.current = nextMotionState + motionWriteSourceRef.current = source + return true + }, []) + + const beginPascalTruckIntro = useCallback(() => { + const taskModePlanningBlocked = robotMode === 'task' && !taskQueuePlanningReady + if ( + introAnimationDebugActive || + !enabled || + pascalTruckIntroRef.current || + !pascalTruckIntroPlan || + taskModePlanningBlocked + ) { + recordTaskModeTrace('navigation.beginPascalTruckIntroSkipped', { + enabled, + hasPlanningReady: taskQueuePlanningReady, + introAnimationDebugActive, + hasIntroPlan: Boolean(pascalTruckIntroPlan), + introAlreadyActive: pascalTruckIntroRef.current !== null, + robotMode, + }) + return false + } + + recordTaskModeTrace('navigation.beginPascalTruckIntroStart', {}, { includeSnapshot: true }) + pascalTruckIntroPlaybackTokenRef.current += 1 + setMotionState( + { + ...createActorMotionState(), + destinationCellIndex: pascalTruckIntroPlan.finalCellIndex, + forcedClip: { + clipName: PASCAL_TRUCK_ENTRY_CLIP_NAME, + holdLastFrame: true, + loop: 'once', + paused: true, + revealProgress: 0, + seekTime: 0, + timeScale: 1, + }, + visibilityRevealProgress: 0, + }, + 'pascalTruckIntro:start', + ) + const introStartPosition: [number, number, number] = [ + pascalTruckIntroPlan.startPosition[0], + pascalTruckIntroPlan.startPosition[1], + pascalTruckIntroPlan.startPosition[2], + ] + const actorGroup = actorGroupRef.current + if (actorGroup) { + actorGroup.position.set(introStartPosition[0], introStartPosition[1], introStartPosition[2]) + actorGroup.rotation.y = pascalTruckIntroPlan.rotationY + actorGroup.updateMatrixWorld(true) + } + doorCollisionStateRef.current = { + blocked: false, + doorIds: [], + } + pendingMotionRef.current = null + setActorMoving(false) + setPathIndices([]) + setPathAnchorWorldPosition(null) + actorPositionInitializedRef.current = false + lastPublishedActorPositionRef.current = introStartPosition + lastPublishedActorPositionAtRef.current = performance.now() + setActorAvailable(false) + setActorWorldPosition(introStartPosition) + navigationEmitter.emit('navigation:actor-transform', { + moving: false, + position: introStartPosition, + rotationY: pascalTruckIntroPlan.rotationY, + }) + setPascalTruckIntroCompleted(false) + pascalTruckIntroRef.current = { + ...pascalTruckIntroPlan, + animationElapsedMs: 0, + animationStarted: false, + handoffPending: false, + revealElapsedMs: 0, + revealStarted: false, + warmupWaitElapsedMs: 0, + } + pascalTruckIntroPostWarmupTokenRef.current = null + pascalTruckIntroPendingSettlePositionRef.current = null + setPascalTruckIntroActive(true) + return true + }, [ + enabled, + introAnimationDebugActive, + pascalTruckIntroPlan, + recordTaskModeTrace, + robotMode, + setActorAvailable, + setActorWorldPosition, + setMotionState, + taskQueuePlanningReady, + ]) + + const setItemMoveGesturePlayback = useCallback((gesture: NavigationItemMoveGesture | null) => { + setItemMoveForcedClipPlayback((currentPlayback) => { + if (!gesture) { + return currentPlayback === null ? currentPlayback : null + } + + if ( + currentPlayback?.clipName === gesture.clipName && + currentPlayback.stabilizeRootMotion === true + ) { + return currentPlayback + } + + return { + clipName: gesture.clipName, + loop: 'once', + revealFromStart: false, + stabilizeRootMotion: true, + timeScale: 1, + } + }) + }, []) + + const clearItemMoveGestureClipState = useCallback(() => { + motionRef.current.forcedClip = null + setItemMoveGesturePlayback(null) + }, [setItemMoveGesturePlayback]) + + const syncItemMoveGestureClipState = useCallback( + (gesture: NavigationItemMoveGesture, progress: number) => { + const clampedProgress = MathUtils.clamp(progress, 0, 1) + motionRef.current.forcedClip = { + clipName: gesture.clipName, + holdLastFrame: false, + loop: 'once', + paused: true, + revealProgress: 1, + seekTime: gesture.durationSeconds * clampedProgress, + timeScale: 1, + } + setItemMoveGesturePlayback(gesture) + }, + [setItemMoveGesturePlayback], + ) + + useEffect(() => { + const sceneGraphEmpty = + sceneState.rootNodeIds.length === 0 || Object.keys(sceneState.nodes).length === 0 + + if (sceneGraphEmpty) { + pascalTruckIntroRef.current = null + pascalTruckIntroPostWarmupTokenRef.current = null + pascalTruckExitRef.current = null + pendingPascalTruckExitRef.current = null + precomputedPascalTruckExitRef.current = null + itemDeleteSequenceRef.current = null + itemRepairSequenceRef.current = null + clearItemMoveGestureClipState() + resetTaskQueueVisuals() + useNavigation.setState({ + activeTaskId: null, + activeTaskIndex: 0, + itemDeleteRequest: null, + itemMoveControllers: {}, + itemMoveRequest: null, + itemRepairRequest: null, + taskQueue: [], + }) + setPascalTruckIntroActive(false) + setPascalTruckExitActive(false) + setPascalTruckIntroCompleted(false) + setToolCarryItemId(null) + setActorCellIndex(null) + resetMotion(true) + return + } + + // Task-mode graph refreshes can temporarily clear the prewarmed graph while + // a new snapshot is being built. Preserve the active queue through that + // gap instead of treating it as a full runtime teardown. + if (!graph) { + return + } + + if (enabled && !pascalTruckIntroCompleted) { + if ( + pascalTruckIntroPlan && + pascalTruckIntroPlan.finalCellIndex !== null && + actorCellIndex !== pascalTruckIntroPlan.finalCellIndex + ) { + setActorCellIndex(pascalTruckIntroPlan.finalCellIndex) + } + return + } + + const currentActorWorldPosition = getResolvedActorWorldPosition() + const actorNavigationPoint = currentActorWorldPosition + ? ([ + currentActorWorldPosition[0], + currentActorWorldPosition[1] - ACTOR_HOVER_Y, + currentActorWorldPosition[2], + ] as [number, number, number]) + : null + const remappedActorCellIndex = + actorNavigationPoint !== null + ? findClosestNavigationCell( + graph, + actorNavigationPoint, + selection.levelId ?? undefined, + null, + ) + : null + + if (remappedActorCellIndex !== null) { + if (actorCellIndex !== remappedActorCellIndex) { + setActorCellIndex(remappedActorCellIndex) + } + return + } + + resetMotion() + setActorCellIndex(getInitialActorCellIndex(graph, selection.levelId) ?? null) + }, [ + actorCellIndex, + clearItemMoveGestureClipState, + enabled, + getResolvedActorWorldPosition, + graph, + sceneState.nodes, + sceneState.rootNodeIds, + pascalTruckIntroCompleted, + pascalTruckIntroPlan, + resetTaskQueueVisuals, + resetMotion, + selection.levelId, + ]) + + useEffect(() => { + const previousRobotMode = previousRobotModeRef.current + previousRobotModeRef.current = robotMode + const robotModeSwitchRequiresReset = + previousRobotMode !== null && robotMode !== null && previousRobotMode !== robotMode + + if (enabled && !robotModeSwitchRequiresReset) { + return + } + + const navigationState = useNavigation.getState() + const navigationVisualState = navigationVisualsStore.getState() + const hasNavigationStateToClear = + navigationState.itemDeleteRequest !== null || + navigationState.itemMoveRequest !== null || + navigationState.itemRepairRequest !== null || + navigationState.taskQueue.length > 0 || + itemMoveControllerCount > 0 + const hasTaskQueueVisualsToClear = + navigationVisualState.itemMovePreview !== null || + Object.keys(navigationVisualState.itemDeleteActivations).length > 0 + const hasLocalStateToClear = + pascalTruckIntroRef.current !== null || + pascalTruckExitRef.current !== null || + itemDeleteSequenceRef.current !== null || + itemRepairSequenceRef.current !== null || + itemMoveSequenceRef.current !== null || + releasedNavigationItemId !== null || + pascalTruckIntroActive || + pascalTruckExitActive || + pascalTruckIntroCompleted || + itemMoveLocked + + if (!hasNavigationStateToClear && !hasLocalStateToClear && !hasTaskQueueVisualsToClear) { + return + } + + pascalTruckIntroRef.current = null + pascalTruckIntroPostWarmupTokenRef.current = null + pascalTruckExitRef.current = null + pendingPascalTruckExitRef.current = null + precomputedPascalTruckExitRef.current = null + itemDeleteSequenceRef.current = null + itemRepairSequenceRef.current = null + setReleasedNavigationItemId(null) + clearItemMoveGestureClipState() + resetTaskQueueVisuals() + setPascalTruckIntroActive(false) + setPascalTruckExitActive(false) + setPascalTruckIntroCompleted(false) + setToolCarryItemId(null) + const activeItemMoveSequence = itemMoveSequenceRef.current + itemMoveSequenceRef.current = null + activeItemMoveSequence?.controller.cancel() + Object.values(itemMoveControllers).forEach((controller) => { + controller?.cancel() + }) + if (hasNavigationStateToClear) { + useNavigation.setState({ + activeTaskId: null, + activeTaskIndex: 0, + itemDeleteRequest: null, + itemMoveControllers: {}, + itemMoveRequest: null, + itemRepairRequest: null, + taskQueue: [], + }) + } + setItemMoveLocked(false) + resetMotion(true) + }, [ + clearItemMoveGestureClipState, + enabled, + itemMoveControllers, + itemMoveControllerCount, + itemMoveLocked, + pascalTruckExitActive, + pascalTruckIntroActive, + pascalTruckIntroCompleted, + releasedNavigationItemId, + resetTaskQueueVisuals, + robotMode, + resetMotion, + setItemMoveLocked, + ]) + + useEffect(() => { + return () => { + resetTaskQueueVisuals() + } + }, [resetTaskQueueVisuals]) + + const simplifiedPathIndices = useMemo( + () => + pathGraph + ? measureNavigationPerf('navigation.pathSimplifyMs', () => + simplifyNavigationPath(pathGraph, pathIndices), + ) + : [], + [pathGraph, pathIndices], + ) + const doorTransitions = useMemo( + () => (pathGraph ? getNavigationDoorTransitions(pathGraph, pathIndices) : []), + [pathGraph, pathIndices], + ) + const rawPathPoints = useMemo(() => { + if (!pathGraph) { + return [] + } + + const worldPoints = getNavigationPathWorldPoints(pathGraph, pathIndices) + if (!pathTargetWorldPosition) { + return worldPoints + } + + const lastWorldPoint = worldPoints.at(-1) + if (!lastWorldPoint) { + return [pathTargetWorldPosition] + } + + const endJoinDistance = Math.max(0.08, (pathGraph.cellSize ?? 0.2) * 0.85) + if ( + Math.hypot( + lastWorldPoint[0] - pathTargetWorldPosition[0], + lastWorldPoint[1] - pathTargetWorldPosition[1], + lastWorldPoint[2] - pathTargetWorldPosition[2], + ) <= endJoinDistance + ) { + worldPoints[worldPoints.length - 1] = pathTargetWorldPosition + return worldPoints + } + + return [...worldPoints, pathTargetWorldPosition] + }, [pathGraph, pathIndices, pathTargetWorldPosition]) + useEffect(() => { + if (pathIndices.length === 0 && pathTargetWorldPosition !== null) { + setPathTargetWorldPosition(null) + } + }, [pathIndices.length, pathTargetWorldPosition]) + useEffect(() => { + if (pathIndices.length === 0 && pathGraphOverride !== null) { + setPathGraphOverride(null) + } + }, [pathGraphOverride, pathIndices.length]) + const protectedPathPointKeys = useMemo( + () => + new Set( + doorTransitions.flatMap((transition) => [ + getNavigationPointKey(transition.approachWorld), + getNavigationPointKey(transition.entryWorld), + getNavigationPointKey(transition.world), + getNavigationPointKey(transition.exitWorld), + getNavigationPointKey(transition.departureWorld), + ]), + ), + [doorTransitions], + ) + const pathComponentId = useMemo(() => { + if (!pathGraph) { + return null + } + + const firstPathCellIndex = pathIndices[0] + if (firstPathCellIndex === undefined) { + return actorComponentId + } + + return pathGraph.componentIdByCell[firstPathCellIndex] ?? actorComponentId + }, [actorComponentId, pathGraph, pathIndices]) + const isPathPointSupported = useCallback( + (point: Vector3) => { + if (!pathGraph) { + return true + } + + return isNavigationPointSupported(pathGraph, [point.x, point.y, point.z], pathComponentId) + }, + [pathComponentId, pathGraph], + ) + const rawElevatedPathPoints = useMemo( + () => + measureNavigationPerf('navigation.pathElevateMs', () => { + const elevatedPoints = rawPathPoints.map(([x, y, z]) => new Vector3(x, y, z)) + const anchoredStartPoint = pathAnchorWorldPosition + ? new Vector3( + pathAnchorWorldPosition[0], + pathAnchorWorldPosition[1] - ACTOR_HOVER_Y, + pathAnchorWorldPosition[2], + ) + : null + + if (anchoredStartPoint && elevatedPoints.length > 0) { + const startJoinDistance = Math.max(0.08, (pathGraph?.cellSize ?? 0.2) * 0.85) + const firstElevatedPoint = elevatedPoints[0] + if ( + firstElevatedPoint && + anchoredStartPoint.distanceTo(firstElevatedPoint) <= startJoinDistance + ) { + elevatedPoints[0] = anchoredStartPoint + } else { + elevatedPoints.unshift(anchoredStartPoint) + } + } + + return elevatedPoints + }), + [pathAnchorWorldPosition, pathGraph?.cellSize, rawPathPoints], + ) + const smoothedPathPoints = useMemo( + () => + measureNavigationPerf('navigation.pathSmoothMs', () => + smoothPathWithinCorridor(rawElevatedPathPoints, protectedPathPointKeys), + ), + [protectedPathPointKeys, rawElevatedPathPoints], + ) + const candidatePathCurve = useMemo( + () => + measureNavigationPerf('navigation.pathCurveBuildMs', () => + buildPathCurve(smoothedPathPoints, doorTransitions, isPathPointSupported), + ), + [doorTransitions, isPathPointSupported, smoothedPathPoints], + ) + debugDoorTransitionsRef.current = doorTransitions + const candidatePathCollisionAudit = useMemo( + () => + measureNavigationPerf('navigation.pathCollisionAuditMs', () => + auditNavigationCurveCollisions(pathGraph, candidatePathCurve, pathComponentId), + ), + [candidatePathCurve, pathGraph, pathComponentId], + ) + const shouldBuildConservativePath = + doorTransitions.length > 0 || + !candidatePathCurve || + candidatePathCollisionAudit.blockedSampleCount > 0 + const conservativePathCurve = useMemo(() => { + if (!shouldBuildConservativePath) { + return null + } + + return measureNavigationPerf('navigation.pathConservativeCurveBuildMs', () => + buildPolylineCurve(rawElevatedPathPoints), + ) + }, [rawElevatedPathPoints, shouldBuildConservativePath]) + const conservativePathCollisionAudit = useMemo(() => { + if (!conservativePathCurve) { + return EMPTY_NAVIGATION_PATH_COLLISION_AUDIT + } + + return measureNavigationPerf('navigation.pathCollisionAuditMs', () => + auditNavigationCurveCollisions(pathGraph, conservativePathCurve, pathComponentId), + ) + }, [conservativePathCurve, pathGraph, pathComponentId]) + const motionPathCurve = useMemo(() => { + if (candidatePathCurve && candidatePathCollisionAudit.blockedSampleCount === 0) { + return candidatePathCurve + } + + if (conservativePathCurve && conservativePathCollisionAudit.blockedSampleCount === 0) { + return conservativePathCurve + } + + return candidatePathCurve ?? conservativePathCurve + }, [ + candidatePathCollisionAudit.blockedSampleCount, + candidatePathCurve, + conservativePathCollisionAudit.blockedSampleCount, + conservativePathCurve, + ]) + const pathCurve = useMemo( + () => candidatePathCurve ?? conservativePathCurve, + [candidatePathCurve, conservativePathCurve], + ) + debugPathCurveRef.current = pathCurve + const pathLength = useMemo(() => pathCurve?.getLength() ?? 0, [pathCurve]) + const conservativePathLength = useMemo( + () => conservativePathCurve?.getLength() ?? 0, + [conservativePathCurve], + ) + const primaryMotionCurve = motionPathCurve ?? conservativePathCurve + const primaryMotionLength = useMemo(() => { + if (!primaryMotionCurve) { + return 0 + } + + return primaryMotionCurve === conservativePathCurve ? conservativePathLength : pathLength + }, [conservativePathCurve, conservativePathLength, pathLength, primaryMotionCurve]) + const trajectoryMotionProfile = useMemo( + () => + measureNavigationPerf('navigation.trajectoryMotionProfileMs', () => + buildTrajectoryMotionProfile(primaryMotionCurve, primaryMotionLength), + ), + [primaryMotionCurve, primaryMotionLength], + ) + const pathTubeSegments = useMemo( + () => Math.max(24, Math.ceil(pathLength / PATH_RENDER_SEGMENT_LENGTH)), + [pathLength], + ) + useEffect(() => { + mergeNavigationPerfMeta({ + navigationPathBlockedObstacleCount: + primaryMotionCurve === candidatePathCurve + ? candidatePathCollisionAudit.blockedObstacleIds.length + : conservativePathCollisionAudit.blockedObstacleIds.length, + navigationPathBlockedSampleCount: + primaryMotionCurve === candidatePathCurve + ? candidatePathCollisionAudit.blockedSampleCount + : conservativePathCollisionAudit.blockedSampleCount, + navigationPathBlockedWallCount: + primaryMotionCurve === candidatePathCurve + ? candidatePathCollisionAudit.blockedWallIds.length + : conservativePathCollisionAudit.blockedWallIds.length, + navigationPathUsingConservativeCurve: + Boolean( + primaryMotionCurve && + conservativePathCurve && + primaryMotionCurve === conservativePathCurve, + ) && primaryMotionCurve !== candidatePathCurve, + navigationPathHighCurvatureSectionCount: + trajectoryMotionProfile?.sections.filter((section) => section.kind === 'high').length ?? 0, + navigationPathLowCurvatureSectionCount: + trajectoryMotionProfile?.sections.filter((section) => section.kind === 'low').length ?? 0, + }) + }, [ + candidatePathCollisionAudit.blockedObstacleIds.length, + candidatePathCollisionAudit.blockedSampleCount, + candidatePathCollisionAudit.blockedWallIds.length, + candidatePathCurve, + conservativePathCollisionAudit.blockedObstacleIds.length, + conservativePathCollisionAudit.blockedSampleCount, + conservativePathCollisionAudit.blockedWallIds.length, + conservativePathCurve, + primaryMotionCurve, + trajectoryMotionProfile, + ]) + const trajectoryRibbonGeometry = useMemo(() => { + if (!(enabled && pathCurve)) { + return null + } + + return measureNavigationPerf('navigation.pathRibbonGeometryBuildMs', () => + buildFlatPathRibbonGeometry(pathCurve, pathTubeSegments, PATH_RENDER_THREAD_WIDTH), + ) + }, [enabled, pathCurve, pathTubeSegments]) + const mainPathGeometry = useMemo(() => { + if (!(PATH_STATIC_PREVIEW_MODE && pathCurve)) { + return null + } + + return measureNavigationPerf('navigation.pathMainGeometryBuildMs', () => { + const splineCurveCount = pathCurve.curves.filter( + (curve): curve is CatmullRomCurve3 => curve instanceof CatmullRomCurve3, + ).length + const lineCurveCount = pathCurve.curves.filter( + (curve): curve is LineCurve3 => curve instanceof LineCurve3, + ).length + const quadraticCurveCount = pathCurve.curves.filter( + (curve): curve is QuadraticBezierCurve3 => curve instanceof QuadraticBezierCurve3, + ).length + const geometry = new TubeGeometry( + pathCurve, + pathTubeSegments, + PATH_STATIC_PREVIEW_MODE ? PATH_RENDER_STATIC_PREVIEW_MAIN_RADIUS : PATH_RENDER_MAIN_RADIUS, + PATH_RENDER_MAIN_RADIAL_SEGMENTS, + false, + ) + mergeNavigationPerfMeta({ + navigationPathCurveCount: pathCurve.curves.length, + navigationPathLineCurveCount: lineCurveCount, + navigationPathLength: pathLength, + navigationPathMainTriangles: pathTubeSegments * PATH_RENDER_MAIN_RADIAL_SEGMENTS * 2, + navigationPathQuadraticCurveCount: quadraticCurveCount, + navigationPathSplineCurveCount: splineCurveCount, + navigationPathTubeSegments: pathTubeSegments, + }) + return geometry + }) + }, [pathCurve, pathLength, pathTubeSegments]) + const orbitPathGeometryA = useMemo(() => { + if (!(PATH_RENDER_ORBITS_ENABLED && pathCurve)) { + return null + } + + return measureNavigationPerf('navigation.pathOrbitGeometryMs', () => { + const geometry = buildOrbitRibbonGeometry( + pathCurve, + pathTubeSegments, + PATH_RENDER_ORBIT_RIBBON_WIDTH, + 0, + ) + if (!geometry) { + return null + } + mergeNavigationPerfMeta({ + navigationPathOrbitCurveCount: 2, + navigationPathOrbitTriangles: pathTubeSegments * 4, + }) + return geometry + }) + }, [pathCurve, pathTubeSegments]) + const orbitPathGeometryB = useMemo(() => { + if (!(PATH_RENDER_ORBITS_ENABLED && pathCurve)) { + return null + } + + return measureNavigationPerf('navigation.pathOrbitGeometryMs', () => + buildOrbitRibbonGeometry( + pathCurve, + pathTubeSegments, + PATH_RENDER_ORBIT_RIBBON_WIDTH, + Math.PI, + ), + ) + }, [pathCurve, pathTubeSegments]) + const highlightPathTexture = useMemo(() => buildPathHighlightTexture(), []) + const pathRenderSegments = useMemo(() => { + if (!(PATH_STATIC_PREVIEW_MODE && pathCurve)) { + return [] + } + + return measureNavigationPerf('navigation.pathMainGeometryBuildMs', () => { + const splineCurveCount = pathCurve.curves.filter( + (curve): curve is CatmullRomCurve3 => curve instanceof CatmullRomCurve3, + ).length + const lineCurveCount = pathCurve.curves.filter( + (curve): curve is LineCurve3 => curve instanceof LineCurve3, + ).length + const quadraticCurveCount = pathCurve.curves.filter( + (curve): curve is QuadraticBezierCurve3 => curve instanceof QuadraticBezierCurve3, + ).length + mergeNavigationPerfMeta({ + navigationPathCurveCount: pathCurve.curves.length, + navigationPathLineCurveCount: lineCurveCount, + navigationPathLength: pathLength, + navigationPathMainTriangles: + Math.max(PATH_STATIC_PREVIEW_FADE_SEGMENT_COUNT, Math.ceil(pathTubeSegments / 2)) * + Math.max( + 3, + Math.ceil( + pathTubeSegments / + Math.max(PATH_STATIC_PREVIEW_FADE_SEGMENT_COUNT, Math.ceil(pathTubeSegments / 2)), + ), + ) * + PATH_RENDER_MAIN_RADIAL_SEGMENTS * + 2, + navigationPathQuadraticCurveCount: quadraticCurveCount, + navigationPathSplineCurveCount: splineCurveCount, + navigationPathTubeSegments: pathTubeSegments, + }) + return buildPathRenderSegments( + pathCurve, + pathTubeSegments, + PATH_RENDER_STATIC_PREVIEW_MAIN_RADIUS, + ) + }) + }, [pathCurve, pathLength, pathTubeSegments]) + const trajectoryRibbonMaterial = useMemo(() => { + return createTrajectoryThreadMaterial() + }, []) + const basePathMaterial = useMemo(() => { + const material = configureTrajectoryMaterial( + new MeshBasicMaterial({ + color: new Color('#000000'), + depthTest: false, + depthWrite: false, + opacity: 1, + side: DoubleSide, + transparent: true, + }), + basePathShaderRef, + { + discardHidden: true, + endFadeLength: 0, + frontFadeLength: 0, + programKey: 'navigation-path-base', + }, + ) + material.toneMapped = false + return material + }, []) + const highlightPathMaterial = useMemo(() => { + if (!highlightPathTexture) { + return null + } + + const material = configureTrajectoryMaterial( + new MeshBasicMaterial({ + alphaMap: highlightPathTexture, + color: new Color('#f5f7f8'), + depthTest: false, + depthWrite: false, + opacity: PATH_MAIN_HIGHLIGHT_ALPHA, + side: DoubleSide, + transparent: true, + }), + highlightPathShaderRef, + { + endFadeLength: 0, + frontFadeLength: 0, + programKey: 'navigation-path-highlight', + }, + ) + material.toneMapped = false + return material + }, [highlightPathTexture]) + const orbitPathMaterialA = useMemo(() => { + const material = configureTrajectoryMaterial( + new MeshBasicMaterial({ + blending: AdditiveBlending, + color: new Color('#ffffff'), + depthTest: false, + depthWrite: false, + fog: false, + opacity: 1, + side: DoubleSide, + transparent: true, + vertexColors: true, + }), + orbitPathShaderARef, + { + discardHidden: true, + endFadeLength: 0, + frontFadeLength: 0, + programKey: 'navigation-path-orbit-a', + }, + ) + material.toneMapped = false + return material + }, []) + const orbitPathMaterialB = useMemo(() => { + const material = configureTrajectoryMaterial( + new MeshBasicMaterial({ + blending: AdditiveBlending, + color: new Color('#ffffff'), + depthTest: false, + depthWrite: false, + fog: false, + opacity: 1, + side: DoubleSide, + transparent: true, + vertexColors: true, + }), + orbitPathShaderBRef, + { + discardHidden: true, + endFadeLength: 0, + frontFadeLength: 0, + programKey: 'navigation-path-orbit-b', + }, + ) + material.toneMapped = false + return material + }, []) + const pathMaterialWarmupGeometry = useMemo(() => { + const geometry = new BufferGeometry() + geometry.setAttribute( + 'position', + new Float32BufferAttribute( + [-0.02, 0, 0, 0.02, 0, 0, 0.02, 0.04, 0, -0.02, 0, 0, 0.02, 0.04, 0, -0.02, 0.04, 0], + 3, + ), + ) + geometry.setAttribute('uv', new Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1], 2)) + return geometry + }, []) + const pathShadersWarmedRef = useRef(false) + const [pathShadersReady, setPathShadersReady] = useState(false) + + useEffect(() => { + if (pathShadersWarmedRef.current) { + return + } + + pathShadersWarmedRef.current = true + setPathShadersReady(false) + const warmupRoot = new Group() + const warmupMeshes = [ + new Mesh(pathMaterialWarmupGeometry, trajectoryRibbonMaterial), + new Mesh(pathMaterialWarmupGeometry, basePathMaterial), + new Mesh(pathMaterialWarmupGeometry, orbitPathMaterialA), + new Mesh(pathMaterialWarmupGeometry, orbitPathMaterialB), + ] + + for (const [index, mesh] of warmupMeshes.entries()) { + mesh.position.set(0, 0, -index * 0.08) + warmupRoot.add(mesh) + } + scene.add(warmupRoot) + + const warmupCamera = new PerspectiveCamera(50, 1, 0.01, 10) + warmupCamera.position.set(0, 0.02, 1.35) + warmupCamera.lookAt(0, 0.02, -0.12) + warmupCamera.updateProjectionMatrix() + warmupCamera.updateMatrixWorld(true) + + let cancelled = false + const renderer = gl as unknown as { + compileAsync?: (scene: Scene, camera: object) => Promise + render?: (scene: Scene, camera: object) => void + setRenderTarget?: (target: RenderTarget | null) => void + } + const warmupStart = performance.now() + const renderTarget = new RenderTarget(64, 64, { depthBuffer: true }) + + const warmupShaders = async () => { + try { + try { + await (renderer.compileAsync?.(scene as unknown as Scene, warmupCamera) ?? + Promise.resolve()) + } catch {} + + recordNavigationPerfSample( + 'navigation.pathRenderWarmupCompileAsyncWallMs', + performance.now() - warmupStart, + ) + + if (cancelled) { + return + } + + const renderStart = performance.now() + renderer.setRenderTarget?.(renderTarget) + renderer.render?.(scene as unknown as Scene, warmupCamera) + recordNavigationPerfSample( + 'navigation.pathRenderWarmupRenderMs', + performance.now() - renderStart, + ) + recordNavigationPerfSample('navigation.pathRenderWarmupMs', performance.now() - warmupStart) + if (!cancelled) { + setPathShadersReady(true) + } + } catch { + } finally { + renderer.setRenderTarget?.(null) + renderTarget.dispose() + warmupMeshes.forEach((mesh) => { + warmupRoot.remove(mesh) + }) + scene.remove(warmupRoot) + } + } + + void warmupShaders() + + return () => { + cancelled = true + scene.remove(warmupRoot) + } + }, [ + basePathMaterial, + gl, + orbitPathMaterialA, + orbitPathMaterialB, + pathMaterialWarmupGeometry, + scene, + trajectoryRibbonMaterial, + ]) + + useEffect( + () => () => { + trajectoryRibbonGeometry?.dispose() + }, + [trajectoryRibbonGeometry], + ) + + useEffect( + () => () => { + mainPathGeometry?.dispose() + }, + [mainPathGeometry], + ) + + useEffect( + () => () => { + pathRenderSegments.forEach((segment) => { + segment.geometry.dispose() + segment.material.dispose() + }) + }, + [pathRenderSegments], + ) + + useEffect( + () => () => { + orbitPathGeometryA?.dispose() + }, + [orbitPathGeometryA], + ) + + useEffect( + () => () => { + orbitPathGeometryB?.dispose() + }, + [orbitPathGeometryB], + ) + + useEffect( + () => () => { + pathMaterialWarmupGeometry.dispose() + trajectoryRibbonMaterial.dispose() + basePathMaterial.dispose() + highlightPathMaterial?.dispose() + highlightPathTexture?.dispose() + orbitPathMaterialA.dispose() + orbitPathMaterialB.dispose() + }, + [ + basePathMaterial, + highlightPathMaterial, + highlightPathTexture, + orbitPathMaterialA, + orbitPathMaterialB, + pathMaterialWarmupGeometry, + trajectoryRibbonMaterial, + ], + ) + + useEffect(() => { + mergeNavigationPerfMeta({ + navigationActorVisible: + enabled && actorCellIndex !== null && Boolean(graph?.cells[actorCellIndex]), + navigationActorMoving: actorMoving, + navigationDoorTransitionCount: doorTransitions.length, + navigationPathGridNodeCount: pathIndices.length, + navigationPathRawWaypointCount: rawPathPoints.length, + navigationPathSimplifiedNodeCount: simplifiedPathIndices.length, + navigationPathSmoothedWaypointCount: smoothedPathPoints.length, + navigationPathVisible: enabled && Boolean(pathCurve), + }) + }, [ + actorCellIndex, + actorMoving, + doorTransitions.length, + enabled, + graph, + pathCurve, + pathIndices.length, + rawPathPoints.length, + simplifiedPathIndices.length, + smoothedPathPoints.length, + ]) + + const commitPlannedNavigationPath = useCallback( + ( + planningGraph: NavigationGraph, + pathResult: NavigationPathResult, + targetWorldPosition?: [number, number, number] | null, + destinationCellIndex?: number | null, + ) => { + const actorWorldPosition = getResolvedActorWorldPosition() + const actorVisualWorldPosition = getResolvedActorVisualWorldPosition() + + mergeNavigationPerfMeta({ + navigationLastPathElapsedMs: pathResult.elapsedMs, + navigationLastPathNodeCount: pathResult.indices.length, + }) + const anchorCellIndex = pathResult.indices.length > 0 ? (pathResult.indices[0] ?? null) : null + lastCommittedPathDebugRef.current = { + actorVisualWorldPosition, + actorWorldPosition, + anchorCellCenter: + anchorCellIndex !== null ? (planningGraph.cells[anchorCellIndex]?.center ?? null) : null, + anchorCellIndex, + destinationCellCenter: + destinationCellIndex !== null && destinationCellIndex !== undefined + ? (planningGraph.cells[destinationCellIndex]?.center ?? null) + : null, + destinationCellIndex: destinationCellIndex ?? null, + graphIsLiveBase: planningGraph === graph, + graphCellCount: planningGraph.cells.length, + pathIndices: [...pathResult.indices], + targetWorldPosition: targetWorldPosition ?? null, + } + setPathGraphOverride(planningGraph === graph ? null : planningGraph) + setPathIndices(pathResult.indices) + setPathAnchorWorldPosition(actorVisualWorldPosition) + setPathTargetWorldPosition(targetWorldPosition ?? null) + if (PATH_STATIC_PREVIEW_MODE) { + pendingMotionRef.current = null + setMotionState( + { + ...createActorMotionState(), + destinationCellIndex: planningGraph === graph ? (destinationCellIndex ?? null) : null, + }, + 'requestNavigation:staticPreview', + ) + setActorMoving(false) + return true + } + + pendingMotionRef.current = { + destinationCellIndex: planningGraph === graph ? (destinationCellIndex ?? null) : null, + moving: pathResult.indices.length > 1, + speed: + pathResult.indices.length > 1 + ? motionRef.current.speed * ACTOR_REPATH_SPEED_RETENTION + : 0, + } + setMotionState( + { + ...createActorMotionState(), + destinationCellIndex: planningGraph === graph ? (destinationCellIndex ?? null) : null, + }, + 'requestNavigation:path', + ) + + if (pathResult.indices.length <= 1) { + setActorMoving(false) + } + + return true + }, + [getResolvedActorVisualWorldPosition, getResolvedActorWorldPosition, graph, setMotionState], + ) + + const requestNavigationToCell = useCallback( + ( + targetCellIndex: number, + targetWorldPosition?: [number, number, number] | null, + planningGraphOverride?: NavigationGraph | null, + ) => { + if (pascalTruckIntroRef.current) { + return false + } + + const planningGraph = planningGraphOverride ?? graph + if (!planningGraph) { + return false + } + + const { actorStartCellIndex: startCellIndex } = getActorNavigationPlanningState( + planningGraph, + selection.levelId ?? null, + ) + if (startCellIndex === null || !planningGraph.cells[startCellIndex]) { + return false + } + + const targetCell = planningGraph.cells[targetCellIndex] + if (!targetCell) { + return false + } + + const pathResult = measureNavigationPerf('navigation.pathfindMs', () => + findNavigationPath(planningGraph, startCellIndex, targetCellIndex), + ) + if (!pathResult) { + return false + } + + return commitPlannedNavigationPath( + planningGraph, + pathResult, + targetWorldPosition, + planningGraph === graph ? targetCellIndex : null, + ) + }, + [commitPlannedNavigationPath, graph, getActorNavigationPlanningState, selection.levelId], + ) + + const requestNavigationToPoint = useCallback( + ( + targetPoint: [number, number, number], + preferredLevelId?: LevelNode['id'] | null, + planningGraphOverride?: NavigationGraph | null, + ) => { + const planningGraph = planningGraphOverride ?? graph + if (!planningGraph) { + return false + } + + const { actorStartComponentId } = getActorNavigationPlanningState( + planningGraph, + preferredLevelId ?? selection.levelId ?? null, + ) + const targetCellIndex = findClosestNavigationCell( + planningGraph, + targetPoint, + preferredLevelId ?? null, + actorStartComponentId, + ) + if (targetCellIndex === null) { + return false + } + + const targetCell = planningGraph.cells[targetCellIndex] + const targetSnapDistance = targetCell + ? Math.hypot( + targetCell.center[0] - targetPoint[0], + (targetCell.center[1] - targetPoint[1]) * 1.5, + targetCell.center[2] - targetPoint[2], + ) + : Number.POSITIVE_INFINITY + + if (targetSnapDistance > MAX_REACHABLE_TARGET_SNAP_DISTANCE) { + return false + } + + return requestNavigationToCell(targetCellIndex, targetPoint, planningGraph) + }, + [getActorNavigationPlanningState, graph, requestNavigationToCell, selection.levelId], + ) + + const tryStartPascalTruckExitPath = useCallback( + (exitState: PascalTruckExitState, options?: { consumePrecomputed?: boolean }) => { + if (!graph) { + return false + } + + const precomputedExitPath = options?.consumePrecomputed + ? precomputedPascalTruckExitRef.current + : null + if (options?.consumePrecomputed) { + precomputedPascalTruckExitRef.current = null + } + + const exitTargetPoint: [number, number, number] = [ + exitState.endPosition[0], + exitState.endPosition[1] - ACTOR_HOVER_Y, + exitState.endPosition[2], + ] + const exitTargetLevelId = + exitState.finalCellIndex !== null + ? (toLevelNodeId(graph.cells[exitState.finalCellIndex]?.levelId) ?? + selection.levelId ?? + null) + : (selection.levelId ?? null) + + return ( + (precomputedExitPath + ? commitPlannedNavigationPath( + precomputedExitPath.planningGraph, + precomputedExitPath.pathResult, + precomputedExitPath.targetWorldPosition, + precomputedExitPath.destinationCellIndex, + ) + : false) || + requestNavigationToPoint(exitTargetPoint, exitTargetLevelId) || + (exitState.finalCellIndex !== null + ? requestNavigationToCell(exitState.finalCellIndex) + : false) + ) + }, + [ + commitPlannedNavigationPath, + graph, + requestNavigationToCell, + requestNavigationToPoint, + selection.levelId, + ], + ) + + const beginPascalTruckExit = useCallback(() => { + const exitPlan = pascalTruckIntroPlan + const actorGroup = actorGroupRef.current + if (!(enabled && graph && exitPlan && actorGroup)) { + pascalTruckExitRef.current = null + setPascalTruckExitActive(false) + setPascalTruckIntroCompleted(false) + setActorCellIndex(null) + resetMotion(true) + return + } + + const actorWorldPosition = getResolvedActorWorldPosition() + const actorToTruckDistance = + actorWorldPosition === null + ? Number.POSITIVE_INFINITY + : Math.hypot( + actorWorldPosition[0] - exitPlan.endPosition[0], + actorWorldPosition[1] - exitPlan.endPosition[1], + actorWorldPosition[2] - exitPlan.endPosition[2], + ) + const exitState: PascalTruckExitState = { + endPosition: exitPlan.endPosition, + fadeElapsedMs: 0, + finalCellIndex: exitPlan.finalCellIndex, + rotationY: exitPlan.rotationY, + stage: actorToTruckDistance <= 0.2 ? 'fade' : 'to-truck', + startPosition: exitPlan.startPosition, + } + + pascalTruckExitRef.current = exitState + setPascalTruckExitActive(true) + setPascalTruckIntroCompleted(false) + motionRef.current.visibilityRevealProgress = 1 + + if (exitState.stage === 'fade') { + actorGroup.position.set( + exitState.endPosition[0], + exitState.endPosition[1], + exitState.endPosition[2], + ) + actorGroup.rotation.y = exitState.rotationY + pendingMotionRef.current = null + setPathIndices([]) + setPathAnchorWorldPosition(null) + setMotionState( + { + ...createActorMotionState(), + destinationCellIndex: exitState.finalCellIndex, + visibilityRevealProgress: 1, + }, + 'pascalTruckExit:start', + ) + setActorMoving(false) + return + } + const started = tryStartPascalTruckExitPath(exitState, { consumePrecomputed: true }) + if (!started) { + pascalTruckExitRef.current = { + ...exitState, + stage: 'fade', + } + actorGroup.position.set( + exitState.endPosition[0], + exitState.endPosition[1], + exitState.endPosition[2], + ) + actorGroup.rotation.y = exitState.rotationY + pendingMotionRef.current = null + setPathIndices([]) + setPathAnchorWorldPosition(null) + setMotionState( + { + ...createActorMotionState(), + destinationCellIndex: exitState.finalCellIndex, + visibilityRevealProgress: 1, + }, + 'pascalTruckExit:fallback', + ) + setActorMoving(false) + } + }, [ + enabled, + getResolvedActorWorldPosition, + graph, + pascalTruckIntroPlan, + resetMotion, + setMotionState, + tryStartPascalTruckExitPath, + ]) + + const schedulePascalTruckExit = useCallback( + (options?: { allowQueuedTasks?: boolean; requiredTaskLoopToken?: number | null }) => { + if (robotMode !== 'task') { + pendingPascalTruckExitRef.current = null + return + } + pendingPascalTruckExitRef.current = { + allowQueuedTasks: options?.allowQueuedTasks ?? false, + requiredTaskLoopToken: options?.requiredTaskLoopToken ?? null, + } + }, + [robotMode], + ) + + useEffect(() => { + if (robotMode !== 'task') { + pendingPascalTruckExitRef.current = null + } + }, [robotMode]) + + useEffect(() => { + const activeSequence = itemMoveSequenceRef.current + if (!activeSequence) { + return + } + + if (activeTaskId !== activeSequence.taskId) { + itemMoveSequenceRef.current = null + itemMoveStageHistoryRef.current.push({ at: performance.now(), stage: null }) + recordNavigationPerfMark('navigation.itemMoveStage', { stage: 'idle' }) + precomputedPascalTruckExitRef.current = null + setReleasedNavigationItemId(null) + clearNavigationItemMoveVisualResidue(activeSequence.request) + if (carriedVisualItemIdRef.current) { + navigationVisualsStore + .getState() + .setItemMoveVisualState(carriedVisualItemIdRef.current, null) + carriedVisualItemIdRef.current = null + } + setToolCarryItemId(null) + clearItemMoveGestureClipState() + useLiveTransforms.getState().clear(getNavigationItemMoveVisualItemId(activeSequence.request)) + activeSequence.controller.cancel() + setItemMoveLocked(false) + resetMotion() + const navigationState = useNavigation.getState() + if ( + actorPositionInitializedRef.current && + navigationState.itemMoveRequest === null && + navigationState.itemDeleteRequest === null && + navigationState.itemRepairRequest === null + ) { + schedulePascalTruckExit() + } + return + } + + if (activeSequence.controller.itemId === activeSequence.request.itemId) { + return + } + + itemMoveSequenceRef.current = null + itemMoveStageHistoryRef.current.push({ at: performance.now(), stage: null }) + recordNavigationPerfMark('navigation.itemMoveStage', { stage: 'idle' }) + precomputedPascalTruckExitRef.current = null + setReleasedNavigationItemId(null) + clearNavigationItemMoveVisualResidue(activeSequence.request) + if (carriedVisualItemIdRef.current) { + navigationVisualsStore.getState().setItemMoveVisualState(carriedVisualItemIdRef.current, null) + carriedVisualItemIdRef.current = null + } + setToolCarryItemId(null) + clearItemMoveGestureClipState() + useLiveTransforms.getState().clear(getNavigationItemMoveVisualItemId(activeSequence.request)) + activeSequence.controller.cancel() + if (activeSequence.taskId) { + removeQueuedTask(activeSequence.taskId) + } else { + requestItemMove(null) + } + setItemMoveLocked(false) + resetMotion() + const navigationState = useNavigation.getState() + if ( + actorPositionInitializedRef.current && + navigationState.itemMoveRequest === null && + navigationState.itemDeleteRequest === null && + navigationState.itemRepairRequest === null + ) { + schedulePascalTruckExit() + } + }, [ + activeTaskId, + clearItemMoveGestureClipState, + clearNavigationItemMoveVisualResidue, + removeQueuedTask, + requestItemMove, + resetMotion, + schedulePascalTruckExit, + setItemMoveLocked, + setReleasedNavigationItemId, + setToolCarryItemId, + ]) + + const hasPendingQueuedNavigationTask = useCallback(() => { + return useNavigation.getState().taskQueue.length > 0 + }, []) + + const advanceTaskLoopAfterCompletion = useCallback( + (completedTaskId: string | null) => { + if (!completedTaskId) { + recordTaskModeTrace( + 'navigation.advanceTaskLoopNoCompletedTask', + {}, + { includeSnapshot: true }, + ) + schedulePascalTruckExit() + return { + hasQueuedTask: false, + wrappedToStart: false, + } + } + + const result = advanceTaskQueue() + recordTaskModeTrace( + 'navigation.advanceTaskLoopAfterCompletion', + { + completedTaskId, + hasQueuedTask: result.hasQueuedTask, + wrappedToStart: result.wrappedToStart, + }, + { includeSnapshot: true }, + ) + if (!result.hasQueuedTask) { + schedulePascalTruckExit() + return result + } + + if (result.wrappedToStart) { + pendingTaskLoopResetBeforeIntroRef.current = true + pendingTaskLoopIntroAfterResetTokenRef.current = null + precomputedPascalTruckExitRef.current = null + schedulePascalTruckExit({ + allowQueuedTasks: true, + }) + } + + return result + }, + [advanceTaskQueue, recordTaskModeTrace, schedulePascalTruckExit], + ) + + useEffect(() => { + const pendingPascalTruckExit = pendingPascalTruckExitRef.current + if (!pendingPascalTruckExit) { + return + } + + if ( + !enabled || + !graph || + !pascalTruckIntroPlan || + !actorPositionInitializedRef.current || + pascalTruckIntroRef.current !== null || + pascalTruckExitRef.current !== null || + itemMoveSequenceRef.current !== null || + itemDeleteSequenceRef.current !== null || + itemRepairSequenceRef.current !== null || + (pendingPascalTruckExit.requiredTaskLoopToken !== null && + taskLoopSettledToken !== pendingPascalTruckExit.requiredTaskLoopToken) || + (pendingPascalTruckExit.allowQueuedTasks && !taskQueuePlanningReady) || + (hasPendingQueuedNavigationTask() && !pendingPascalTruckExit.allowQueuedTasks) + ) { + return + } + + pendingPascalTruckExitRef.current = null + beginPascalTruckExit() + }, [ + beginPascalTruckExit, + enabled, + graph, + hasPendingQueuedNavigationTask, + pascalTruckIntroPlan, + taskLoopSettledToken, + taskQueuePlanningReady, + ]) + + const cancelItemDeleteSequence = useCallback(() => { + const activeSequence = itemDeleteSequenceRef.current + recordTaskModeTrace( + 'navigation.itemDeleteSequenceCancelled', + { + itemId: activeSequence?.request.itemId ?? null, + taskId: activeSequence?.taskId ?? null, + }, + { includeSnapshot: true }, + ) + itemDeleteSequenceRef.current = null + precomputedPascalTruckExitRef.current = null + resetMotion() + clearItemMoveGestureClipState() + if (activeSequence?.taskId) { + removeQueuedTask(activeSequence.taskId) + } else { + requestItemDelete(null) + } + setItemMoveLocked(false) + navigationVisualsStore.getState().clearItemDelete(activeSequence?.request.itemId) + if (actorPositionInitializedRef.current && !hasPendingQueuedNavigationTask()) { + schedulePascalTruckExit() + } + }, [ + clearItemMoveGestureClipState, + hasPendingQueuedNavigationTask, + recordTaskModeTrace, + removeQueuedTask, + requestItemDelete, + resetMotion, + schedulePascalTruckExit, + setItemMoveLocked, + ]) + + const completeItemDeleteSequence = useCallback( + (sequence: NavigationItemDeleteSequence) => { + recordTaskModeTrace( + 'navigation.itemDeleteSequenceCompleted', + { + itemId: sequence.request.itemId, + taskId: sequence.taskId, + }, + { includeSnapshot: true }, + ) + itemDeleteSequenceRef.current = null + precomputedPascalTruckExitRef.current = null + resetMotion() + clearItemMoveGestureClipState() + if (robotMode !== 'task') { + requestItemDelete(null) + } + setItemMoveLocked(false) + navigationVisualsStore.getState().clearItemDelete(sequence.request.itemId) + useScene.getState().deleteNode(sequence.request.itemId) + sfxEmitter.emit('sfx:item-delete') + if (robotMode === 'task') { + if (sequence.taskId) { + advanceTaskLoopAfterCompletion(sequence.taskId) + } else { + requestItemDelete(null) + if (!hasPendingQueuedNavigationTask()) { + schedulePascalTruckExit() + } + } + } else if (!hasPendingQueuedNavigationTask()) { + schedulePascalTruckExit() + } + }, + [ + advanceTaskLoopAfterCompletion, + clearItemMoveGestureClipState, + hasPendingQueuedNavigationTask, + recordTaskModeTrace, + removeQueuedTask, + robotMode, + requestItemDelete, + resetMotion, + schedulePascalTruckExit, + setItemMoveLocked, + ], + ) + + const cancelItemRepairSequence = useCallback(() => { + const activeSequence = itemRepairSequenceRef.current + recordTaskModeTrace( + 'navigation.itemRepairSequenceCancelled', + { + itemId: activeSequence?.request.itemId ?? null, + taskId: activeSequence?.taskId ?? null, + }, + { includeSnapshot: true }, + ) + itemRepairSequenceRef.current = null + precomputedPascalTruckExitRef.current = null + resetMotion() + clearItemMoveGestureClipState() + if (activeSequence?.taskId) { + removeQueuedTask(activeSequence.taskId) + } else { + requestItemRepair(null) + } + setItemMoveLocked(false) + if (actorPositionInitializedRef.current && !hasPendingQueuedNavigationTask()) { + schedulePascalTruckExit() + } + }, [ + clearItemMoveGestureClipState, + hasPendingQueuedNavigationTask, + recordTaskModeTrace, + removeQueuedTask, + requestItemRepair, + resetMotion, + schedulePascalTruckExit, + setItemMoveLocked, + ]) + + useEffect(() => { + const activeSequence = itemDeleteSequenceRef.current + if (!activeSequence || activeTaskId === activeSequence.taskId) { + return + } + + itemDeleteSequenceRef.current = null + precomputedPascalTruckExitRef.current = null + resetMotion() + clearItemMoveGestureClipState() + setItemMoveLocked(false) + navigationVisualsStore.getState().clearItemDelete(activeSequence.request.itemId) + const navigationState = useNavigation.getState() + if ( + actorPositionInitializedRef.current && + navigationState.itemMoveRequest === null && + navigationState.itemDeleteRequest === null && + navigationState.itemRepairRequest === null + ) { + schedulePascalTruckExit() + } + }, [ + activeTaskId, + clearItemMoveGestureClipState, + resetMotion, + schedulePascalTruckExit, + setItemMoveLocked, + ]) + + useEffect(() => { + const activeSequence = itemRepairSequenceRef.current + if (!activeSequence || activeTaskId === activeSequence.taskId) { + return + } + + itemRepairSequenceRef.current = null + precomputedPascalTruckExitRef.current = null + resetMotion() + clearItemMoveGestureClipState() + setItemMoveLocked(false) + const navigationState = useNavigation.getState() + if ( + actorPositionInitializedRef.current && + navigationState.itemMoveRequest === null && + navigationState.itemDeleteRequest === null && + navigationState.itemRepairRequest === null + ) { + schedulePascalTruckExit() + } + }, [ + activeTaskId, + clearItemMoveGestureClipState, + resetMotion, + schedulePascalTruckExit, + setItemMoveLocked, + ]) + + const completeItemRepairSequence = useCallback( + (sequence: NavigationItemRepairSequence) => { + recordTaskModeTrace( + 'navigation.itemRepairSequenceCompleted', + { + itemId: sequence.request.itemId, + taskId: sequence.taskId, + }, + { includeSnapshot: true }, + ) + itemRepairSequenceRef.current = null + precomputedPascalTruckExitRef.current = null + resetMotion() + clearItemMoveGestureClipState() + if (robotMode !== 'task') { + requestItemRepair(null) + } + setItemMoveLocked(false) + if (robotMode === 'task') { + if (sequence.taskId) { + advanceTaskLoopAfterCompletion(sequence.taskId) + } else { + requestItemRepair(null) + if (!hasPendingQueuedNavigationTask()) { + schedulePascalTruckExit() + } + } + } else if (!hasPendingQueuedNavigationTask()) { + schedulePascalTruckExit() + } + }, + [ + advanceTaskLoopAfterCompletion, + clearItemMoveGestureClipState, + hasPendingQueuedNavigationTask, + recordTaskModeTrace, + removeQueuedTask, + robotMode, + requestItemRepair, + resetMotion, + schedulePascalTruckExit, + setItemMoveLocked, + ], + ) + + useEffect(() => { + if ( + !( + enabled && + graph && + itemMoveRequest && + !itemMoveLocked && + taskQueuePlanningReady && + headItemMoveController && + pascalTruckIntroCompleted && + pascalTruckIntroTaskReady && + pendingPascalTruckExitRef.current === null && + !pascalTruckIntroRef.current && + !pascalTruckExitRef.current + ) + ) { + return + } + + if ( + headItemMoveController.itemId !== itemMoveRequest.itemId || + itemMoveSequenceRef.current || + itemRepairSequenceRef.current + ) { + return + } + + if (releasedNavigationItemId !== null) { + setReleasedNavigationItemId(null) + return + } + + const abortPendingItemMove = () => { + headItemMoveController.cancel() + if (activeTaskId) { + removeQueuedTask(activeTaskId) + } else { + requestItemMove(null) + } + setItemMoveLocked(false) + } + + const targetPosition = itemMoveRequest.finalUpdate.position + const targetRotation = itemMoveRequest.finalUpdate.rotation ?? itemMoveRequest.sourceRotation + const targetRotationY = targetRotation?.[1] ?? itemMoveRequest.sourceRotation[1] ?? 0 + + if (!targetPosition || !targetRotation) { + abortPendingItemMove() + return + } + + const { actorNavigationPoint, actorStartCellIndex, actorStartComponentId } = + getActorNavigationPlanningState( + graph, + selection.levelId ?? toLevelNodeId(itemMoveRequest.levelId) ?? null, + ) + if (actorStartCellIndex === null) { + abortPendingItemMove() + return + } + + const itemMovePlanCacheKey = createNavigationItemMovePlanCacheKey( + itemMoveRequest, + actorStartCellIndex, + navigationSceneSnapshot?.key ?? null, + selection.buildingId ?? null, + ) + const precomputedItemMovePlan = + robotMode === 'task' + ? null + : itemMovePreviewPlanRef.current?.cacheKey === itemMovePlanCacheKey + ? itemMovePreviewPlanRef.current + : (itemMovePreviewPlanCacheRef.current.get(itemMovePlanCacheKey) ?? null) + const resolvedItemMovePlan = + precomputedItemMovePlan ?? + resolveItemMovePlan( + itemMoveRequest, + actorStartCellIndex, + actorNavigationPoint, + actorStartComponentId, + ) + mergeNavigationPerfMeta({ + navigationItemMoveUsedPreviewPlan: Boolean(precomputedItemMovePlan), + }) + if (!resolvedItemMovePlan) { + abortPendingItemMove() + return + } + + const { + exitPath, + sourceApproach, + sourcePath: pathToSource, + targetApproach: resolvedTargetApproach, + targetPath: resolvedPathToTarget, + targetPlanningGraph: resolvedTargetPlanningGraph, + } = resolvedItemMovePlan + + const started = commitPlannedNavigationPath( + graph, + pathToSource, + sourceApproach.world, + sourceApproach.cellIndex, + ) + if (!started) { + abortPendingItemMove() + return + } + + itemMoveSequenceRef.current = { + controller: headItemMoveController, + dropGesture: getRandomItemMoveGesture(), + dropStartedAt: null, + dropStartPosition: null, + dropSettledAt: null, + exitPath, + pickupCarryVisualStartedAt: null, + pickupGesture: getRandomItemMoveGesture(), + pickupStartedAt: null, + pickupTransferStartedAt: null, + request: itemMoveRequest, + sourceDisplayPosition: getRenderedFloorItemPosition( + itemMoveRequest.levelId, + itemMoveRequest.sourcePosition, + itemMoveRequest.itemDimensions, + itemMoveRequest.sourceRotation, + ), + sourceApproach, + sourcePath: pathToSource, + stage: 'to-source', + taskId: activeTaskId, + targetDisplayPosition: getRenderedFloorItemPosition( + itemMoveRequest.levelId, + targetPosition, + itemMoveRequest.itemDimensions, + targetRotation, + ), + targetApproach: resolvedTargetApproach, + targetPath: resolvedPathToTarget, + targetPlanningGraph: resolvedTargetPlanningGraph, + targetRotationY, + } + itemMoveStageHistoryRef.current = [{ at: performance.now(), stage: 'to-source' }] + recordTaskModeTrace( + 'navigation.itemMoveSequenceStarted', + { + activeTaskId, + itemId: itemMoveRequest.itemId, + visualItemId: getNavigationItemMoveVisualItemId(itemMoveRequest), + }, + { includeSnapshot: true }, + ) + }, [ + activeTaskId, + commitPlannedNavigationPath, + enabled, + graph, + getActorNavigationPlanningState, + headItemMoveController, + itemMoveLocked, + itemMoveRequest, + navigationSceneSnapshot?.key, + pascalTruckIntroCompleted, + pascalTruckIntroTaskReady, + removeQueuedTask, + requestItemMove, + recordTaskModeTrace, + releasedNavigationItemId, + resolveItemMovePlan, + selection.buildingId, + selection.levelId, + taskQueuePlanningReady, + setItemMoveLocked, + ]) + + useEffect(() => { + if ( + !( + enabled && + graph && + itemDeleteRequest && + !itemMoveLocked && + taskQueuePlanningReady && + pascalTruckIntroCompleted && + pascalTruckIntroTaskReady && + pendingPascalTruckExitRef.current === null && + !pascalTruckIntroRef.current && + !pascalTruckExitRef.current + ) + ) { + return + } + + if ( + itemMoveSequenceRef.current || + itemDeleteSequenceRef.current || + itemRepairSequenceRef.current + ) { + return + } + + const { actorNavigationPoint, actorStartCellIndex, actorStartComponentId } = + getActorNavigationPlanningState( + graph, + selection.levelId ?? toLevelNodeId(itemDeleteRequest.levelId) ?? null, + ) + + if (actorStartCellIndex === null) { + cancelItemDeleteSequence() + return + } + + const sourceApproach = findItemMoveApproach( + graph, + { + dimensions: itemDeleteRequest.itemDimensions, + footprintBounds: extractObjectLocalFootprintBounds( + sceneRegistry.nodes.get(itemDeleteRequest.itemId) ?? null, + ), + levelId: itemDeleteRequest.levelId, + position: itemDeleteRequest.sourcePosition, + rotation: itemDeleteRequest.sourceRotation, + }, + actorStartComponentId, + actorStartCellIndex, + actorNavigationPoint, + ) + + if (!sourceApproach) { + cancelItemDeleteSequence() + return + } + + if (!findNavigationPath(graph, actorStartCellIndex, sourceApproach.cellIndex)) { + cancelItemDeleteSequence() + return + } + + const started = requestNavigationToPoint(sourceApproach.world) + if (!started) { + cancelItemDeleteSequence() + return + } + + itemDeleteSequenceRef.current = { + deleteStartedAt: null, + gesture: getRandomItemMoveGesture(), + request: itemDeleteRequest, + sourceApproach, + stage: 'to-source', + taskId: activeTaskId, + } + recordTaskModeTrace( + 'navigation.itemDeleteSequenceStarted', + { + activeTaskId, + itemId: itemDeleteRequest.itemId, + }, + { includeSnapshot: true }, + ) + }, [ + activeTaskId, + cancelItemDeleteSequence, + enabled, + graph, + getActorNavigationPlanningState, + itemDeleteRequest, + itemMoveLocked, + pascalTruckIntroCompleted, + pascalTruckIntroTaskReady, + recordTaskModeTrace, + requestNavigationToPoint, + selection.levelId, + taskQueuePlanningReady, + ]) + + useEffect(() => { + if ( + !( + enabled && + graph && + itemRepairRequest && + !itemMoveLocked && + taskQueuePlanningReady && + pascalTruckIntroCompleted && + pascalTruckIntroTaskReady && + pendingPascalTruckExitRef.current === null && + !pascalTruckIntroRef.current && + !pascalTruckExitRef.current + ) + ) { + return + } + + if ( + itemMoveSequenceRef.current || + itemDeleteSequenceRef.current || + itemRepairSequenceRef.current + ) { + return + } + + const { actorNavigationPoint, actorStartCellIndex, actorStartComponentId } = + getActorNavigationPlanningState( + graph, + selection.levelId ?? toLevelNodeId(itemRepairRequest.levelId) ?? null, + ) + + if (actorStartCellIndex === null) { + cancelItemRepairSequence() + return + } + + const sourceApproach = findItemMoveApproach( + graph, + { + dimensions: itemRepairRequest.itemDimensions, + footprintBounds: extractObjectLocalFootprintBounds( + sceneRegistry.nodes.get(itemRepairRequest.itemId) ?? null, + ), + levelId: itemRepairRequest.levelId, + position: itemRepairRequest.sourcePosition, + rotation: itemRepairRequest.sourceRotation, + }, + actorStartComponentId, + actorStartCellIndex, + actorNavigationPoint, + ) + + if (!sourceApproach) { + cancelItemRepairSequence() + return + } + + if (!findNavigationPath(graph, actorStartCellIndex, sourceApproach.cellIndex)) { + cancelItemRepairSequence() + return + } + + const started = requestNavigationToPoint(sourceApproach.world) + if (!started) { + cancelItemRepairSequence() + return + } + + itemRepairSequenceRef.current = { + gesture: getRandomItemMoveGesture(), + repairStartedAt: null, + request: itemRepairRequest, + sourceApproach, + stage: 'to-source', + taskId: activeTaskId, + } + recordTaskModeTrace( + 'navigation.itemRepairSequenceStarted', + { + activeTaskId, + itemId: itemRepairRequest.itemId, + }, + { includeSnapshot: true }, + ) + }, [ + activeTaskId, + cancelItemRepairSequence, + enabled, + graph, + getActorNavigationPlanningState, + itemMoveLocked, + itemRepairRequest, + pascalTruckIntroCompleted, + pascalTruckIntroTaskReady, + recordTaskModeTrace, + requestItemRepair, + requestNavigationToPoint, + selection.levelId, + taskQueuePlanningReady, + ]) + + useEffect(() => { + if ( + !( + enabled && + graph && + pascalTruckIntroCompleted && + pascalTruckIntroTaskReady && + !pascalTruckIntroRef.current && + !pascalTruckExitRef.current + ) + ) { + return + } + + const canvas = gl.domElement + + const canHandleNavigationClick = () => { + const { + itemMoveControllers: currentItemMoveControllers, + itemRepairRequest: currentItemRepairRequest, + navigationClickSuppressedUntil, + } = useNavigation.getState() + const hasQueuedMoveController = Object.keys(currentItemMoveControllers).length > 0 + + if ( + cameraDragging || + itemDeleteSequenceRef.current || + itemRepairSequenceRef.current || + pendingPascalTruckExitRef.current !== null || + hasQueuedMoveController || + useNavigation.getState().itemDeleteRequest || + currentItemRepairRequest || + useNavigation.getState().itemMoveLocked || + useEditor.getState().movingNode || + performance.now() < navigationClickSuppressedUntil + ) { + return false + } + + const committedActorIndex = actorCellIndex + if (committedActorIndex === null || !graph.cells[committedActorIndex]) { + return false + } + + return true + } + + const requestNavigationAtClientPoint = (clientX: number, clientY: number) => { + if (!canHandleNavigationClick()) { + return false + } + + const committedActorIndex = actorCellIndex + if (committedActorIndex === null || !graph.cells[committedActorIndex]) { + return false + } + + const rect = canvas.getBoundingClientRect() + pointerRef.current.x = ((clientX - rect.left) / rect.width) * 2 - 1 + pointerRef.current.y = -((clientY - rect.top) / rect.height) * 2 + 1 + raycasterRef.current.setFromCamera(pointerRef.current, camera) + + const preferredLevelId = + selection.levelId ?? graph.cells[committedActorIndex]?.levelId ?? null + const pickableObjects = getPickableNavigationObjects() + const pickableRoots = new Set(pickableObjects) + const occluderObjects = getNavigationOccluderObjects() + const occluderRoots = new Set(occluderObjects) + const intersections = raycasterRef.current.intersectObjects( + [...pickableObjects, ...occluderObjects], + true, + ) + const hits = intersections.filter((hit) => objectBelongsToRoots(hit.object, pickableRoots)) + const firstHit = hits[0] ?? null + const firstOccludingHit = + intersections.find((hit) => objectBelongsToRoots(hit.object, occluderRoots)) ?? null + + if ( + firstOccludingHit && + (!firstHit || firstOccludingHit.distance <= firstHit.distance + Number.EPSILON) + ) { + return false + } + + if (hits.length === 0) { + return false + } + + // Some rooms sit below overlapping slabs from upper levels. Try the visible + // hits in depth order and pick the first one that resolves on the active level. + for (const hit of hits) { + if (requestNavigationToPoint([hit.point.x, hit.point.y, hit.point.z], preferredLevelId)) { + return true + } + } + return false + } + + const handleClick = (event: MouseEvent) => { + if (event.button !== 0 || robotMode === 'normal') { + return + } + + requestNavigationAtClientPoint(event.clientX, event.clientY) + } + + const handleContextMenu = (event: MouseEvent) => { + if (robotMode !== 'normal') { + return + } + + if (cameraDragging) { + return + } + + event.preventDefault() + requestNavigationAtClientPoint(event.clientX, event.clientY) + } + + canvas.addEventListener('click', handleClick) + canvas.addEventListener('contextmenu', handleContextMenu) + return () => { + canvas.removeEventListener('click', handleClick) + canvas.removeEventListener('contextmenu', handleContextMenu) + } + }, [ + actorCellIndex, + camera, + cameraDragging, + enabled, + gl.domElement, + graph, + pascalTruckIntroCompleted, + pascalTruckIntroTaskReady, + requestNavigationToPoint, + robotMode, + selection.levelId, + ]) + + useEffect(() => { + const pendingMotion = pendingMotionRef.current + if (!pendingMotion) { + return + } + + if (pendingMotion.moving && !primaryMotionCurve) { + return + } + + setMotionState( + { + ...createActorMotionState(), + destinationCellIndex: pendingMotion.destinationCellIndex, + distance: 0, + moving: pendingMotion.moving, + speed: pendingMotion.speed, + }, + 'pendingMotion:flush', + ) + recordTaskModeTrace('navigation.pendingMotionFlushed', { + destinationCellIndex: pendingMotion.destinationCellIndex, + moving: pendingMotion.moving, + speed: pendingMotion.speed, + }) + pendingMotionRef.current = null + setActorMoving(pendingMotion.moving) + }, [pathIndices, primaryMotionCurve, recordTaskModeTrace]) + + useEffect(() => { + const hasPendingTaskRequest = + itemMoveRequest !== null || itemDeleteRequest !== null || itemRepairRequest !== null + const hasPendingTaskWork = + hasPendingTaskRequest || (robotMode === 'task' && taskQueue.length > 0) + const hasPendingTaskLoopReset = + pendingTaskLoopResetBeforeIntroRef.current || + pendingTaskLoopIntroAfterResetTokenRef.current !== null + if ( + !( + enabled && + !introAnimationDebugActive && + pascalTruckIntroPlan && + !pascalTruckIntroCompleted && + !pascalTruckIntroRef.current && + !pascalTruckExitActive && + !hasPendingTaskLoopReset && + (robotMode !== 'task' || (hasPendingTaskWork && taskQueuePlanningReady)) + ) + ) { + return + } + + debugPascalTruckIntroAttemptCountRef.current += 1 + if (beginPascalTruckIntro()) { + debugPascalTruckIntroStartCountRef.current += 1 + } + }, [ + beginPascalTruckIntro, + enabled, + introAnimationDebugActive, + itemDeleteRequest, + itemMoveRequest, + itemRepairRequest, + pascalTruckExitActive, + pascalTruckIntroCompleted, + pascalTruckIntroPlan, + robotMode, + taskQueue.length, + taskQueuePlanningReady, + ]) + + useEffect(() => { + const hasPendingTaskWork = + itemMoveRequest !== null || + itemDeleteRequest !== null || + itemRepairRequest !== null || + taskQueue.length > 0 + if ( + introAnimationDebugActive || + robotMode !== 'task' || + !pascalTruckIntroRef.current || + hasPendingTaskWork || + itemMoveSequenceRef.current !== null || + itemDeleteSequenceRef.current !== null || + itemRepairSequenceRef.current !== null + ) { + return + } + + pascalTruckIntroRef.current = null + pascalTruckIntroPostWarmupTokenRef.current = null + setPascalTruckIntroActive(false) + setPascalTruckIntroCompleted(false) + setActorCellIndex(null) + resetMotion(true) + }, [ + introAnimationDebugActive, + itemDeleteRequest, + itemMoveRequest, + itemRepairRequest, + resetMotion, + robotMode, + taskQueue.length, + ]) + + useEffect(() => { + if (!(actorSpawnPosition && actorGroupRef.current)) { + return + } + + if (actorPositionInitializedRef.current && lastPublishedActorPositionRef.current) { + return + } + + actorGroupRef.current.position.set( + actorSpawnPosition[0], + actorSpawnPosition[1], + actorSpawnPosition[2], + ) + if (pascalTruckIntroRef.current) { + actorGroupRef.current.rotation.y = pascalTruckIntroRef.current.rotationY + } + actorPositionInitializedRef.current = true + setPathAnchorWorldPosition(null) + lastPublishedActorPositionRef.current = actorSpawnPosition + lastPublishedActorPositionAtRef.current = performance.now() + setActorAvailable(true) + setActorWorldPosition(actorSpawnPosition) + recordTaskModeTrace('navigation.actorSpawnInitialized', { + actorSpawnPosition, + }) + navigationEmitter.emit('navigation:actor-transform', { + moving: false, + position: actorSpawnPosition, + rotationY: actorGroupRef.current.rotation.y, + }) + }, [actorSpawnPosition, recordTaskModeTrace, setActorAvailable, setActorWorldPosition]) + + const tryStartPascalTruckIntroReveal = useCallback( + ( + trigger: 'post-warmup-ready' | 'robot-ready' | 'robot-ready-timeout', + options?: { ignorePendingWarmup?: boolean }, + ) => { + const pascalTruckIntro = pascalTruckIntroRef.current + if (!(pascalTruckIntro && !pascalTruckIntro.revealStarted)) { + return false + } + + const pendingWarmupToken = pascalTruckIntroPostWarmupTokenRef.current + if ( + !options?.ignorePendingWarmup && + pendingWarmupToken !== null && + navigationPostWarmupCompletedToken < pendingWarmupToken + ) { + return false + } + + recordNavigationPerfMark('navigation.pascalTruckIntroRevealStart', { trigger }) + pascalTruckIntro.revealStarted = true + pascalTruckIntroPostWarmupTokenRef.current = null + return true + }, + [navigationPostWarmupCompletedToken], + ) + + const handleActorRobotWarmupReadyChange = useCallback((ready: boolean) => { + actorRobotWarmupReadyRef.current = ready + setActorRobotWarmupReady(ready) + }, []) + + useEffect(() => { + actorRobotWarmupReadyRef.current = actorRobotWarmupReady + }, [actorRobotWarmupReady]) + + useEffect(() => { + if (pascalTruckIntroPostWarmupTokenRef.current === null) { + return + } + + if (navigationPostWarmupCompletedToken >= pascalTruckIntroPostWarmupTokenRef.current) { + recordNavigationPerfMark('navigation.postWarmupComplete', { + token: pascalTruckIntroPostWarmupTokenRef.current, + trigger: 'intro', + }) + } + void tryStartPascalTruckIntroReveal('post-warmup-ready') + }, [navigationPostWarmupCompletedToken, tryStartPascalTruckIntroReveal]) + + const handlePascalTruckIntroRobotReady = useCallback(() => { + const pascalTruckIntro = pascalTruckIntroRef.current + if (!(pascalTruckIntro && !pascalTruckIntro.revealStarted)) { + return + } + + const baselineWarmupReady = + lastNavigationPostWarmupRequestKeyRef.current === navigationPostWarmupRequestKey && + navigationPostWarmupRequestToken <= navigationPostWarmupCompletedToken + if (baselineWarmupReady) { + void tryStartPascalTruckIntroReveal('robot-ready') + return + } + + if (pascalTruckIntroPostWarmupTokenRef.current === null) { + const token = navigationVisualsStore.getState().requestNavigationPostWarmup() + pascalTruckIntroPostWarmupTokenRef.current = token + recordNavigationPerfMark('navigation.postWarmupRequest', { + token, + trigger: 'intro', + }) + return + } + + void tryStartPascalTruckIntroReveal('robot-ready') + }, [ + navigationPostWarmupCompletedToken, + navigationPostWarmupRequestKey, + navigationPostWarmupRequestToken, + tryStartPascalTruckIntroReveal, + ]) + + useEffect(() => { + if (!(pascalTruckIntroActive && actorRobotWarmupReady)) { + return + } + + handlePascalTruckIntroRobotReady() + }, [actorRobotWarmupReady, handlePascalTruckIntroRobotReady, pascalTruckIntroActive]) + + useEffect(() => { + const pendingTaskLoopIntroToken = pendingTaskLoopIntroAfterResetTokenRef.current + if (pendingTaskLoopIntroToken === null) { + return + } + + const hasPendingTaskRequest = + itemMoveRequest !== null || itemDeleteRequest !== null || itemRepairRequest !== null + const hasPendingTaskWork = + hasPendingTaskRequest || (robotMode === 'task' && taskQueue.length > 0) + if (!hasPendingTaskWork) { + pendingTaskLoopIntroAfterResetTokenRef.current = null + return + } + + if ( + !( + enabled && + graph && + pascalTruckIntroPlan && + !introAnimationDebugActive && + !pascalTruckIntroRef.current && + !pascalTruckExitActive && + !pascalTruckExitRef.current && + robotMode === 'task' && + taskLoopSettledToken === pendingTaskLoopIntroToken && + taskQueuePlanningReady + ) + ) { + return + } + + if (beginPascalTruckIntro()) { + pendingTaskLoopIntroAfterResetTokenRef.current = null + recordTaskModeTrace( + 'navigation.taskLoopIntroAfterReset', + { taskLoopToken: pendingTaskLoopIntroToken }, + { includeSnapshot: true }, + ) + setActorCellIndex(null) + } + }, [ + beginPascalTruckIntro, + enabled, + graph, + introAnimationDebugActive, + itemDeleteRequest, + itemMoveRequest, + itemRepairRequest, + pascalTruckExitActive, + pascalTruckIntroPlan, + recordTaskModeTrace, + robotMode, + taskLoopSettledToken, + taskQueue.length, + taskQueuePlanningReady, + ]) + + const cancelDeferredItemMoveCommit = useCallback(() => { + if (deferredItemMoveCommitFrameRef.current !== null) { + cancelAnimationFrame(deferredItemMoveCommitFrameRef.current) + deferredItemMoveCommitFrameRef.current = null + } + + if ( + deferredItemMoveCommitIdleRef.current !== null && + typeof window !== 'undefined' && + 'cancelIdleCallback' in window + ) { + window.cancelIdleCallback(deferredItemMoveCommitIdleRef.current) + deferredItemMoveCommitIdleRef.current = null + } + + if (deferredItemMoveCommitTimeoutRef.current !== null) { + window.clearTimeout(deferredItemMoveCommitTimeoutRef.current) + deferredItemMoveCommitTimeoutRef.current = null + } + }, []) + + useEffect(() => { + return () => { + cancelDeferredItemMoveCommit() + } + }, [cancelDeferredItemMoveCommit]) + + useEffect(() => { + if (queueRestartToken === processedQueueRestartTokenRef.current) { + return + } + + recordTaskModeTrace( + 'navigation.queueRestartDetected', + { + nextQueueRestartToken: queueRestartToken, + previousQueueRestartToken: processedQueueRestartTokenRef.current, + queueLength: taskQueue.length, + }, + { includeSnapshot: true }, + ) + processedQueueRestartTokenRef.current = queueRestartToken + if (!enabled || robotMode !== 'task' || taskQueue.length === 0) { + return + } + + cancelDeferredItemMoveCommit() + + const timeoutId = pascalTruckIntroTaskReadyTimeoutRef.current + if (timeoutId !== null) { + window.clearTimeout(timeoutId) + pascalTruckIntroTaskReadyTimeoutRef.current = null + } + + const activeMoveSequence = itemMoveSequenceRef.current + itemMoveSequenceRef.current = null + if (activeMoveSequence) { + itemMoveStageHistoryRef.current.push({ at: performance.now(), stage: null }) + recordNavigationPerfMark('navigation.itemMoveStage', { stage: 'idle' }) + setReleasedNavigationItemId(null) + clearNavigationItemMoveVisualResidue(activeMoveSequence.request) + if (carriedVisualItemIdRef.current) { + navigationVisualsStore + .getState() + .setItemMoveVisualState(carriedVisualItemIdRef.current, null) + carriedVisualItemIdRef.current = null + } + setToolCarryItemId(null) + useLiveTransforms + .getState() + .clear(getNavigationItemMoveVisualItemId(activeMoveSequence.request)) + } + + const activeDeleteSequence = itemDeleteSequenceRef.current + itemDeleteSequenceRef.current = null + if (activeDeleteSequence) { + navigationVisualsStore.getState().clearItemDelete(activeDeleteSequence.request.itemId) + } + + itemRepairSequenceRef.current = null + + pascalTruckIntroRef.current = null + pascalTruckIntroPostWarmupTokenRef.current = null + pascalTruckExitRef.current = null + pendingPascalTruckExitRef.current = null + precomputedPascalTruckExitRef.current = null + pendingTaskLoopResetBeforeIntroRef.current = false + pendingTaskLoopIntroAfterResetTokenRef.current = null + pascalTruckIntroPendingSettlePositionRef.current = null + setPendingTaskGraphSyncKey(null) + setPascalTruckIntroTaskReady(false) + doorCollisionStateRef.current = { + blocked: false, + doorIds: [], + } + clearItemMoveGestureClipState() + setItemMoveLocked(false) + setToolCarryItemId(null) + setPascalTruckIntroActive(false) + setPascalTruckExitActive(false) + setPascalTruckIntroCompleted(false) + setActorCellIndex(null) + resetMotion(true) + }, [ + cancelDeferredItemMoveCommit, + clearItemMoveGestureClipState, + clearNavigationItemMoveVisualResidue, + enabled, + queueRestartToken, + recordTaskModeTrace, + resetMotion, + robotMode, + setPendingTaskGraphSyncKey, + setItemMoveLocked, + setReleasedNavigationItemId, + setToolCarryItemId, + taskQueue.length, + ]) + + const scheduleDeferredItemMoveCommit = useCallback( + ( + sequence: NavigationItemMoveSequence, + finalCarryTransform?: { position: [number, number, number]; rotation: number }, + ) => { + cancelDeferredItemMoveCommit() + const commitItemMove = () => { + deferredItemMoveCommitIdleRef.current = null + deferredItemMoveCommitTimeoutRef.current = null + const nextTaskGraphSyncKey = + robotMode === 'task' ? buildItemMoveTargetSceneSnapshot(sequence.request).key : null + if (robotMode === 'task') { + itemMovePreviewPlanRef.current = null + itemMovePreviewPlanCacheRef.current.clear() + } + setPendingTaskGraphSyncKey(nextTaskGraphSyncKey) + measureNavigationPerf('navigation.itemMoveCommitMs', () => + sequence.controller.commit(sequence.request.finalUpdate, finalCarryTransform), + ) + setItemMoveLocked(false) + if (robotMode === 'task') { + if (sequence.taskId) { + advanceTaskLoopAfterCompletion(sequence.taskId) + } else { + requestItemMove(null) + if (!hasPendingQueuedNavigationTask()) { + schedulePascalTruckExit() + } + } + } else if (!hasPendingQueuedNavigationTask()) { + schedulePascalTruckExit() + } + } + + if (robotMode === 'task') { + queueMicrotask(commitItemMove) + return + } + + deferredItemMoveCommitTimeoutRef.current = window.setTimeout(() => { + deferredItemMoveCommitTimeoutRef.current = null + + if (typeof window !== 'undefined' && 'requestIdleCallback' in window) { + deferredItemMoveCommitIdleRef.current = window.requestIdleCallback(commitItemMove, { + timeout: ITEM_MOVE_COMMIT_IDLE_TIMEOUT_MS, + }) + return + } + + commitItemMove() + }, ITEM_MOVE_COMMIT_DEFER_DELAY_MS) + }, + [ + advanceTaskLoopAfterCompletion, + buildItemMoveTargetSceneSnapshot, + cancelDeferredItemMoveCommit, + hasPendingQueuedNavigationTask, + removeQueuedTask, + requestItemMove, + robotMode, + schedulePascalTruckExit, + setPendingTaskGraphSyncKey, + setItemMoveLocked, + ], + ) + + const cancelItemMoveSequence = useCallback(() => { + const activeSequence = itemMoveSequenceRef.current + recordTaskModeTrace( + 'navigation.itemMoveSequenceCancelled', + { + itemId: activeSequence?.request.itemId ?? null, + taskId: activeSequence?.taskId ?? null, + }, + { includeSnapshot: true }, + ) + itemMoveSequenceRef.current = null + itemMoveStageHistoryRef.current.push({ at: performance.now(), stage: null }) + recordNavigationPerfMark('navigation.itemMoveStage', { stage: 'idle' }) + cancelDeferredItemMoveCommit() + precomputedPascalTruckExitRef.current = null + setReleasedNavigationItemId(null) + clearNavigationItemMoveVisualResidue(activeSequence?.request ?? null) + if (carriedVisualItemIdRef.current) { + navigationVisualsStore.getState().setItemMoveVisualState(carriedVisualItemIdRef.current, null) + carriedVisualItemIdRef.current = null + } + setToolCarryItemId(null) + clearItemMoveGestureClipState() + setItemMoveLocked(false) + if (activeSequence) { + useLiveTransforms.getState().clear(getNavigationItemMoveVisualItemId(activeSequence.request)) + if (activeSequence.taskId) { + removeQueuedTask(activeSequence.taskId) + } else { + requestItemMove(null) + } + } else { + requestItemMove(null) + } + activeSequence?.controller.cancel() + if (actorPositionInitializedRef.current && !hasPendingQueuedNavigationTask()) { + schedulePascalTruckExit() + } + }, [ + clearItemMoveGestureClipState, + clearNavigationItemMoveVisualResidue, + hasPendingQueuedNavigationTask, + recordTaskModeTrace, + removeQueuedTask, + requestItemMove, + schedulePascalTruckExit, + setItemMoveLocked, + setReleasedNavigationItemId, + setToolCarryItemId, + ]) + + const completeItemMoveSequence = useCallback( + ( + sequence: NavigationItemMoveSequence, + finalCarryTransform?: { position: [number, number, number]; rotation: number }, + ) => { + recordTaskModeTrace( + 'navigation.itemMoveSequenceCompleted', + { + itemId: sequence.request.itemId, + taskId: sequence.taskId, + visualItemId: getNavigationItemMoveVisualItemId(sequence.request), + }, + { includeSnapshot: true }, + ) + itemMoveSequenceRef.current = null + itemMoveStageHistoryRef.current.push({ at: performance.now(), stage: null }) + recordNavigationPerfMark('navigation.itemMoveStage', { stage: 'idle' }) + precomputedPascalTruckExitRef.current = sequence.exitPath + // The item commit rebuilds the navigation graph. Clear the finished route first so + // stale cell indices from the previous graph cannot render as a bogus post-drop path. + setReleasedNavigationItemId(null) + clearNavigationItemMoveVisualResidue(sequence.request, { + preserveDestinationGhost: robotMode === 'task' && sequence.taskId !== null, + }) + if (carriedVisualItemIdRef.current) { + navigationVisualsStore + .getState() + .setItemMoveVisualState(carriedVisualItemIdRef.current, null) + carriedVisualItemIdRef.current = null + } + setToolCarryItemId(null) + resetMotion() + clearItemMoveGestureClipState() + if (robotMode !== 'task') { + requestItemMove(null) + } + scheduleDeferredItemMoveCommit(sequence, finalCarryTransform) + }, + [ + clearItemMoveGestureClipState, + clearNavigationItemMoveVisualResidue, + recordTaskModeTrace, + requestItemMove, + robotMode, + resetMotion, + scheduleDeferredItemMoveCommit, + setReleasedNavigationItemId, + setToolCarryItemId, + ], + ) + + useFrame(() => { + const shadowMap = (gl as typeof gl & { shadowMap?: RendererShadowMap }).shadowMap + if (!shadowMap) { + return + } + + const shadowController = shadowControllerRef.current + const now = performance.now() + const shadowsEnabled = shadowMapOverrideEnabledRef.current !== false + const shouldAutoUpdate = shadowsEnabled + + if (shadowController.currentAutoUpdate !== shouldAutoUpdate) { + shadowMap.autoUpdate = shouldAutoUpdate + shadowController.currentAutoUpdate = shouldAutoUpdate + } + + if (shadowController.currentEnabled !== shadowsEnabled) { + shadowMap.enabled = shadowsEnabled + shadowController.currentEnabled = shadowsEnabled + shadowMap.needsUpdate = shadowsEnabled + } + + if (!shadowsEnabled) { + shadowMap.needsUpdate = false + } + + shadowController.dynamicSettleFrames = 0 + shadowController.lastDynamicUpdateAtMs = shadowsEnabled ? now : 0 + + mergeNavigationPerfMeta({ + navigationShadowAutoUpdate: shouldAutoUpdate, + navigationShadowDynamicScene: false, + navigationShadowMapEnabled: shadowsEnabled, + navigationShadowFrozenDuringNavigation: false, + navigationShadowThrottled: false, + }) + }) + + useFrame((_, delta) => { + if (!graph) { + return + } + + const actorGroup = actorGroupRef.current + const frameStart = performance.now() + const frameDeltaMs = delta * 1000 + recordNavigationPerfSample('navigation.frameDeltaMs', frameDeltaMs) + const primaryPathCurve = primaryMotionCurve + const primaryPathLength = primaryMotionLength + const activeMotionProfile = trajectoryMotionProfile + const ribbonPathVisible = enabled && pathCurve && pathLength > Number.EPSILON + const trajectoryMode = trajectoryDebugModeRef.current + const debugDistance = trajectoryDebugDistanceRef.current + const pathDistance = Math.max(0, debugDistance ?? motionRef.current.distance) + const pathProgress = + primaryPathLength > Number.EPSILON + ? MathUtils.clamp(pathDistance / primaryPathLength, 0, 1) + : 0 + const opaqueTrajectory = trajectoryDebugOpaqueRef.current || trajectoryMode === 'opaque' + const hiddenTrajectory = trajectoryMode === 'hidden' + const visibleStart = + !ribbonPathVisible || opaqueTrajectory || hiddenTrajectory + ? 0 + : MathUtils.clamp( + pathProgress + PATH_RENDER_FADE_START_DISTANCE / Math.max(pathLength, Number.EPSILON), + 0, + 1, + ) + const frontFadeLength = + !ribbonPathVisible || opaqueTrajectory || hiddenTrajectory + ? 0 + : MathUtils.clamp( + (PATH_RENDER_FADE_END_DISTANCE - PATH_RENDER_FADE_START_DISTANCE) / pathLength, + 0.0001, + 1, + ) + + if (!actorGroup) { + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + + const pendingPascalTruckIntroSettlePosition = pascalTruckIntroPendingSettlePositionRef.current + if (pendingPascalTruckIntroSettlePosition) { + actorGroup.position.set( + pendingPascalTruckIntroSettlePosition[0], + pendingPascalTruckIntroSettlePosition[1], + pendingPascalTruckIntroSettlePosition[2], + ) + const settledActorWorldPosition: [number, number, number] = [ + actorGroup.position.x, + actorGroup.position.y, + actorGroup.position.z, + ] + pascalTruckIntroPendingSettlePositionRef.current = null + lastPublishedActorPositionRef.current = settledActorWorldPosition + lastPublishedActorPositionAtRef.current = performance.now() + setActorWorldPosition(settledActorWorldPosition) + navigationEmitter.emit('navigation:actor-transform', { + moving: false, + position: settledActorWorldPosition, + rotationY: actorGroup.rotation.y, + }) + } + + const pascalTruckIntro = pascalTruckIntroRef.current + if (pascalTruckIntro) { + const now = performance.now() + const introStepMs = MathUtils.clamp(frameDeltaMs, 0, PASCAL_TRUCK_ENTRY_MAX_STEP_MS) + if (!pascalTruckIntro.revealStarted) { + pascalTruckIntro.warmupWaitElapsedMs += introStepMs + if (actorRobotWarmupReadyRef.current) { + void tryStartPascalTruckIntroReveal('robot-ready') + } else if ( + pascalTruckIntroPostWarmupTokenRef.current === null && + pascalTruckIntro.warmupWaitElapsedMs >= PASCAL_TRUCK_ENTRY_ROBOT_READY_FALLBACK_MS + ) { + void tryStartPascalTruckIntroReveal('robot-ready-timeout', { ignorePendingWarmup: true }) + } + } + + if (pascalTruckIntro.revealStarted && !pascalTruckIntro.animationStarted) { + pascalTruckIntro.revealElapsedMs = Math.min( + PASCAL_TRUCK_ENTRY_REVEAL_DURATION_MS, + pascalTruckIntro.revealElapsedMs + introStepMs, + ) + if (pascalTruckIntro.revealElapsedMs >= PASCAL_TRUCK_ENTRY_REVEAL_DURATION_MS - 1e-3) { + pascalTruckIntro.animationStarted = true + } + } else if (pascalTruckIntro.animationStarted) { + pascalTruckIntro.animationElapsedMs = Math.min( + PASCAL_TRUCK_ENTRY_CLIP_DURATION_SECONDS * 1000, + pascalTruckIntro.animationElapsedMs + introStepMs, + ) + } + + const revealProgress = pascalTruckIntro.revealStarted + ? MathUtils.clamp( + pascalTruckIntro.revealElapsedMs / PASCAL_TRUCK_ENTRY_REVEAL_DURATION_MS, + 0, + 1, + ) + : 0 + const animationProgress = MathUtils.clamp( + pascalTruckIntro.animationElapsedMs / (PASCAL_TRUCK_ENTRY_CLIP_DURATION_SECONDS * 1000), + 0, + 1, + ) + const revealTravelProgress = + (1 - (1 - revealProgress) * (1 - revealProgress)) * PASCAL_TRUCK_ENTRY_REVEAL_TRAVEL_RATIO + const animationTravelProgress = + smoothstep01( + MathUtils.clamp(animationProgress / PASCAL_TRUCK_ENTRY_TRAVEL_END_PROGRESS, 0, 1), + ) * + (1 - PASCAL_TRUCK_ENTRY_REVEAL_TRAVEL_RATIO) + const positionBlend = Math.min(1, revealTravelProgress + animationTravelProgress) + actorGroup.position.set( + MathUtils.lerp( + pascalTruckIntro.startPosition[0], + pascalTruckIntro.endPosition[0], + positionBlend, + ), + MathUtils.lerp( + pascalTruckIntro.startPosition[1], + pascalTruckIntro.endPosition[1], + positionBlend, + ), + MathUtils.lerp( + pascalTruckIntro.startPosition[2], + pascalTruckIntro.endPosition[2], + positionBlend, + ), + ) + actorGroup.rotation.y = pascalTruckIntro.rotationY + const preservedRootMotionOffset = motionRef.current.rootMotionOffset + setMotionState( + { + ...createActorMotionState(), + destinationCellIndex: pascalTruckIntro.finalCellIndex, + forcedClip: { + clipName: PASCAL_TRUCK_ENTRY_CLIP_NAME, + holdLastFrame: true, + loop: 'once', + paused: !pascalTruckIntro.animationStarted, + revealProgress, + seekTime: pascalTruckIntro.animationStarted ? null : 0, + timeScale: 1, + }, + rootMotionOffset: preservedRootMotionOffset, + visibilityRevealProgress: revealProgress, + }, + 'pascalTruckIntro:frame', + ) + + const actorVisualWorldPosition: [number, number, number] = [ + actorGroup.position.x + preservedRootMotionOffset[0], + actorGroup.position.y, + actorGroup.position.z + preservedRootMotionOffset[2], + ] + if (followRobotEnabled) { + navigationEmitter.emit('navigation:actor-transform', { + moving: false, + position: actorVisualWorldPosition, + rotationY: actorGroup.rotation.y, + }) + } + + const lastPublishedActorPosition = lastPublishedActorPositionRef.current + const shouldPublishActorPosition = + !lastPublishedActorPosition || + Math.hypot( + actorVisualWorldPosition[0] - lastPublishedActorPosition[0], + actorVisualWorldPosition[1] - lastPublishedActorPosition[1], + actorVisualWorldPosition[2] - lastPublishedActorPosition[2], + ) > ACTOR_POSITION_PUBLISH_DISTANCE || + now - lastPublishedActorPositionAtRef.current > ACTOR_POSITION_PUBLISH_INTERVAL_MS + + if (shouldPublishActorPosition || animationProgress >= 1) { + lastPublishedActorPositionRef.current = actorVisualWorldPosition + lastPublishedActorPositionAtRef.current = now + } + + if (animationProgress >= 1 && pascalTruckIntro.animationStarted) { + if (!pascalTruckIntro.handoffPending) { + pascalTruckIntro.handoffPending = true + } else { + const settledActorWorldPosition: [number, number, number] = [ + actorGroup.position.x + preservedRootMotionOffset[0], + actorGroup.position.y, + actorGroup.position.z + preservedRootMotionOffset[2], + ] + const settledActorCellIndex = graph + ? (findClosestNavigationCell( + graph, + [ + settledActorWorldPosition[0], + settledActorWorldPosition[1] - ACTOR_HOVER_Y, + settledActorWorldPosition[2], + ], + selection.levelId ?? + (pascalTruckIntro.finalCellIndex !== null + ? toLevelNodeId(graph.cells[pascalTruckIntro.finalCellIndex]?.levelId) + : null) ?? + undefined, + null, + ) ?? pascalTruckIntro.finalCellIndex) + : pascalTruckIntro.finalCellIndex + pascalTruckIntroPendingSettlePositionRef.current = settledActorWorldPosition + pascalTruckIntroRef.current = null + pascalTruckIntroPostWarmupTokenRef.current = null + setPascalTruckIntroActive(false) + setPascalTruckIntroCompleted(true) + setMotionState( + { + ...createActorMotionState(), + destinationCellIndex: settledActorCellIndex, + }, + 'pascalTruckIntro:complete', + ) + if (settledActorCellIndex !== null && actorCellIndex !== settledActorCellIndex) { + setActorCellIndex(settledActorCellIndex) + } + } + } + + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + + const pascalTruckExit = pascalTruckExitRef.current + if (pascalTruckExit?.stage === 'fade') { + const exitStepMs = MathUtils.clamp(frameDeltaMs, 0, PASCAL_TRUCK_ENTRY_MAX_STEP_MS) + pascalTruckExit.fadeElapsedMs = Math.min( + PASCAL_TRUCK_ENTRY_REVEAL_DURATION_MS, + pascalTruckExit.fadeElapsedMs + exitStepMs, + ) + const exitProgress = MathUtils.clamp( + pascalTruckExit.fadeElapsedMs / PASCAL_TRUCK_ENTRY_REVEAL_DURATION_MS, + 0, + 1, + ) + const revealProgress = 1 - exitProgress + actorGroup.position.set( + pascalTruckExit.endPosition[0], + pascalTruckExit.endPosition[1], + pascalTruckExit.endPosition[2], + ) + actorGroup.rotation.y = pascalTruckExit.rotationY + motionRef.current.moving = false + motionRef.current.locomotion = createActorLocomotionState() + motionRef.current.forcedClip = null + motionRef.current.rootMotionOffset = [0, 0, 0] + motionRef.current.visibilityRevealProgress = revealProgress + + const exitActorWorldPosition: [number, number, number] = [ + actorGroup.position.x, + actorGroup.position.y, + actorGroup.position.z, + ] + if (followRobotEnabled) { + navigationEmitter.emit('navigation:actor-transform', { + moving: false, + position: exitActorWorldPosition, + rotationY: actorGroup.rotation.y, + }) + } + + if (exitProgress >= 0.999) { + pascalTruckExitRef.current = null + setPascalTruckExitActive(false) + const navigationState = useNavigation.getState() + const hasPendingTaskRequest = + navigationState.itemMoveRequest !== null || + navigationState.itemDeleteRequest !== null || + navigationState.itemRepairRequest !== null + const hasPendingTaskWork = + hasPendingTaskRequest || (robotMode === 'task' && navigationState.taskQueue.length > 0) + if ( + hasPendingTaskWork && + robotMode === 'task' && + pendingTaskLoopResetBeforeIntroRef.current + ) { + const nextTaskLoopToken = beginTaskLoopReset() + pendingTaskLoopResetBeforeIntroRef.current = false + pendingTaskLoopIntroAfterResetTokenRef.current = nextTaskLoopToken + recordTaskModeTrace( + 'navigation.taskLoopResetBeforeNextIntro', + { taskLoopToken: nextTaskLoopToken }, + { includeSnapshot: true }, + ) + setActorCellIndex(null) + resetMotion(true) + } else if (hasPendingTaskWork && beginPascalTruckIntro()) { + setActorCellIndex(null) + } else { + setPascalTruckIntroCompleted(false) + setActorCellIndex(null) + resetMotion(true) + } + } + + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + + if (debugDistance !== null && primaryPathCurve && primaryPathLength > Number.EPSILON) { + const debugProgress = MathUtils.clamp(debugDistance / primaryPathLength, 0, 1) + primaryPathCurve.getPointAt(debugProgress, actorPointRef.current) + primaryPathCurve.getTangentAt( + Math.min(0.999, debugProgress + 0.0001), + actorTangentRef.current, + ) + actorGroup.position.set( + actorPointRef.current.x, + actorPointRef.current.y + ACTOR_HOVER_Y, + actorPointRef.current.z, + ) + actorGroup.rotation.y = Math.atan2(actorTangentRef.current.x, actorTangentRef.current.z) + } + + trajectoryRibbonMaterial.userData.uFadeLength.value = frontFadeLength + trajectoryRibbonMaterial.userData.uOpaque.value = opaqueTrajectory ? 1 : 0 + trajectoryRibbonMaterial.userData.uReveal.value = ribbonPathVisible && !hiddenTrajectory ? 1 : 0 + trajectoryRibbonMaterial.userData.uVisibleStart.value = visibleStart + const actorWorldPosition: [number, number, number] = [ + actorGroup.position.x, + actorGroup.position.y, + actorGroup.position.z, + ] + if (followRobotEnabled) { + navigationEmitter.emit('navigation:actor-transform', { + moving: motionRef.current.moving, + position: actorWorldPosition, + rotationY: actorGroup.rotation.y, + }) + } + const lastPublishedActorPosition = lastPublishedActorPositionRef.current + const now = performance.now() + const shouldPublishActorPosition = + !lastPublishedActorPosition || + Math.hypot( + actorWorldPosition[0] - lastPublishedActorPosition[0], + actorWorldPosition[1] - lastPublishedActorPosition[1], + actorWorldPosition[2] - lastPublishedActorPosition[2], + ) > ACTOR_POSITION_PUBLISH_DISTANCE || + (motionRef.current.moving && + now - lastPublishedActorPositionAtRef.current > ACTOR_POSITION_PUBLISH_INTERVAL_MS) + + if (shouldPublishActorPosition) { + lastPublishedActorPositionRef.current = actorWorldPosition + lastPublishedActorPositionAtRef.current = now + } + + const activeItemMoveSequence = itemMoveSequenceRef.current + const activeItemDeleteSequence = itemDeleteSequenceRef.current + const activeItemRepairSequence = itemRepairSequenceRef.current + toolInteractionPhaseRef.current = activeItemMoveSequence + ? activeItemMoveSequence.stage === 'pickup-transfer' || + activeItemMoveSequence.stage === 'to-target' + ? 'pickup' + : activeItemMoveSequence.stage === 'drop-transfer' || + activeItemMoveSequence.stage === 'drop-settle' + ? 'drop' + : null + : activeItemDeleteSequence?.stage === 'delete-transfer' + ? 'delete' + : activeItemRepairSequence?.stage === 'repair-transfer' + ? 'repair' + : null + toolInteractionTargetItemIdRef.current = activeItemMoveSequence + ? activeItemMoveSequence.stage === 'pickup-transfer' + ? activeItemMoveSequence.pickupCarryVisualStartedAt !== null + ? getNavigationItemMoveVisualItemId(activeItemMoveSequence.request) + : activeItemMoveSequence.request.itemId + : activeItemMoveSequence.stage === 'drop-transfer' || + activeItemMoveSequence.stage === 'drop-settle' + ? getNavigationItemMoveVisualItemId(activeItemMoveSequence.request) + : null + : activeItemDeleteSequence?.stage === 'delete-transfer' + ? activeItemDeleteSequence.request.itemId + : activeItemRepairSequence?.stage === 'repair-transfer' + ? activeItemRepairSequence.request.itemId + : null + if ( + !activeItemMoveSequence && + !activeItemDeleteSequence && + !activeItemRepairSequence && + itemMoveForcedClipPlayback + ) { + clearItemMoveGestureClipState() + } + const currentMovingNode = useEditor.getState().movingNode + if (NAVIGATION_FRAME_TRACE_ENABLED) { + const registeredMoveControllerId = Object.keys(itemMoveControllers)[0] ?? null + const traceSourceId = + currentMovingNode?.id ?? + registeredMoveControllerId ?? + activeItemMoveSequence?.request.itemId ?? + null + if (traceSourceId && itemMoveTraceSourceIdRef.current !== traceSourceId) { + itemMoveTraceSourceIdRef.current = traceSourceId + itemMoveTraceSourceBaselineRef.current = null + itemMoveTraceGhostBaselineRef.current = null + itemMoveFrameTraceRef.current = [] + } + const traceActive = Boolean(traceSourceId || itemMoveTraceCooldownFramesRef.current > 0) + if (traceSourceId) { + itemMoveTraceCooldownFramesRef.current = 90 + } else if (itemMoveTraceCooldownFramesRef.current > 0) { + itemMoveTraceCooldownFramesRef.current -= 1 + } else { + itemMoveTraceSourceIdRef.current = null + itemMoveTraceSourceBaselineRef.current = null + itemMoveTraceGhostBaselineRef.current = null + } + if (traceActive) { + const liveSceneNodes = useScene.getState().nodes as Record + const previewSelectedIds = [...useViewer.getState().previewSelectedIds] + const transientPreviewGhostId = + Object.values(liveSceneNodes).find((node) => { + if (node?.type !== 'item' || node.id === traceSourceId) { + return false + } + + return isNavigationTaskPreviewNodeId(node.id) + })?.id ?? null + const ghostId = previewSelectedIds[0] ?? transientPreviewGhostId + const sourceNode = traceSourceId ? liveSceneNodes[traceSourceId] : null + const ghostNode = ghostId ? liveSceneNodes[ghostId] : null + const sourceObject = traceSourceId ? sceneRegistry.nodes.get(traceSourceId) : null + const ghostObject = ghostId ? sceneRegistry.nodes.get(ghostId) : null + const sourceWorldPosition = sourceObject + ? (() => { + const world = sourceObject.getWorldPosition(actorPointRef.current) + return [world.x, world.y, world.z] as [number, number, number] + })() + : null + const ghostWorldPosition = ghostObject + ? (() => { + const world = ghostObject.getWorldPosition(actorFallbackPointRef.current) + return [world.x, world.y, world.z] as [number, number, number] + })() + : null + + if (sourceWorldPosition && !itemMoveTraceSourceBaselineRef.current) { + itemMoveTraceSourceBaselineRef.current = [...sourceWorldPosition] as [ + number, + number, + number, + ] + } + if (ghostWorldPosition && !itemMoveTraceGhostBaselineRef.current) { + itemMoveTraceGhostBaselineRef.current = [...ghostWorldPosition] as [ + number, + number, + number, + ] + } + + const sourceBaseline = itemMoveTraceSourceBaselineRef.current + const ghostBaseline = itemMoveTraceGhostBaselineRef.current + itemMoveFrameTraceRef.current.push({ + at: now, + ghostId, + ghostLivePosition: ghostId + ? (useLiveTransforms.getState().get(ghostId)?.position ?? null) + : null, + ghostLocalPosition: ghostObject + ? ([ghostObject.position.x, ghostObject.position.y, ghostObject.position.z] as [ + number, + number, + number, + ]) + : null, + ghostNodePosition: ghostNode?.type === 'item' ? ghostNode.position : null, + ghostWorldDeltaYFromStart: + ghostWorldPosition && ghostBaseline ? ghostWorldPosition[1] - ghostBaseline[1] : null, + ghostWorldDeltaZFromStart: + ghostWorldPosition && ghostBaseline ? ghostWorldPosition[2] - ghostBaseline[2] : null, + ghostWorldPosition, + sourceId: traceSourceId, + sourceLivePosition: traceSourceId + ? (useLiveTransforms.getState().get(traceSourceId)?.position ?? null) + : null, + sourceLocalPosition: sourceObject + ? ([sourceObject.position.x, sourceObject.position.y, sourceObject.position.z] as [ + number, + number, + number, + ]) + : null, + sourceNodePosition: sourceNode?.type === 'item' ? sourceNode.position : null, + sourceWorldDeltaYFromStart: + sourceWorldPosition && sourceBaseline + ? sourceWorldPosition[1] - sourceBaseline[1] + : null, + sourceWorldDeltaZFromStart: + sourceWorldPosition && sourceBaseline + ? sourceWorldPosition[2] - sourceBaseline[2] + : null, + sourceWorldPosition, + stage: activeItemMoveSequence?.stage ?? null, + }) + if (itemMoveFrameTraceRef.current.length > 360) { + itemMoveFrameTraceRef.current.shift() + } + } + } + if (currentMovingNode && !activeItemMoveSequence) { + recordNavigationPerfSample('navigation.itemMovePreviewFrameDeltaMs', frameDeltaMs) + } + if (activeItemMoveSequence) { + recordNavigationPerfSample('navigation.itemMoveSequenceFrameDeltaMs', frameDeltaMs) + } + const applySmoothedActorFacing = (targetPosition: [number, number, number] | null) => { + if (!targetPosition) { + return + } + + const deltaX = targetPosition[0] - actorGroup.position.x + const deltaZ = targetPosition[2] - actorGroup.position.z + if (deltaX * deltaX + deltaZ * deltaZ <= 1e-6) { + return + } + + const targetYaw = Math.atan2(deltaX, deltaZ) + const yawDelta = getShortestAngleDelta(actorGroup.rotation.y, targetYaw) + actorGroup.rotation.y = MathUtils.damp( + actorGroup.rotation.y, + actorGroup.rotation.y + yawDelta, + ACTOR_TURN_RESPONSE, + delta, + ) + } + const syncCarriedItem = (sequence: NavigationItemMoveSequence, wobbleEnabled: boolean) => { + const visualItemId = getNavigationItemMoveVisualItemId(sequence.request) + const carryAnchor = getCarryAnchorPosition( + [actorGroup.position.x, actorGroup.position.y, actorGroup.position.z], + actorGroup.rotation.y, + sequence.request.itemDimensions, + now, + wobbleEnabled, + ) + sequence.controller.updateCarryTransform( + carryAnchor.position, + sequence.request.sourceRotation[1] ?? 0, + ) + useLiveTransforms.getState().set(visualItemId, { + position: carryAnchor.position, + rotation: sequence.request.sourceRotation[1] ?? 0, + }) + } + const syncCarryVisualItem = (sequence: NavigationItemMoveSequence | null) => { + const nextCarryVisualItemId = + sequence && + ((sequence.stage === 'pickup-transfer' && sequence.pickupCarryVisualStartedAt !== null) || + sequence.stage === 'to-target' || + sequence.stage === 'drop-transfer' || + sequence.stage === 'drop-settle') + ? getNavigationItemMoveVisualItemId(sequence.request) + : null + + if (carriedVisualItemIdRef.current !== nextCarryVisualItemId) { + if (carriedVisualItemIdRef.current) { + navigationVisualsStore + .getState() + .setItemMoveVisualState(carriedVisualItemIdRef.current, null) + } + if (nextCarryVisualItemId) { + navigationVisualsStore.getState().setItemMoveVisualState(nextCarryVisualItemId, 'carried') + } + carriedVisualItemIdRef.current = nextCarryVisualItemId + } + } + const beginPickup = (sequence: NavigationItemMoveSequence) => { + if (sequence.pickupStartedAt !== null) { + return + } + + sequence.stage = 'pickup-transfer' + sequence.pickupStartedAt = now + sequence.pickupCarryVisualStartedAt = null + sequence.pickupTransferStartedAt = null + sequence.dropStartedAt = null + sequence.dropStartPosition = null + sequence.dropSettledAt = null + itemMoveStageHistoryRef.current.push({ at: now, stage: 'pickup-transfer' }) + recordNavigationPerfMark('navigation.itemMoveStage', { stage: 'pickup-transfer' }) + syncItemMoveGestureClipState(sequence.pickupGesture, 0) + markNavigationItemMovePickupSourcePending(sequence.request) + if (!shouldDelayPickupCarryUntilCheckoutComplete(sequence.request)) { + sequence.pickupCarryVisualStartedAt = now + sequence.pickupTransferStartedAt = now + clearNavigationItemMovePickupSourcePending(sequence.request) + sequence.controller.beginCarry() + navigationVisualsStore + .getState() + .setItemMoveVisualState(getNavigationItemMoveVisualItemId(sequence.request), 'carried') + } + } + const beginDrop = (sequence: NavigationItemMoveSequence) => { + if (sequence.dropStartedAt !== null) { + return + } + + const targetNodePosition = sequence.request.finalUpdate.position + if (!targetNodePosition) { + cancelItemMoveSequence() + return + } + + sequence.stage = 'drop-transfer' + sequence.dropStartedAt = now + sequence.dropStartPosition = getCarryAnchorPosition( + [actorGroup.position.x, actorGroup.position.y, actorGroup.position.z], + actorGroup.rotation.y, + sequence.request.itemDimensions, + now, + false, + ).position + sequence.dropSettledAt = null + itemMoveStageHistoryRef.current.push({ at: now, stage: 'drop-transfer' }) + recordNavigationPerfMark('navigation.itemMoveStage', { stage: 'drop-transfer' }) + syncItemMoveGestureClipState(sequence.dropGesture, 0) + } + const beginItemDelete = (sequence: NavigationItemDeleteSequence) => { + if (sequence.deleteStartedAt !== null) { + return + } + + sequence.stage = 'delete-transfer' + sequence.deleteStartedAt = now + syncItemMoveGestureClipState(sequence.gesture, 0) + } + const beginItemRepair = (sequence: NavigationItemRepairSequence) => { + if (sequence.repairStartedAt !== null) { + return + } + + sequence.stage = 'repair-transfer' + sequence.repairStartedAt = now + syncItemMoveGestureClipState(sequence.gesture, 0) + } + if (trajectoryDebugPauseRef.current) { + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + + if (primaryPathLength <= Number.EPSILON) { + motionRef.current.moving = false + motionRef.current.locomotion = createActorLocomotionState() + if (actorMoving) { + setActorMoving(false) + } + } + + const waitingForPendingMotion = pendingMotionRef.current !== null + const hasActivePathMotion = + Boolean(primaryPathCurve) && motionRef.current.moving && primaryPathLength > Number.EPSILON + + if ( + pascalTruckExit?.stage === 'to-truck' && + !hasActivePathMotion && + !waitingForPendingMotion && + activeItemMoveSequence === null && + activeItemDeleteSequence === null && + activeItemRepairSequence === null + ) { + const exitActorWorldPosition = getResolvedActorWorldPosition() + const actorToTruckDistance = + exitActorWorldPosition === null + ? Number.POSITIVE_INFINITY + : Math.hypot( + exitActorWorldPosition[0] - pascalTruckExit.endPosition[0], + exitActorWorldPosition[1] - pascalTruckExit.endPosition[1], + exitActorWorldPosition[2] - pascalTruckExit.endPosition[2], + ) + + if (pathIndices.length > 1 && primaryPathCurve && primaryPathLength > Number.EPSILON) { + setMotionState( + { + ...createActorMotionState(), + destinationCellIndex: pascalTruckExit.finalCellIndex, + distance: 0, + moving: true, + speed: Math.max(motionRef.current.speed, ACTOR_WALK_MAX_SPEED * 0.35), + visibilityRevealProgress: 1, + }, + 'pascalTruckExit:recoverMotion', + ) + motionRef.current.visibilityRevealProgress = 1 + setActorMoving(true) + recordTaskModeTrace('navigation.pascalTruckExitRecoveredMotion', { + actorToTruckDistance, + pathLength: primaryPathLength, + pathNodeCount: pathIndices.length, + }) + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + + if (actorToTruckDistance > 0.2 && tryStartPascalTruckExitPath(pascalTruckExit)) { + const retriedPendingMotion = pendingMotionRef.current + const retryHasMotion = + (retriedPendingMotion?.moving === true && + (retriedPendingMotion.destinationCellIndex ?? null) !== actorCellIndex) || + (motionRef.current.moving && pathIndices.length > 1) + if (retryHasMotion) { + recordTaskModeTrace('navigation.pascalTruckExitRetriedPath', { + actorToTruckDistance, + finalCellIndex: pascalTruckExit.finalCellIndex, + pathNodeCount: pathIndices.length, + }) + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + + recordTaskModeTrace('navigation.pascalTruckExitRetryProducedNoMotion', { + actorCellIndex, + actorToTruckDistance, + finalCellIndex: pascalTruckExit.finalCellIndex, + pathNodeCount: pathIndices.length, + pendingDestinationCellIndex: retriedPendingMotion?.destinationCellIndex ?? null, + pendingMoving: retriedPendingMotion?.moving ?? null, + }) + } + + actorGroup.position.set( + pascalTruckExit.endPosition[0], + pascalTruckExit.endPosition[1], + pascalTruckExit.endPosition[2], + ) + actorGroup.rotation.y = pascalTruckExit.rotationY + pascalTruckExit.stage = 'fade' + pascalTruckExit.fadeElapsedMs = 0 + setPathIndices([]) + setPathAnchorWorldPosition(null) + setMotionState( + { + ...createActorMotionState(), + destinationCellIndex: pascalTruckExit.finalCellIndex, + visibilityRevealProgress: 1, + }, + 'pascalTruckExit:arrive', + ) + motionRef.current.visibilityRevealProgress = 1 + setActorMoving(false) + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + + if (!hasActivePathMotion || !primaryPathCurve) { + motionRef.current.locomotion = createActorLocomotionState() + if (activeItemMoveSequence) { + if (activeItemMoveSequence.stage === 'to-source' && !waitingForPendingMotion) { + beginPickup(activeItemMoveSequence) + applySmoothedActorFacing(activeItemMoveSequence.request.sourcePosition) + } else if (activeItemMoveSequence.stage === 'pickup-transfer') { + if (activeItemMoveSequence.pickupStartedAt === null) { + beginPickup(activeItemMoveSequence) + applySmoothedActorFacing(activeItemMoveSequence.request.sourcePosition) + } else { + const visualItemId = getNavigationItemMoveVisualItemId(activeItemMoveSequence.request) + const pickupGestureProgress = clamp01( + (now - activeItemMoveSequence.pickupStartedAt) / + getItemInteractionGestureDurationMs(activeItemMoveSequence.pickupGesture), + ) + syncItemMoveGestureClipState( + activeItemMoveSequence.pickupGesture, + pickupGestureProgress, + ) + applySmoothedActorFacing(activeItemMoveSequence.sourceDisplayPosition) + if ( + activeItemMoveSequence.pickupCarryVisualStartedAt === null && + pickupGestureProgress >= 0.5 + ) { + activeItemMoveSequence.pickupCarryVisualStartedAt = now + clearNavigationItemMovePickupSourcePending(activeItemMoveSequence.request) + activeItemMoveSequence.controller.beginCarry() + navigationVisualsStore.getState().setItemMoveVisualState(visualItemId, 'carried') + } + if ( + activeItemMoveSequence.pickupTransferStartedAt === null && + pickupGestureProgress >= 0.999 + ) { + if (activeItemMoveSequence.pickupCarryVisualStartedAt === null) { + activeItemMoveSequence.pickupCarryVisualStartedAt = now + clearNavigationItemMovePickupSourcePending(activeItemMoveSequence.request) + activeItemMoveSequence.controller.beginCarry() + navigationVisualsStore.getState().setItemMoveVisualState(visualItemId, 'carried') + } + activeItemMoveSequence.pickupTransferStartedAt = now + } + if (activeItemMoveSequence.pickupTransferStartedAt !== null) { + const pickupTransferProgress = clamp01( + (now - activeItemMoveSequence.pickupTransferStartedAt) / + ITEM_MOVE_PICKUP_DURATION_MS, + ) + const pickupTransform = getPickupTransferTransform( + [actorGroup.position.x, actorGroup.position.y, actorGroup.position.z], + actorGroup.rotation.y, + activeItemMoveSequence.request.itemDimensions, + activeItemMoveSequence.sourceDisplayPosition, + activeItemMoveSequence.request.sourceRotation[1] ?? 0, + now, + pickupTransferProgress, + ) + activeItemMoveSequence.controller.updateCarryTransform( + pickupTransform.position, + pickupTransform.rotationY, + ) + useLiveTransforms.getState().set(visualItemId, { + position: pickupTransform.position, + rotation: pickupTransform.rotationY, + }) + + if (pickupTransferProgress >= 0.999) { + if (pickupGestureProgress >= 0.999) { + const startedTargetMove = commitPlannedNavigationPath( + activeItemMoveSequence.targetPlanningGraph, + activeItemMoveSequence.targetPath, + activeItemMoveSequence.targetApproach.world, + activeItemMoveSequence.targetApproach.cellIndex, + ) + if (startedTargetMove) { + clearItemMoveGestureClipState() + activeItemMoveSequence.stage = 'to-target' + activeItemMoveSequence.pickupCarryVisualStartedAt = null + activeItemMoveSequence.pickupStartedAt = null + activeItemMoveSequence.pickupTransferStartedAt = null + itemMoveStageHistoryRef.current.push({ at: now, stage: 'to-target' }) + recordNavigationPerfMark('navigation.itemMoveStage', { stage: 'to-target' }) + } else { + cancelItemMoveSequence() + } + } + } + } + } + } else if (activeItemMoveSequence.stage === 'to-target' && !waitingForPendingMotion) { + beginDrop(activeItemMoveSequence) + applySmoothedActorFacing(activeItemMoveSequence.request.finalUpdate.position ?? null) + } else if ( + activeItemMoveSequence.stage === 'drop-transfer' || + activeItemMoveSequence.stage === 'drop-settle' + ) { + if ( + activeItemMoveSequence.dropStartedAt === null || + !activeItemMoveSequence.dropStartPosition + ) { + beginDrop(activeItemMoveSequence) + } else if (activeItemMoveSequence.stage === 'drop-settle') { + const visualItemId = getNavigationItemMoveVisualItemId(activeItemMoveSequence.request) + const dropGestureProgress = clamp01( + (now - activeItemMoveSequence.dropStartedAt) / + getItemInteractionGestureDurationMs(activeItemMoveSequence.dropGesture), + ) + applySmoothedActorFacing(activeItemMoveSequence.targetDisplayPosition) + syncItemMoveGestureClipState(activeItemMoveSequence.dropGesture, dropGestureProgress) + activeItemMoveSequence.controller.updateCarryTransform( + activeItemMoveSequence.targetDisplayPosition, + activeItemMoveSequence.targetRotationY, + ) + useLiveTransforms.getState().set(visualItemId, { + position: activeItemMoveSequence.targetDisplayPosition, + rotation: activeItemMoveSequence.targetRotationY, + }) + if ( + dropGestureProgress >= 0.999 && + activeItemMoveSequence.dropSettledAt !== null && + now - activeItemMoveSequence.dropSettledAt >= ITEM_MOVE_DROP_SETTLE_DURATION_MS + ) { + completeItemMoveSequence(activeItemMoveSequence, { + position: activeItemMoveSequence.targetDisplayPosition, + rotation: activeItemMoveSequence.targetRotationY, + }) + } + } else { + const visualItemId = getNavigationItemMoveVisualItemId(activeItemMoveSequence.request) + applySmoothedActorFacing(activeItemMoveSequence.targetDisplayPosition) + const dropTransferProgress = clamp01( + (now - activeItemMoveSequence.dropStartedAt) / ITEM_MOVE_DROP_DURATION_MS, + ) + const dropGestureProgress = clamp01( + (now - activeItemMoveSequence.dropStartedAt) / + getItemInteractionGestureDurationMs(activeItemMoveSequence.dropGesture), + ) + syncItemMoveGestureClipState(activeItemMoveSequence.dropGesture, dropGestureProgress) + const dropTransform = getDropTransferTransform( + activeItemMoveSequence.dropStartPosition, + activeItemMoveSequence.targetDisplayPosition, + activeItemMoveSequence.request.sourceRotation[1] ?? 0, + activeItemMoveSequence.targetRotationY, + dropTransferProgress, + ) + activeItemMoveSequence.controller.updateCarryTransform( + dropTransform.position, + dropTransform.rotationY, + ) + useLiveTransforms.getState().set(visualItemId, { + position: dropTransform.position, + rotation: dropTransform.rotationY, + }) + + if (dropTransferProgress >= 0.999) { + activeItemMoveSequence.controller.updateCarryTransform( + activeItemMoveSequence.targetDisplayPosition, + activeItemMoveSequence.targetRotationY, + ) + useLiveTransforms.getState().set(visualItemId, { + position: activeItemMoveSequence.targetDisplayPosition, + rotation: activeItemMoveSequence.targetRotationY, + }) + activeItemMoveSequence.stage = 'drop-settle' + activeItemMoveSequence.dropSettledAt = now + itemMoveStageHistoryRef.current.push({ at: now, stage: 'drop-settle' }) + recordNavigationPerfMark('navigation.itemMoveStage', { stage: 'drop-settle' }) + } + } + } + } + if (activeItemDeleteSequence) { + const deleteSourcePosition = getRenderedFloorItemPosition( + activeItemDeleteSequence.request.levelId, + activeItemDeleteSequence.request.sourcePosition, + activeItemDeleteSequence.request.itemDimensions, + activeItemDeleteSequence.request.sourceRotation, + ) + + if (activeItemDeleteSequence.stage === 'to-source' && !waitingForPendingMotion) { + beginItemDelete(activeItemDeleteSequence) + applySmoothedActorFacing(deleteSourcePosition) + } else if (activeItemDeleteSequence.stage === 'delete-transfer') { + if (activeItemDeleteSequence.deleteStartedAt === null) { + beginItemDelete(activeItemDeleteSequence) + applySmoothedActorFacing(deleteSourcePosition) + } else { + const deleteElapsedMs = now - activeItemDeleteSequence.deleteStartedAt + const deleteProgress = clamp01( + deleteElapsedMs / + getItemInteractionGestureDurationMs(activeItemDeleteSequence.gesture), + ) + const deleteFadeStartedAtMs = + navigationVisualsStore.getState().itemDeleteActivations[ + activeItemDeleteSequence.request.itemId + ]?.fadeStartedAtMs ?? null + syncItemMoveGestureClipState(activeItemDeleteSequence.gesture, deleteProgress) + applySmoothedActorFacing(deleteSourcePosition) + + if (deleteFadeStartedAtMs === null && deleteProgress >= 0.5) { + navigationVisualsStore + .getState() + .beginItemDeleteFade(activeItemDeleteSequence.request.itemId, now) + } + + if ( + deleteProgress >= 0.999 && + deleteFadeStartedAtMs !== null && + now - deleteFadeStartedAtMs >= ITEM_DELETE_FADE_OUT_MS + ) { + completeItemDeleteSequence(activeItemDeleteSequence) + } + } + } + } + if (activeItemRepairSequence) { + const repairSourcePosition = getRenderedFloorItemPosition( + activeItemRepairSequence.request.levelId, + activeItemRepairSequence.request.sourcePosition, + activeItemRepairSequence.request.itemDimensions, + activeItemRepairSequence.request.sourceRotation, + ) + + if (activeItemRepairSequence.stage === 'to-source' && !waitingForPendingMotion) { + beginItemRepair(activeItemRepairSequence) + applySmoothedActorFacing(repairSourcePosition) + } else if (activeItemRepairSequence.stage === 'repair-transfer') { + if (activeItemRepairSequence.repairStartedAt === null) { + beginItemRepair(activeItemRepairSequence) + applySmoothedActorFacing(repairSourcePosition) + } else { + const repairProgress = clamp01( + (now - activeItemRepairSequence.repairStartedAt) / + getItemInteractionGestureDurationMs(activeItemRepairSequence.gesture), + ) + syncItemMoveGestureClipState(activeItemRepairSequence.gesture, repairProgress) + applySmoothedActorFacing(repairSourcePosition) + if (repairProgress >= 0.999) { + completeItemRepairSequence(activeItemRepairSequence) + } + } + } + } + + syncCarryVisualItem(itemMoveSequenceRef.current) + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + + const motionPathCurve = primaryPathCurve + const motionDelta = Number.isFinite(delta) + ? MathUtils.clamp(delta, 0, ACTOR_MOTION_MAX_FRAME_DELTA_SECONDS) + : 0 + const currentProgress = motionRef.current.distance / primaryPathLength + motionPathCurve.getTangentAt( + Math.min(0.999, currentProgress + 0.0001), + actorTangentAheadRef.current, + ) + const currentTargetYaw = Math.atan2( + actorTangentAheadRef.current.x, + actorTangentAheadRef.current.z, + ) + const currentYawDelta = getShortestAngleDelta(actorGroup.rotation.y, currentTargetYaw) + const trajectoryMotionState = getTrajectoryMotionState( + activeMotionProfile, + motionRef.current.distance, + ) + const trajectoryRunBlend = trajectoryMotionState.runBlend + const turnSpeedFactor = getTurnSpeedFactor(currentYawDelta) + const speedCap = + MathUtils.lerp(ACTOR_WALK_MAX_SPEED, ACTOR_RUN_MAX_SPEED, trajectoryRunBlend) * + turnSpeedFactor + const acceleration = MathUtils.lerp( + ACTOR_WALK_ACCELERATION, + ACTOR_RUN_ACCELERATION, + trajectoryRunBlend, + ) + const deceleration = MathUtils.lerp( + ACTOR_WALK_DECELERATION, + ACTOR_RUN_DECELERATION, + trajectoryRunBlend, + ) + const remainingDistance = primaryPathLength - motionRef.current.distance + const brakingDistance = + deceleration > Number.EPSILON + ? (motionRef.current.speed * motionRef.current.speed) / (2 * deceleration) + : 0 + + if (remainingDistance <= brakingDistance || motionRef.current.speed > speedCap) { + motionRef.current.speed = Math.max(0, motionRef.current.speed - deceleration * motionDelta) + } else { + motionRef.current.speed = Math.min( + speedCap, + motionRef.current.speed + acceleration * motionDelta, + ) + } + + const candidateDistance = Math.min( + primaryPathLength, + motionRef.current.distance + motionRef.current.speed * motionDelta, + ) + const candidateProgress = candidateDistance / primaryPathLength + const activeDoorBounds = getActiveDoorLeafCollisionShapes(activeDoorCollisionCandidateIds) + let resolvedMotionCurve = motionPathCurve + + motionPathCurve.getPointAt(candidateProgress, doorCollisionPointScratch) + let blockingDoorIds = getBlockingDoorIdsForPoint(doorCollisionPointScratch, activeDoorBounds) + + if ( + blockingDoorIds.length > 0 && + candidatePathCurve && + conservativePathCurve && + motionPathCurve === candidatePathCurve + ) { + const conservativeCandidateProgress = + conservativePathLength > Number.EPSILON + ? Math.min(1, candidateDistance / conservativePathLength) + : candidateProgress + conservativePathCurve.getPointAt(conservativeCandidateProgress, actorFallbackPointRef.current) + const fallbackBlockingDoorIds = getBlockingDoorIdsForPoint( + actorFallbackPointRef.current, + activeDoorBounds, + ) + + if (fallbackBlockingDoorIds.length === 0) { + resolvedMotionCurve = conservativePathCurve + blockingDoorIds = fallbackBlockingDoorIds + } + } + + const blockedByDoor = blockingDoorIds.length > 0 + doorCollisionStateRef.current = { + blocked: blockedByDoor, + doorIds: blockingDoorIds, + } + mergeNavigationPerfMeta({ + navigationDoorCollisionBlocked: blockedByDoor, + navigationDoorCollisionDoorCount: blockingDoorIds.length, + }) + + if (blockedByDoor) { + motionRef.current.speed = Math.max( + 0, + motionRef.current.speed - deceleration * 1.5 * motionDelta, + ) + } else { + motionRef.current.distance = candidateDistance + } + + const resolvedTrajectoryMotionState = getTrajectoryMotionState( + activeMotionProfile, + motionRef.current.distance, + ) + const locomotionMoveBlend = motionRef.current.moving + ? smoothstep01(motionRef.current.speed / ACTOR_LOCOMOTION_BLEND_SPEED) + : 0 + const locomotionRunBlend = + resolvedTrajectoryMotionState.runBlend * + smoothstep01( + (motionRef.current.speed - ACTOR_WALK_MAX_SPEED * 0.82) / + Math.max(ACTOR_RUN_MAX_SPEED - ACTOR_WALK_MAX_SPEED * 0.82, Number.EPSILON), + ) + motionRef.current.locomotion = { + moveBlend: locomotionMoveBlend, + runBlend: Math.min(locomotionMoveBlend, locomotionRunBlend), + runTimeScale: MathUtils.lerp( + ACTOR_RUN_ANIMATION_SPEED_SCALE * 0.88, + ACTOR_RUN_ANIMATION_SPEED_SCALE, + clamp01(motionRef.current.speed / ACTOR_RUN_MAX_SPEED), + ), + sectionKind: resolvedTrajectoryMotionState.sectionKind, + walkTimeScale: MathUtils.lerp( + ACTOR_WALK_ANIMATION_SPEED_SCALE * 0.72, + ACTOR_WALK_ANIMATION_SPEED_SCALE, + clamp01(motionRef.current.speed / ACTOR_WALK_MAX_SPEED), + ), + } + + const progress = motionRef.current.distance / primaryPathLength + let renderCurve = resolvedMotionCurve + const conservativeProgress = + conservativePathLength > Number.EPSILON + ? Math.min(1, motionRef.current.distance / conservativePathLength) + : progress + + if (candidatePathCurve && conservativePathCurve && renderCurve === candidatePathCurve) { + candidatePathCurve.getPointAt(progress, actorPointRef.current) + if (getBlockingDoorIdsForPoint(actorPointRef.current, activeDoorBounds).length > 0) { + renderCurve = conservativePathCurve + } + } + + if (renderCurve === conservativePathCurve) { + conservativePathCurve!.getPointAt(conservativeProgress, actorPointRef.current) + conservativePathCurve!.getTangentAt( + Math.min(0.999, conservativeProgress + 0.0001), + actorTangentRef.current, + ) + } else { + motionPathCurve.getPointAt(progress, actorPointRef.current) + motionPathCurve.getTangentAt(Math.min(0.999, progress + 0.0001), actorTangentRef.current) + } + const targetYaw = Math.atan2(actorTangentRef.current.x, actorTangentRef.current.z) + const yawDelta = getShortestAngleDelta(actorGroup.rotation.y, targetYaw) + + actorGroup.position.set( + actorPointRef.current.x, + actorPointRef.current.y + ACTOR_HOVER_Y, + actorPointRef.current.z, + ) + actorGroup.rotation.y = MathUtils.damp( + actorGroup.rotation.y, + actorGroup.rotation.y + yawDelta, + ACTOR_TURN_RESPONSE, + motionDelta, + ) + + if (activeItemMoveSequence?.stage === 'to-target') { + syncCarriedItem(activeItemMoveSequence, true) + } + + if (progress >= 0.999) { + motionRef.current.moving = false + motionRef.current.speed = 0 + motionRef.current.locomotion = createActorLocomotionState() + if (actorMoving) { + setActorMoving(false) + } + if (pathIndices.length > 0) { + setPathIndices([]) + } + setPathAnchorWorldPosition(null) + const destinationCellIndex = motionRef.current.destinationCellIndex + const settledActorWorldPosition = getResolvedActorVisualWorldPosition() + const settledActorCellIndex = + destinationCellIndex ?? + (graph && settledActorWorldPosition + ? findClosestNavigationCell( + graph, + [ + settledActorWorldPosition[0], + settledActorWorldPosition[1] - ACTOR_HOVER_Y, + settledActorWorldPosition[2], + ], + selection.levelId ?? undefined, + null, + ) + : null) + if (settledActorCellIndex !== null) { + setActorCellIndex(settledActorCellIndex) + } + + if (activeItemMoveSequence?.stage === 'to-source') { + beginPickup(activeItemMoveSequence) + } else if (activeItemMoveSequence?.stage === 'to-target') { + beginDrop(activeItemMoveSequence) + } else if (activeItemDeleteSequence?.stage === 'to-source') { + beginItemDelete(activeItemDeleteSequence) + } else if (activeItemRepairSequence?.stage === 'to-source') { + beginItemRepair(activeItemRepairSequence) + } + } + + syncCarryVisualItem(itemMoveSequenceRef.current) + + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + }) + + const actorVisible = + enabled && + (pascalTruckIntroActive || + pascalTruckExitActive || + (pascalTruckIntroCompleted && + actorCellIndex !== null && + Boolean(graph?.cells[actorCellIndex]))) + const actorRenderVisible = + actorVisible && + (actorRenderVisibleOverrideRef.current === null || actorRenderVisibleOverrideRef.current) + const actorToolAttachmentsVisible = robotToolAttachmentsVisibleOverrideRef.current ?? true + const actorMounted = true + const actorRenderPosition = + getResolvedActorWorldPosition() ?? actorSpawnPosition ?? ([0, 0, 0] as [number, number, number]) + + useEffect(() => { + if (!(isNavigationDebugEnabled() && typeof window !== 'undefined')) { + return + } + + const getActorWorldPosition = () => getResolvedActorWorldPosition() + + const getActorNavigationPoint = () => { + const actorWorldPosition = getActorWorldPosition() + if (!actorWorldPosition) { + return null + } + + return [ + actorWorldPosition[0], + actorWorldPosition[1] - ACTOR_HOVER_Y, + actorWorldPosition[2], + ] as [number, number, number] + } + + const getConnectivitySnapshot = () => { + if (!graph) { + return null + } + + const graphWithoutDoors = buildNavigationGraph( + sceneState.nodes, + sceneState.rootNodeIds, + selection.buildingId, + { + includeDoorPortals: false, + }, + ) + + const actorNavigationPoint = getActorNavigationPoint() + const actorLevelId = + selection.levelId ?? (actorCellIndex !== null ? graph.cells[actorCellIndex]?.levelId : null) + const actorCellIndexWithDoors = + actorNavigationPoint !== null + ? (findClosestNavigationCell(graph, actorNavigationPoint, actorLevelId, null) ?? + actorCellIndex) + : actorCellIndex + const actorCellIndexWithoutDoors = + actorNavigationPoint !== null && graphWithoutDoors + ? findClosestNavigationCell(graphWithoutDoors, actorNavigationPoint, actorLevelId, null) + : null + const actorComponentWithDoors = + actorCellIndexWithDoors !== null + ? (graph.componentIdByCell[actorCellIndexWithDoors] ?? null) + : null + const actorComponentWithoutDoors = + actorCellIndexWithoutDoors !== null && graphWithoutDoors + ? (graphWithoutDoors.componentIdByCell[actorCellIndexWithoutDoors] ?? null) + : null + + const zones = Object.values(sceneState.nodes) + .filter( + ( + node, + ): node is { + id: string + name?: string + parentId?: string + polygon: Array<[number, number]> + type: 'zone' + visible?: boolean + } => + node?.type === 'zone' && + node.visible !== false && + Array.isArray((node as { polygon?: Array<[number, number]> }).polygon) && + ((node as { polygon?: Array<[number, number]> }).polygon?.length ?? 0) >= 3, + ) + .map((zone) => { + const centroid = getPolygonCentroid(zone.polygon) + if (!centroid) { + return null + } + + const zoneLevelId = toLevelNodeId(zone.parentId) + const withDoorCellIndex = findClosestNavigationCell( + graph, + [centroid[0], 0, centroid[1]], + zoneLevelId ?? undefined, + null, + ) + const withoutDoorCellIndex = graphWithoutDoors + ? findClosestNavigationCell( + graphWithoutDoors, + [centroid[0], 0, centroid[1]], + zoneLevelId ?? undefined, + null, + ) + : null + + return { + centroid, + id: zone.id, + levelId: zoneLevelId, + name: zone.name ?? zone.id, + withDoorCellIndex, + withDoorComponentId: + withDoorCellIndex !== null + ? (graph.componentIdByCell[withDoorCellIndex] ?? null) + : null, + withoutDoorCellIndex, + withoutDoorComponentId: + withoutDoorCellIndex !== null && graphWithoutDoors + ? (graphWithoutDoors.componentIdByCell[withoutDoorCellIndex] ?? null) + : null, + } + }) + .filter( + ( + zone, + ): zone is { + centroid: [number, number] + id: string + levelId: LevelNode['id'] | null + name: string + withDoorCellIndex: number | null + withDoorComponentId: number | null + withoutDoorCellIndex: number | null + withoutDoorComponentId: number | null + } => Boolean(zone), + ) + + const suggestedRoomTarget = + actorCellIndexWithDoors !== null + ? (() => { + const candidates = zones + .filter((zone) => { + if (zone.withDoorCellIndex === null || zone.withoutDoorCellIndex === null) { + return false + } + + if (zone.withDoorCellIndex === actorCellIndexWithDoors) { + return false + } + + return ( + zone.withDoorComponentId === actorComponentWithDoors && + zone.withoutDoorComponentId !== actorComponentWithoutDoors + ) + }) + .map((zone) => { + if (zone.withDoorCellIndex === null) { + return null + } + + const path = findNavigationPath( + graph, + actorCellIndexWithDoors, + zone.withDoorCellIndex, + ) + if (!path) { + return null + } + + const targetCell = graph.cells[zone.withDoorCellIndex] + if (!targetCell) { + return null + } + + return { + fromCellIndex: actorCellIndexWithDoors, + fromLevelId: actorLevelId, + pathCost: path.cost, + pathNodeCount: path.indices.length, + separatedWithoutDoors: true as const, + targetCellIndex: zone.withDoorCellIndex, + targetComponentId: zone.withDoorComponentId, + targetLevelId: zone.levelId, + targetWorld: targetCell.center, + zoneId: zone.id, + zoneName: zone.name, + } + }) + .filter( + (candidate): candidate is NonNullable => candidate !== null, + ) + + if (candidates.length === 0) { + return null + } + + candidates.sort((left, right) => left.pathCost - right.pathCost) + return candidates[0] ?? null + })() + : null + + const suggestedCrossFloorTarget = + actorCellIndexWithDoors !== null + ? (() => { + const candidates = zones + .filter((zone) => { + if (zone.withDoorCellIndex === null) { + return false + } + + return zone.levelId !== actorLevelId + }) + .map((zone) => { + if (zone.withDoorCellIndex === null) { + return null + } + + const path = findNavigationPath( + graph, + actorCellIndexWithDoors, + zone.withDoorCellIndex, + ) + if (!path) { + return null + } + + const targetCell = graph.cells[zone.withDoorCellIndex] + if (!targetCell) { + return null + } + + return { + fromCellIndex: actorCellIndexWithDoors, + fromLevelId: actorLevelId, + pathCost: path.cost, + pathNodeCount: path.indices.length, + targetCellIndex: zone.withDoorCellIndex, + targetLevelId: zone.levelId, + targetWorld: targetCell.center, + zoneId: zone.id, + zoneName: zone.name, + } + }) + .filter( + (candidate): candidate is NonNullable => candidate !== null, + ) + + if (candidates.length === 0) { + return null + } + + candidates.sort((left, right) => left.pathCost - right.pathCost) + return candidates[0] ?? null + })() + : null + + const stairLevels = [...graph.cellsByLevel.entries()] + .map(([levelId, cellIndices]) => { + const stairCells = cellIndices + .map((cellIndex) => graph.cells[cellIndex]) + .filter((cell): cell is NonNullable => Boolean(cell)) + .filter((cell) => cell.surfaceType === 'stair') + + if (stairCells.length === 0) { + return null + } + + const highestCell = [...stairCells].sort( + (left, right) => right.center[1] - left.center[1], + )[0] + const lowestCell = [...stairCells].sort( + (left, right) => left.center[1] - right.center[1], + )[0] + + return { + componentIds: [ + ...new Set(stairCells.map((cell) => graph.componentIdByCell[cell.cellIndex])), + ], + count: stairCells.length, + highestWorld: highestCell?.center ?? null, + levelId, + maxY: Math.max(...stairCells.map((cell) => cell.center[1])), + minY: Math.min(...stairCells.map((cell) => cell.center[1])), + lowestWorld: lowestCell?.center ?? null, + } + }) + .filter((level): level is NonNullable => Boolean(level)) + + return { + actorCellIndexWithDoors, + actorCellIndexWithoutDoors, + actorComponentWithDoors, + actorComponentWithoutDoors, + actorLevelId, + doorBridgeEdgeCount: graph.doorBridgeEdgeCount, + graphComponentCountWithDoors: graph.components.length, + graphComponentCountWithoutDoors: graphWithoutDoors?.components.length ?? null, + stairLevels, + stairSurfaceCount: graph.stairSurfaceCount, + stairTransitionEdgeCount: graph.stairTransitionEdgeCount, + suggestedCrossFloorTarget, + suggestedRoomTarget, + zoneCount: zones.length, + zones, + } + } + + const getState = () => { + const navigationState = useNavigation.getState() + const navigationVisualState = navigationVisualsStore.getState() + const viewerState = useViewer.getState() + const actorRobotDebugState = actorRobotDebugStateRef.current + const shadowController = shadowControllerRef.current + const pascalTruckIntro = pascalTruckIntroRef.current + const pascalTruckIntroRevealProgress = pascalTruckIntro + ? MathUtils.clamp( + pascalTruckIntro.revealElapsedMs / PASCAL_TRUCK_ENTRY_REVEAL_DURATION_MS, + 0, + 1, + ) + : 0 + const pascalTruckIntroAnimationProgress = pascalTruckIntro + ? MathUtils.clamp( + pascalTruckIntro.animationElapsedMs / (PASCAL_TRUCK_ENTRY_CLIP_DURATION_SECONDS * 1000), + 0, + 1, + ) + : 0 + const pascalTruckIntroPositionBlend = pascalTruckIntro + ? Math.min( + 1, + (1 - (1 - pascalTruckIntroRevealProgress) * (1 - pascalTruckIntroRevealProgress)) * + PASCAL_TRUCK_ENTRY_REVEAL_TRAVEL_RATIO + + smoothstep01( + MathUtils.clamp( + pascalTruckIntroAnimationProgress / PASCAL_TRUCK_ENTRY_TRAVEL_END_PROGRESS, + 0, + 1, + ), + ) * + (1 - PASCAL_TRUCK_ENTRY_REVEAL_TRAVEL_RATIO), + ) + : 0 + + return { + actorCellIndex, + actorComponentId, + actorAvailable: navigationState.actorAvailable, + actorRotationY: actorGroupRef.current?.rotation.y ?? 0, + blockedDoorIds: doorCollisionStateRef.current.doorIds, + blockedObstacleIds: + primaryMotionCurve === candidatePathCurve + ? candidatePathCollisionAudit.blockedObstacleIds + : conservativePathCollisionAudit.blockedObstacleIds, + blockedWallIds: + primaryMotionCurve === candidatePathCurve + ? candidatePathCollisionAudit.blockedWallIds + : conservativePathCollisionAudit.blockedWallIds, + doorCollisionBlocked: doorCollisionStateRef.current.blocked, + actorMoving, + pathDistanceTravelled: trajectoryDebugDistanceRef.current ?? motionRef.current.distance, + actorVisible, + actorVisualWorldPosition: getResolvedActorVisualWorldPosition(), + actorWorldPosition: getActorWorldPosition(), + enabled: navigationState.enabled, + itemDeleteRequestId: navigationState.itemDeleteRequest?.itemId ?? null, + itemMovePreviewPlanCacheSize: itemMovePreviewPlanCacheRef.current.size, + itemMovePreviewPlanWarmPending: itemMovePreviewPlanWarmTimeoutRef.current !== null, + itemMoveRequestId: navigationState.itemMoveRequest?.itemId ?? null, + itemRepairRequestId: navigationState.itemRepairRequest?.itemId ?? null, + introAnimationDebugActive, + levelId: selection.levelId, + navigationActorRenderVisible: + actorRenderVisibleOverrideRef.current === null + ? actorVisible + : actorVisible && actorRenderVisibleOverrideRef.current, + navigationActorRenderVisibleOverride: actorRenderVisibleOverrideRef.current, + navigationGraphCacheSize: graphCacheRef.current.size, + navigationGraphReady: Boolean(prewarmedGraph), + navigationRobotToolAttachmentsVisible: + robotToolAttachmentsVisibleOverrideRef.current ?? true, + navigationRobotSkinnedMeshesVisible: robotSkinnedMeshVisibleOverrideRef.current ?? true, + navigationRobotSkinnedMeshesVisibleOverride: robotSkinnedMeshVisibleOverrideRef.current, + navigationRobotStaticMeshesVisible: robotStaticMeshVisibleOverrideRef.current ?? true, + navigationRobotStaticMeshesVisibleOverride: robotStaticMeshVisibleOverrideRef.current, + navigationRobotToolAttachmentsVisibleOverride: + robotToolAttachmentsVisibleOverrideRef.current, + navigationRobotMaterialDebugModeOverride: + robotMaterialDebugModeOverrideRef.current ?? 'auto', + navigationRobotRevealMaterialsActive: + actorRobotDebugState && typeof actorRobotDebugState.revealMaterialsActive === 'boolean' + ? actorRobotDebugState.revealMaterialsActive + : null, + navigationRobotToolRevealMaterialsActive: + actorRobotDebugState && + typeof actorRobotDebugState.toolRevealMaterialsActive === 'boolean' + ? actorRobotDebugState.toolRevealMaterialsActive + : null, + navigationSceneSnapshotKey: navigationSceneSnapshot?.key ?? null, + navigationShadowAutoUpdate: shadowController.currentAutoUpdate, + navigationShadowDynamicSettleFrames: shadowController.dynamicSettleFrames, + navigationShadowLastDynamicUpdateAtMs: shadowController.lastDynamicUpdateAtMs, + navigationShadowMapEnabled: shadowController.currentEnabled, + navigationShadowMapOverrideEnabled: shadowMapOverrideEnabledRef.current, + navigationPostWarmupCompletedToken: + navigationVisualState.navigationPostWarmupCompletedToken, + navigationPostWarmupPending: + navigationVisualState.navigationPostWarmupRequestToken > + navigationVisualState.navigationPostWarmupCompletedToken, + navigationPostWarmupRequestToken: navigationVisualState.navigationPostWarmupRequestToken, + runtimePostProcessing: viewerState.runtimePostProcessing, + pascalTruckVisible: + navigationVisualState.nodeVisibilityOverrides[PASCAL_TRUCK_ITEM_NODE_ID] !== false, + toolConeOverlayEnabled: navigationVisualState.toolConeOverlayEnabled, + pascalTruckIntroActive: Boolean(pascalTruckIntro), + pascalTruckIntroAnimationProgress, + pascalTruckIntroCompleted, + pascalTruckIntroPositionBlend, + pascalTruckIntroRevealProgress, + pascalTruckIntroTaskReady, + pascalTruckExitActive, + motionWriteSource: motionWriteSourceRef.current, + pathCellCount: pathIndices.length, + pathCollisionSampleCount: + primaryMotionCurve === candidatePathCurve + ? candidatePathCollisionAudit.blockedSampleCount + : conservativePathCollisionAudit.blockedSampleCount, + pathLength, + pendingPascalTruckExitActive: pendingPascalTruckExitRef.current !== null, + pendingTaskRequestActive: + navigationState.itemMoveRequest !== null || + navigationState.itemDeleteRequest !== null || + navigationState.itemRepairRequest !== null, + debugPascalTruckIntroAttemptCount: debugPascalTruckIntroAttemptCountRef.current, + debugPascalTruckIntroStartCount: debugPascalTruckIntroStartCountRef.current, + queueRestartToken: navigationState.queueRestartToken, + robotMaterialWarmupReady: actorRobotWarmupReady, + robotMode: navigationState.robotMode, + runtimeActive: navigationRuntimeActive, + truckIntroPlanReady: pascalTruckIntroPlan !== null, + taskQueueLength: navigationState.taskQueue.length, + toolConeOverlayWarmupReady: navigationVisualState.toolConeOverlayWarmupReady, + pathUsingConservativeCurve: + Boolean( + primaryMotionCurve && + conservativePathCurve && + primaryMotionCurve === conservativePathCurve, + ) && primaryMotionCurve !== candidatePathCurve, + trajectoryCurrentRunBlend: motionRef.current.locomotion.runBlend, + trajectoryCurrentSectionKind: motionRef.current.locomotion.sectionKind, + trajectoryLowCurvatureSectionCount: + trajectoryMotionProfile?.sections.filter((section) => section.kind === 'low').length ?? 0, + trajectoryHighCurvatureSectionCount: + trajectoryMotionProfile?.sections.filter((section) => section.kind === 'high').length ?? + 0, + trajectoryDebugMode: trajectoryDebugModeRef.current, + trajectoryRenderReady: Boolean(trajectoryRibbonGeometry), + trajectoryRenderType: trajectoryRibbonGeometry ? 'ribbon' : null, + } + } + + const getDoorTangentDiagnostics = () => { + const debugPathCurve = debugPathCurveRef.current + const debugDoorTransitions = debugDoorTransitionsRef.current + if (!debugPathCurve || debugDoorTransitions.length === 0) { + return [] + } + + const tangentSampleCount = Math.max(96, Math.ceil(pathLength / 0.08)) + const tangent = new Vector3() + + return debugDoorTransitions.map((transition) => { + const approachPoint = new Vector3(...transition.approachWorld) + const entryPoint = new Vector3(...transition.entryWorld) + const exitPoint = new Vector3(...transition.exitWorld) + const departurePoint = new Vector3(...transition.departureWorld) + + const approachAxis = entryPoint.clone().sub(approachPoint).normalize() + const departureAxis = departurePoint.clone().sub(exitPoint).normalize() + const approachT = findClosestCurveProgress( + debugPathCurve, + approachPoint, + tangentSampleCount, + ) + const departureT = findClosestCurveProgress( + debugPathCurve, + departurePoint, + tangentSampleCount, + ) + + debugPathCurve.getTangentAt(approachT, tangent) + const approachDot = Math.abs(tangent.normalize().dot(approachAxis)) + debugPathCurve.getTangentAt(departureT, tangent) + const departureDot = Math.abs(tangent.normalize().dot(departureAxis)) + + return { + approachDot, + approachT, + departureDot, + departureT, + openingId: transition.openingId, + progress: transition.progress, + } + }) + } + + const getRenderBreakdown = () => { + const rootSummaries = [ + ...Array.from(sceneRegistry.byType.item, (nodeId) => ({ nodeId, type: 'item' as const })), + ...Array.from(sceneRegistry.byType.door, (nodeId) => ({ nodeId, type: 'door' as const })), + ...Array.from(sceneRegistry.byType.wall, (nodeId) => ({ nodeId, type: 'wall' as const })), + ...Array.from(sceneRegistry.byType.window, (nodeId) => ({ + nodeId, + type: 'window' as const, + })), + ...Array.from(sceneRegistry.byType.slab, (nodeId) => ({ nodeId, type: 'slab' as const })), + ...Array.from(sceneRegistry.byType.ceiling, (nodeId) => ({ + nodeId, + type: 'ceiling' as const, + })), + ...Array.from(sceneRegistry.byType.roof, (nodeId) => ({ nodeId, type: 'roof' as const })), + ] + .map(({ nodeId, type }) => { + const object = sceneRegistry.nodes.get(nodeId) + if (!object) { + return null + } + + let meshCount = 0 + let skinnedMeshCount = 0 + let triangleCount = 0 + const materialIds = new Set() + + object.traverse((child) => { + const mesh = child as Mesh + if (!mesh.isMesh || !mesh.visible) { + return + } + + meshCount += 1 + if ((mesh as Mesh & { isSkinnedMesh?: boolean }).isSkinnedMesh) { + skinnedMeshCount += 1 + } + + if (Array.isArray(mesh.material)) { + for (const material of mesh.material) { + materialIds.add(material.uuid) + } + } else if (mesh.material) { + materialIds.add(mesh.material.uuid) + } + + const positionAttribute = mesh.geometry.getAttribute('position') + if (positionAttribute) { + triangleCount += Math.floor(positionAttribute.count / 3) + } + }) + + return { + materialCount: materialIds.size, + meshCount, + name: + sceneState.nodes[nodeId]?.type === 'item' + ? ((sceneState.nodes[nodeId] as ItemNode).name ?? + (sceneState.nodes[nodeId] as ItemNode).asset.name) + : (sceneState.nodes[nodeId]?.type ?? type), + nodeId, + skinnedMeshCount, + triangleCount, + type, + } + }) + .filter((summary): summary is NonNullable => Boolean(summary)) + + return rootSummaries.sort((left, right) => { + if (right.meshCount !== left.meshCount) { + return right.meshCount - left.meshCount + } + + return right.triangleCount - left.triangleCount + }) + } + + const getNodeRenderTree = (nodeId: string) => { + const root = sceneRegistry.nodes.get(nodeId) + if (!root) { + return null + } + + const describe = (object: Object3D): Record => { + const mesh = object as Mesh + return { + children: object.children.map((child) => describe(child)), + material: + mesh.isMesh && mesh.material + ? Array.isArray(mesh.material) + ? mesh.material.map((material) => material.name || material.type) + : mesh.material.name || mesh.material.type + : null, + mesh: Boolean(mesh.isMesh), + name: object.name || object.type, + type: object.type, + visible: object.visible, + } + } + + return describe(root) + } + + const getTrajectorySamples = (sampleCount = 7) => { + if (!pathCurve || pathLength <= Number.EPSILON) { + return [] + } + + const clampedCount = Math.max(2, Math.floor(sampleCount)) + const samplePoint = new Vector3() + return Array.from({ length: clampedCount }, (_, index) => { + const sampleT = clampedCount <= 1 ? 0 : index / (clampedCount - 1) + pathCurve.getPointAt(sampleT, samplePoint) + return [samplePoint.x, samplePoint.y + PATH_CURVE_OFFSET_Y, samplePoint.z] as [ + number, + number, + number, + ] + }) + } + + const getItemMoveState = () => { + const navigationState = useNavigation.getState() + const editorState = useEditor.getState() + const itemMoveSequence = itemMoveSequenceRef.current + const actorRobotDebugState = actorRobotDebugStateRef.current + const previewSelectedIds = [...useViewer.getState().previewSelectedIds] + const liveSceneNodes = useScene.getState().nodes as Record + const movingNodeId = + editorState.movingNode?.id ?? + Object.keys(navigationState.itemMoveControllers)[0] ?? + itemMoveSequence?.request.itemId ?? + null + const movingNode = movingNodeId ? liveSceneNodes[movingNodeId] : null + const transientPreviewGhostId = + Object.values(liveSceneNodes).find((node) => { + if (node?.type !== 'item' || node.id === movingNodeId) { + return false + } + + return isNavigationTaskPreviewNodeId(node.id) + })?.id ?? null + const previewGhostId = previewSelectedIds[0] ?? transientPreviewGhostId + const previewGhostNode = previewGhostId ? liveSceneNodes[previewGhostId] : null + const itemMoveFrameTrace = [...itemMoveFrameTraceRef.current] + + return { + itemMoveControllerId: Object.keys(navigationState.itemMoveControllers)[0] ?? null, + itemMoveFrameTrace, + itemMoveFrameTraceSummary: { + ghostBaselineWorldPosition: itemMoveTraceGhostBaselineRef.current, + ghostMinWorldY: itemMoveFrameTrace.reduce( + (minimum, sample) => + sample.ghostWorldPosition + ? minimum === null + ? sample.ghostWorldPosition[1] + : Math.min(minimum, sample.ghostWorldPosition[1]) + : minimum, + null, + ), + sourceBaselineWorldPosition: itemMoveTraceSourceBaselineRef.current, + sourceMinWorldY: itemMoveFrameTrace.reduce( + (minimum, sample) => + sample.sourceWorldPosition + ? minimum === null + ? sample.sourceWorldPosition[1] + : Math.min(minimum, sample.sourceWorldPosition[1]) + : minimum, + null, + ), + }, + itemMoveLocked: navigationState.itemMoveLocked, + itemMoveRequestId: navigationState.itemMoveRequest?.itemId ?? null, + itemMoveSequenceStage: itemMoveSequence?.stage ?? null, + itemMoveStageHistory: [...itemMoveStageHistoryRef.current], + moveItemsEnabled: navigationState.moveItemsEnabled, + robotMode: navigationState.robotMode, + taskQueue: navigationState.taskQueue.map((task) => ({ + itemId: task.request.itemId, + kind: task.kind, + taskId: task.taskId, + })), + movingNodeId, + movingNodeLiveTransform: movingNodeId + ? (useLiveTransforms.getState().get(movingNodeId) ?? null) + : null, + movingNodePosition: + editorState.movingNode && 'position' in editorState.movingNode + ? editorState.movingNode.position + : null, + movingNodeVisualState: movingNodeId + ? (navigationVisualsStore.getState().itemMoveVisualStates[movingNodeId] ?? null) + : null, + toolCone: + typeof actorRobotDebugState?.toolCone === 'object' && + actorRobotDebugState.toolCone !== null + ? actorRobotDebugState.toolCone + : null, + toolConeIsolatedOverlay: navigationVisualsStore.getState().toolConeIsolatedOverlay, + previewGhostId, + previewGhostVisualState: previewGhostId + ? (navigationVisualsStore.getState().itemMoveVisualStates[previewGhostId] ?? null) + : null, + previewGhostVisible: + previewGhostId !== null + ? (navigationVisualsStore.getState().nodeVisibilityOverrides[previewGhostId] ?? + previewGhostNode?.visible ?? + null) + : null, + previewSelectedIds, + } + } + + const getPathDiagnostics = () => { + if (!pathGraph) { + return null + } + + const actorWorldPosition = getResolvedActorWorldPosition() + const actorVisualWorldPosition = getResolvedActorVisualWorldPosition() + const actorNavigationPoint = actorWorldPosition + ? ([ + actorWorldPosition[0], + actorWorldPosition[1] - ACTOR_HOVER_Y, + actorWorldPosition[2], + ] as [number, number, number]) + : null + const nearestLiveGraphCellIndex = + actorNavigationPoint && graph + ? findClosestNavigationCell( + graph, + actorNavigationPoint, + selection.levelId ?? undefined, + null, + ) + : null + + return { + actorCellCenter: + actorCellIndex !== null && graph ? (graph.cells[actorCellIndex]?.center ?? null) : null, + actorCellIndex, + actorComponentId, + actorNavigationPoint, + actorVisualWorldPosition, + actorWorldPosition, + candidateCurveLength: candidatePathCurve?.getLength() ?? null, + conservativeCurveLength: conservativePathCurve?.getLength() ?? null, + lastCommittedPath: lastCommittedPathDebugRef.current, + lastItemMovePlan: lastItemMovePlanDebugRef.current, + nearestLiveGraphCellCenter: + nearestLiveGraphCellIndex !== null && graph + ? (graph.cells[nearestLiveGraphCellIndex]?.center ?? null) + : null, + nearestLiveGraphCellIndex, + doorTransitions: doorTransitions.map((transition) => ({ + approachWorld: transition.approachWorld, + departureWorld: transition.departureWorld, + doorIds: [...transition.doorIds], + entryWorld: transition.entryWorld, + exitWorld: transition.exitWorld, + fromCellCenter: pathGraph.cells[transition.fromCellIndex]?.center ?? null, + fromCellIndex: transition.fromCellIndex, + fromPathIndex: transition.fromPathIndex, + openingId: transition.openingId, + pathPosition: transition.pathPosition, + progress: transition.progress, + toCellCenter: pathGraph.cells[transition.toCellIndex]?.center ?? null, + toCellIndex: transition.toCellIndex, + toPathIndex: transition.toPathIndex, + world: transition.world, + })), + pathAnchorWorldPosition, + pathCellCenters: pathIndices.map((cellIndex) => pathGraph.cells[cellIndex]?.center ?? null), + pathGraphCellCount: pathGraph.cells.length, + pathGraphIsOverride: pathGraph !== graph, + pathIndices: [...pathIndices], + pathLength, + pathTargetWorldPosition, + pathUsingConservativeCurve: + Boolean( + primaryMotionCurve && + conservativePathCurve && + primaryMotionCurve === conservativePathCurve, + ) && primaryMotionCurve !== candidatePathCurve, + rawPathPoints: rawPathPoints.map((point) => [...point] as [number, number, number]), + simplifiedPathCellCenters: simplifiedPathIndices.map( + (cellIndex) => pathGraph.cells[cellIndex]?.center ?? null, + ), + simplifiedPathIndices: [...simplifiedPathIndices], + smoothedPathPoints: smoothedPathPoints.map( + (point) => [point.x, point.y, point.z] as [number, number, number], + ), + rootMotionOffset: [...motionRef.current.rootMotionOffset] as [number, number, number], + } + } + + const getCurrentMovePlanDiagnostics = () => { + if (!(graph && itemMoveRequest)) { + return null + } + + const { actorNavigationPoint, actorStartCellIndex, actorStartComponentId } = + getActorNavigationPlanningState( + graph, + selection.levelId ?? toLevelNodeId(itemMoveRequest.levelId) ?? null, + ) + + if (actorStartCellIndex === null) { + return { + actorNavigationPoint, + actorStartCellIndex: null, + itemId: itemMoveRequest.itemId, + reason: 'missing-actor-start-cell', + } + } + + const previousPlanDebug = lastItemMovePlanDebugRef.current + const resolvedPlan = resolveItemMovePlan( + itemMoveRequest, + actorStartCellIndex, + actorNavigationPoint, + actorStartComponentId, + { + recordFallbackMeta: false, + targetGraphPerfMetricName: 'navigation.debugCurrentMovePlanTargetGraphBuildMs', + }, + ) + const recomputedPlanDebug = lastItemMovePlanDebugRef.current + lastItemMovePlanDebugRef.current = previousPlanDebug + + return { + actorComponentId: actorStartComponentId, + actorCommittedComponentId: actorComponentId, + actorNavigationPoint, + actorStartCellCenter: graph.cells[actorStartCellIndex]?.center ?? null, + actorStartCellIndex, + liveGraphCacheKey: prewarmedGraphStateKey, + liveGraphCurrent: navigationGraphCurrent, + itemId: itemMoveRequest.itemId, + navigationSceneSnapshotKey: navigationSceneSnapshot?.key ?? null, + request: itemMoveRequest, + resolved: Boolean(resolvedPlan), + resolvedPlan: recomputedPlanDebug, + } + } + + const canMoveItemToWorld = (itemId: string, world: [number, number, number]) => { + const candidate = sceneState.nodes[itemId] + if (!(candidate && candidate.type === 'item')) { + return null + } + + const item = candidate as ItemNode + if ( + !isDebugMovableItem( + item, + sceneState.nodes as Record, + ) + ) { + return { + itemId, + reason: 'not-movable', + valid: false, + world, + } + } + + const levelId = resolveLevelId(item, sceneState.nodes) + if (!levelId) { + return { + itemId, + reason: 'missing-level', + valid: false, + world, + } + } + + const itemDimensions = getScaledDimensions(item) + const finalPosition: [number, number, number] = [ + snapDebugMoveAxis(world[0], itemDimensions[0]), + item.position[1], + snapDebugMoveAxis(world[2], itemDimensions[2]), + ] + const finalRotation = [...item.rotation] as [number, number, number] + const placement = spatialGridManager.canPlaceOnFloor( + levelId, + finalPosition, + itemDimensions, + finalRotation, + [item.id], + ) + + return { + finalPosition, + finalRotation, + itemId, + valid: placement.valid, + world, + } + } + + const getToolConeDiagnostics = () => { + const actorRobotDebugState = actorRobotDebugStateRef.current + const toolCone = + typeof actorRobotDebugState?.toolCone === 'object' && actorRobotDebugState.toolCone !== null + ? (actorRobotDebugState.toolCone as Record) + : null + if (!toolCone) { + return null + } + + const targetItemId = typeof toolCone.targetItemId === 'string' ? toolCone.targetItemId : null + const targetObject = targetItemId ? (sceneRegistry.nodes.get(targetItemId) ?? null) : null + const rect = gl.domElement.getBoundingClientRect() + const projectedScratch = new Vector3() + const projectedVisiblePoints: Vector2[] = [] + const positionScratch = new Vector3() + const cameraWorldPosition = new Vector3() + camera.getWorldPosition(cameraWorldPosition) + const projectWorldPoint = (world: [number, number, number]) => { + projectedScratch.set(world[0], world[1], world[2]).project(camera) + if ( + !Number.isFinite(projectedScratch.x) || + !Number.isFinite(projectedScratch.y) || + !Number.isFinite(projectedScratch.z) + ) { + return null + } + + return { + client: new Vector2( + (projectedScratch.x + 1) * 0.5 * rect.width, + (1 - projectedScratch.y) * 0.5 * rect.height, + ), + visible: + projectedScratch.z >= -1 && + projectedScratch.z <= 1 && + projectedScratch.x >= -1 && + projectedScratch.x <= 1 && + projectedScratch.y >= -1 && + projectedScratch.y <= 1, + } + } + + if (targetObject) { + targetObject.updateWorldMatrix(true, true) + targetObject.traverse((child) => { + const mesh = child as Mesh + if ( + !mesh.isMesh || + !mesh.geometry || + mesh.userData?.pascalExcludeFromToolConeTarget === true || + !isObjectVisibleInHierarchy(mesh) + ) { + return + } + + const positionAttribute = mesh.geometry.getAttribute('position') + if (!positionAttribute) { + return + } + + for (let index = 0; index < positionAttribute.count; index += 1) { + positionScratch.fromBufferAttribute(positionAttribute, index) + mesh.localToWorld(positionScratch) + const projectedPoint = projectWorldPoint([ + positionScratch.x, + positionScratch.y, + positionScratch.z, + ]) + if (!projectedPoint) { + continue + } + projectedVisiblePoints.push(projectedPoint.client) + } + }) + } + + const targetProjectedHull = computeProjectedHull2D(projectedVisiblePoints) + const hullPoints = Array.isArray(toolCone.hullPoints) ? toolCone.hullPoints : [] + const hullDiagnostics = ( + hullPoints.map((entry) => { + if (!(typeof entry === 'object' && entry !== null)) { + return null + } + + const hullPoint = entry as Record + const worldPoint = isVector3Tuple(hullPoint.worldPoint) ? hullPoint.worldPoint : null + const renderedWorldPoint = isVector3Tuple(hullPoint.renderedWorldPoint) + ? hullPoint.renderedWorldPoint + : null + if (!worldPoint) { + return null + } + + const projectedWorldPoint = projectWorldPoint(worldPoint) + const projectedRenderedPoint = renderedWorldPoint + ? projectWorldPoint(renderedWorldPoint) + : null + const surfaceHit = hullPoint.isApex + ? null + : getToolConeTargetSurfaceHit(targetObject, worldPoint, cameraWorldPosition) + let silhouetteDistancePx: number | null = null + let silhouetteRelation: 'boundary' | 'inside' | 'outside' | 'unknown' = 'unknown' + + if (projectedWorldPoint && targetProjectedHull.length >= 2) { + silhouetteDistancePx = targetProjectedHull.reduce( + (minimumDistance, point, index) => { + const nextPoint = targetProjectedHull[(index + 1) % targetProjectedHull.length] + if (!nextPoint) { + return minimumDistance + } + return Math.min( + minimumDistance, + getDistanceToSegment2D(projectedWorldPoint.client, point, nextPoint), + ) + }, + Number.POSITIVE_INFINITY, + ) + + if (Number.isFinite(silhouetteDistancePx)) { + if (silhouetteDistancePx <= 1) { + silhouetteRelation = 'boundary' + } else { + silhouetteRelation = isPointInsidePolygon2D( + projectedWorldPoint.client, + targetProjectedHull, + ) + ? 'inside' + : 'outside' + } + } else { + silhouetteDistancePx = null + } + } + + const cameraSurfaceRelation = + surfaceHit?.relation === 'no-hit' && + projectedWorldPoint?.visible && + typeof silhouetteDistancePx === 'number' && + silhouetteDistancePx <= 1 + ? ('grazing' as const) + : (surfaceHit?.relation ?? (hullPoint.isApex ? 'apex' : 'no-hit')) + + return { + ...hullPoint, + cameraSurfaceDistanceDelta: surfaceHit?.surfaceDistanceDelta ?? null, + cameraSurfaceMeshName: surfaceHit?.surfaceMeshName ?? null, + cameraSurfacePoint: surfaceHit?.surfacePoint ?? null, + cameraSurfaceRelation, + projectedVisible: projectedWorldPoint?.visible ?? false, + screenAlignmentErrorPx: + projectedWorldPoint && projectedRenderedPoint + ? projectedWorldPoint.client.distanceTo(projectedRenderedPoint.client) + : null, + silhouetteDistancePx, + silhouetteRelation, + } + }) as Array< + | null + | (Record & { + cameraSurfaceDistanceDelta: number | null + cameraSurfaceMeshName: string | null + cameraSurfacePoint: [number, number, number] | null + cameraSurfaceRelation: 'apex' | 'grazing' | 'no-hit' | 'occluded' | 'visible' + projectedVisible: boolean + screenAlignmentErrorPx: number | null + silhouetteDistancePx: number | null + silhouetteRelation: 'boundary' | 'inside' | 'outside' | 'unknown' + worldAlignmentError?: number + }) + > + ).filter((entry): entry is NonNullable => Boolean(entry)) + + const interiorPoints = hullDiagnostics.filter( + (entry) => + entry.silhouetteRelation === 'inside' && + typeof entry.silhouetteDistancePx === 'number' && + entry.silhouetteDistancePx > 1, + ) + const occludedSurfacePoints = hullDiagnostics.filter( + (entry) => entry.cameraSurfaceRelation === 'occluded', + ) + const grazingSurfacePoints = hullDiagnostics.filter( + (entry) => entry.cameraSurfaceRelation === 'grazing', + ) + const missingSurfacePoints = hullDiagnostics.filter( + (entry) => entry.cameraSurfaceRelation === 'no-hit', + ) + + return { + ...toolCone, + hullPoints: hullDiagnostics, + maxCameraSurfaceDistanceDelta: hullDiagnostics.reduce( + (maximum, entry) => + Math.max( + maximum, + typeof entry.cameraSurfaceDistanceDelta === 'number' + ? entry.cameraSurfaceDistanceDelta + : 0, + ), + 0, + ), + interiorPointCount: interiorPoints.length, + maxInteriorDistancePx: interiorPoints.reduce( + (maximum, entry) => + Math.max( + maximum, + typeof entry.silhouetteDistancePx === 'number' ? entry.silhouetteDistancePx : 0, + ), + 0, + ), + missingSurfacePointCount: missingSurfacePoints.length, + occludedSurfacePointCount: occludedSurfacePoints.length, + grazingSurfacePointCount: grazingSurfacePoints.length, + maxScreenAlignmentErrorPx: hullDiagnostics.reduce( + (maximum, entry) => + Math.max( + maximum, + typeof entry.screenAlignmentErrorPx === 'number' ? entry.screenAlignmentErrorPx : 0, + ), + 0, + ), + maxWorldAlignmentError: hullDiagnostics.reduce( + (maximum, entry) => + Math.max( + maximum, + typeof entry.worldAlignmentError === 'number' ? entry.worldAlignmentError : 0, + ), + 0, + ), + targetItemId, + targetObjectFound: Boolean(targetObject), + targetProjectedHullVertexCount: targetProjectedHull.length, + } + } + + const getMovableItems = () => + Object.values(sceneState.nodes) + .filter((node): node is ItemNode => node?.type === 'item') + .filter((node) => + isDebugMovableItem( + node, + sceneState.nodes as Record, + ), + ) + .map((node) => ({ + id: node.id, + levelId: resolveLevelId(node, sceneState.nodes), + name: node.name ?? node.asset.name, + position: [...node.position] as [number, number, number], + })) + + const getRenderDiagnostics = () => { + const summarizeObject = (object: Object3D | null | undefined) => { + if (!object) { + return { + groupCount: 0, + lineCount: 0, + materialCount: 0, + meshCount: 0, + objectCount: 0, + triangleCount: 0, + } + } + + let groupCount = 0 + let lineCount = 0 + let materialCount = 0 + let meshCount = 0 + let objectCount = 0 + let triangleCount = 0 + + object.traverse((child) => { + objectCount += 1 + if ((child as Group).isGroup) { + groupCount += 1 + } + const childAsMesh = child as Object3D & { + geometry?: BufferGeometry + isLine?: boolean + isLineLoop?: boolean + isLineSegments?: boolean + isMesh?: boolean + material?: Material | Material[] + } + if (childAsMesh.isMesh) { + meshCount += 1 + materialCount += Array.isArray(childAsMesh.material) ? childAsMesh.material.length : 1 + const positionAttribute = childAsMesh.geometry?.getAttribute('position') + const indexCount = childAsMesh.geometry?.index?.count ?? 0 + if (indexCount > 0) { + triangleCount += indexCount / 3 + } else if (positionAttribute) { + triangleCount += positionAttribute.count / 3 + } + } else if (childAsMesh.isLine || childAsMesh.isLineLoop || childAsMesh.isLineSegments) { + lineCount += 1 + } + }) + + return { + groupCount, + lineCount, + materialCount, + meshCount, + objectCount, + triangleCount, + } + } + + const items = Object.values(sceneState.nodes) + .filter((node): node is ItemNode => node?.type === 'item') + .map((node) => { + const object = sceneRegistry.nodes.get(node.id) + return { + assetId: node.asset.id, + assetSrc: node.asset.src, + id: node.id, + name: node.name ?? node.asset.name, + ...summarizeObject(object), + } + }) + .sort( + (left, right) => + right.meshCount - left.meshCount || right.triangleCount - left.triangleCount, + ) + + const assetSummary = new Map< + string, + { + count: number + meshCount: number + triangleCount: number + } + >() + + for (const item of items) { + const key = item.assetSrc || item.assetId + const entry = assetSummary.get(key) ?? { + count: 0, + meshCount: 0, + triangleCount: 0, + } + entry.count += 1 + entry.meshCount += item.meshCount + entry.triangleCount += item.triangleCount + assetSummary.set(key, entry) + } + + return { + assetSummary: [...assetSummary.entries()] + .map(([assetKey, value]) => ({ + assetKey, + averageMeshCount: value.count > 0 ? value.meshCount / value.count : 0, + averageTriangleCount: value.count > 0 ? value.triangleCount / value.count : 0, + count: value.count, + totalMeshCount: value.meshCount, + totalTriangleCount: value.triangleCount, + })) + .sort((left, right) => right.totalMeshCount - left.totalMeshCount) + .slice(0, 20), + itemCount: items.length, + sceneByType: Object.fromEntries( + Object.entries(sceneRegistry.byType).map(([type, ids]) => [type, ids.size]), + ), + topItems: items.slice(0, 20), + } + } + + const getViewportDiagnostics = () => { + const rect = gl.domElement.getBoundingClientRect() + return { + cameraAspect: + camera instanceof PerspectiveCamera + ? camera.aspect + : rect.height > 0 + ? rect.width / rect.height + : null, + cameraLayers: camera.layers.mask, + canvasClientHeight: gl.domElement.clientHeight, + canvasClientWidth: gl.domElement.clientWidth, + canvasHeight: gl.domElement.height, + canvasRect: { + height: rect.height, + left: rect.left, + top: rect.top, + width: rect.width, + }, + canvasWidth: gl.domElement.width, + devicePixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio : null, + size: { + height: canvasSize.height, + left: canvasSize.left, + top: canvasSize.top, + width: canvasSize.width, + }, + } + } + + const projectNodeToClient = (nodeId: string) => { + const nodeObject = sceneRegistry.nodes.get(nodeId) + if (!nodeObject) { + return null + } + + const worldPosition = nodeObject.getWorldPosition(new Vector3()) + return projectWorldToClient([worldPosition.x, worldPosition.y, worldPosition.z]) + } + + const projectNodeBoundsToClient = (nodeId: string) => { + const nodeObject = sceneRegistry.nodes.get(nodeId) + if (!nodeObject) { + return null + } + + const bounds = new Box3().setFromObject(nodeObject) + if (bounds.isEmpty()) { + const projectedPoint = projectNodeToClient(nodeId) + if (!projectedPoint) { + return null + } + return { + bottom: projectedPoint.y, + centerX: projectedPoint.x, + centerY: projectedPoint.y, + height: 0, + left: projectedPoint.x, + right: projectedPoint.x, + top: projectedPoint.y, + visible: projectedPoint.visible, + width: 0, + } + } + + const rect = gl.domElement.getBoundingClientRect() + const corners = [ + new Vector3(bounds.min.x, bounds.min.y, bounds.min.z), + new Vector3(bounds.min.x, bounds.min.y, bounds.max.z), + new Vector3(bounds.min.x, bounds.max.y, bounds.min.z), + new Vector3(bounds.min.x, bounds.max.y, bounds.max.z), + new Vector3(bounds.max.x, bounds.min.y, bounds.min.z), + new Vector3(bounds.max.x, bounds.min.y, bounds.max.z), + new Vector3(bounds.max.x, bounds.max.y, bounds.min.z), + new Vector3(bounds.max.x, bounds.max.y, bounds.max.z), + ] + + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + let anyVisible = false + + for (const corner of corners) { + const projected = corner.project(camera) + if ( + Number.isFinite(projected.x) && + Number.isFinite(projected.y) && + Number.isFinite(projected.z) + ) { + const x = rect.left + ((projected.x + 1) / 2) * rect.width + const y = rect.top + ((1 - projected.y) / 2) * rect.height + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x) + maxY = Math.max(maxY, y) + if ( + projected.z >= -1 && + projected.z <= 1 && + projected.x >= -1 && + projected.x <= 1 && + projected.y >= -1 && + projected.y <= 1 + ) { + anyVisible = true + } + } + } + + if ( + !Number.isFinite(minX) || + !Number.isFinite(minY) || + !Number.isFinite(maxX) || + !Number.isFinite(maxY) + ) { + return null + } + + return { + bottom: maxY, + centerX: (minX + maxX) / 2, + centerY: (minY + maxY) / 2, + height: Math.max(0, maxY - minY), + left: minX, + right: maxX, + top: minY, + visible: anyVisible, + width: Math.max(0, maxX - minX), + } + } + + const setNavigationEnabled = (value: boolean) => { + recordNavigationPerfMark('navigation.debugSetEnabled', { enabled: value }) + useNavigation.getState().setEnabled(value) + } + + const setMoveItemsEnabled = (value: boolean) => { + recordNavigationPerfMark('navigation.debugSetMoveItemsEnabled', { enabled: value }) + useNavigation.getState().setMoveItemsEnabled(value) + } + + const setRobotMode = (mode: NavigationRobotMode | null) => { + recordNavigationPerfMark('navigation.debugSetRobotMode', { mode: mode ?? 'off' }) + useNavigation.getState().setRobotMode(mode) + } + + const snapDebugMoveAxis = (position: number, dimension: number) => { + const halfDimension = dimension / 2 + const needsOffset = Math.abs(((halfDimension * 2) % 1) - 0.5) < 0.01 + const offset = needsOffset ? 0.25 : 0 + return Math.round((position - offset) * 2) / 2 + offset + } + + const startMoveItem = (itemId: string) => { + const candidate = sceneState.nodes[itemId] + if (!(candidate && candidate.type === 'item')) { + return false + } + + const item = candidate as ItemNode + if ( + !isDebugMovableItem( + item, + sceneState.nodes as Record, + ) + ) { + return false + } + + const levelId = resolveLevelId(item, sceneState.nodes) + const selectionLevelId = toLevelNodeId(levelId) ?? useViewer.getState().selection.levelId + useEditor.getState().setPhase('furnish') + useEditor.getState().setMode('select') + useEditor.getState().setTool(null) + useEditor.getState().setMovingNode(item) + recordNavigationPerfMark('navigation.debugStartMoveItem', { itemId: item.id }) + useViewer.getState().setSelection({ + levelId: selectionLevelId, + selectedIds: [], + zoneId: null, + }) + return true + } + + const requestMoveItemToWorld = (itemId: string, world: [number, number, number]) => { + const candidate = sceneState.nodes[itemId] + if (!(candidate && candidate.type === 'item')) { + return false + } + + const item = candidate as ItemNode + if ( + !isDebugMovableItem( + item, + sceneState.nodes as Record, + ) + ) { + return false + } + + const levelId = resolveLevelId(item, sceneState.nodes) + if (!levelId) { + return false + } + + const itemDimensions = getScaledDimensions(item) + const finalPosition: [number, number, number] = [ + snapDebugMoveAxis(world[0], itemDimensions[0]), + item.position[1], + snapDebugMoveAxis(world[2], itemDimensions[2]), + ] + const finalRotation = [...item.rotation] as [number, number, number] + const placement = spatialGridManager.canPlaceOnFloor( + levelId, + finalPosition, + itemDimensions, + finalRotation, + [item.id], + ) + if (!placement.valid) { + return false + } + + if (!startMoveItem(itemId)) { + return false + } + + const request: NavigationItemMoveRequest = { + finalUpdate: { + position: finalPosition, + rotation: finalRotation, + }, + itemDimensions, + itemId: item.id, + levelId: item.parentId, + sourcePosition: [...item.position] as [number, number, number], + sourceRotation: [...item.rotation] as [number, number, number], + } + recordNavigationPerfMark('navigation.debugRequestMoveItemToWorld', { + itemId: item.id, + targetX: finalPosition[0], + targetY: finalPosition[1], + targetZ: finalPosition[2], + }) + + let remainingFrames = 120 + const startWhenControllerReady = () => { + const editorState = useEditor.getState() + if (editorState.movingNode?.id !== item.id) { + return + } + + const navigationState = useNavigation.getState() + if (navigationState.itemMoveControllers[item.id]) { + navigationState.requestItemMove(request) + navigationState.setItemMoveLocked(true) + return + } + + if (remainingFrames <= 0) { + return + } + + remainingFrames -= 1 + requestAnimationFrame(startWhenControllerReady) + } + + startWhenControllerReady() + return true + } + + const queueMoveItemToWorld = (itemId: string, world: [number, number, number]) => { + const candidate = sceneState.nodes[itemId] + if (!(candidate && candidate.type === 'item')) { + return false + } + + const item = candidate as ItemNode + if ( + !isDebugMovableItem( + item, + sceneState.nodes as Record, + ) + ) { + return false + } + + const levelId = resolveLevelId(item, sceneState.nodes) + if (!levelId) { + return false + } + + const navigationState = useNavigation.getState() + if ( + navigationState.taskQueue.some( + (task) => task.kind === 'move' && task.request.itemId === item.id, + ) || + navigationState.itemMoveControllers[item.id] + ) { + return false + } + + const itemDimensions = getScaledDimensions(item) + const finalPosition: [number, number, number] = [ + snapDebugMoveAxis(world[0], itemDimensions[0]), + item.position[1], + snapDebugMoveAxis(world[2], itemDimensions[2]), + ] + const finalRotation = [...item.rotation] as [number, number, number] + const previewId = + `item_debug_move_preview_${item.id}_${Math.round(performance.now())}` as ItemNode['id'] + const placement = spatialGridManager.canPlaceOnFloor( + levelId, + finalPosition, + itemDimensions, + finalRotation, + [item.id], + ) + if (!placement.valid) { + return false + } + + const request: NavigationItemMoveRequest = { + finalUpdate: { + position: finalPosition, + rotation: finalRotation, + }, + itemDimensions, + itemId: item.id, + levelId: item.parentId, + sourcePosition: [...item.position] as [number, number, number], + sourceRotation: [...item.rotation] as [number, number, number], + targetPreviewItemId: previewId, + visualItemId: item.id, + } + + ensureQueuedNavigationMoveGhostNode(request) + + navigationState.registerItemMoveController(item.id, { + itemId: item.id, + beginCarry: () => { + navigationVisualsStore.getState().setItemMoveVisualState(item.id, 'carried') + }, + cancel: () => { + navigationVisualsStore.getState().setItemMoveVisualState(item.id, null) + navigationVisualsStore.getState().setNodeVisibilityOverride(item.id, null) + useLiveTransforms.getState().clear(item.id) + navigationState.registerItemMoveController(item.id, null) + }, + commit: (finalUpdate, finalCarryTransform) => { + const sceneNode = useScene.getState().nodes[item.id as AnyNodeId] + if (sceneNode?.type === 'item') { + useScene.getState().updateNode(item.id as AnyNodeId, { + ...finalUpdate, + metadata: stripTransient(sceneNode.metadata) as ItemNode['metadata'], + }) + } + + if (finalCarryTransform) { + useLiveTransforms.getState().set(item.id, finalCarryTransform) + } + navigationVisualsStore.getState().setItemMoveVisualState(item.id, null) + navigationVisualsStore.getState().setNodeVisibilityOverride(item.id, null) + useLiveTransforms.getState().clear(item.id) + clearRuntimeItemMoveVisualState(item.id) + navigationState.registerItemMoveController(item.id, null) + }, + updateCarryTransform: (position, rotationY) => { + useLiveTransforms.getState().set(item.id, { + position, + rotation: rotationY, + }) + }, + }) + + navigationVisualsStore.getState().setItemMoveVisualState(item.id, 'source-pending') + navigationVisualsStore.getState().setItemMoveVisualState(previewId, 'destination-ghost') + recordNavigationPerfMark('navigation.debugQueueMoveItemToWorld', { + itemId: item.id, + previewId, + targetX: finalPosition[0], + targetY: finalPosition[1], + targetZ: finalPosition[2], + }) + navigationState.requestItemMove(request) + navigationState.setItemMoveLocked(false) + return true + } + + const emitGridMove = (world: [number, number, number]) => { + emitter.emit('grid:move', { + nativeEvent: null as never, + localPosition: world, + position: world, + }) + } + + const emitGridClick = (world: [number, number, number]) => { + emitter.emit('grid:click', { + nativeEvent: null as never, + localPosition: world, + position: world, + }) + } + + const projectWorldToClient = (world: [number, number, number]) => { + const rect = gl.domElement.getBoundingClientRect() + const projected = new Vector3(world[0], world[1], world[2]).project(camera) + + return { + visible: + projected.z >= -1 && + projected.z <= 1 && + projected.x >= -1 && + projected.x <= 1 && + projected.y >= -1 && + projected.y <= 1, + x: rect.left + ((projected.x + 1) / 2) * rect.width, + y: rect.top + ((1 - projected.y) / 2) * rect.height, + } + } + + const setLookAt = (position: [number, number, number], target: [number, number, number]) => { + navigationEmitter.emit('navigation:look-at', { + position, + target, + }) + } + + const setActorRenderVisible = (visible: boolean | null) => { + actorRenderVisibleOverrideRef.current = visible + } + + const setRobotSkinnedMeshesVisible = (visible: boolean | null) => { + robotSkinnedMeshVisibleOverrideRef.current = visible + } + + const setRobotStaticMeshesVisible = (visible: boolean | null) => { + robotStaticMeshVisibleOverrideRef.current = visible + } + + const setRobotToolAttachmentsVisible = (visible: boolean | null) => { + robotToolAttachmentsVisibleOverrideRef.current = visible + } + + const setRobotMaterialDebugMode = (mode: NavigationRobotMaterialDebugMode | null) => { + robotMaterialDebugModeOverrideRef.current = mode + } + + const requestDeleteItemById = (itemId: string) => { + const node = sceneState.nodes[itemId] + if (node?.type !== 'item') { + return false + } + + return requestNavigationItemDelete(node) + } + + const navDebugApi = { + canMoveItemToWorld, + getConnectivitySnapshot, + getCurrentMovePlanDiagnostics, + getDoorTangentDiagnostics, + getNodeRenderTree, + getPathDiagnostics, + getRenderBreakdown, + getState, + getTrajectorySamples, + getItemMoveState, + getMovableItems, + getRenderDiagnostics, + getToolConeDiagnostics, + getToolConeIsolatedOverlay: () => navigationVisualsStore.getState().toolConeIsolatedOverlay, + getViewportDiagnostics, + emitGridClick, + emitGridMove, + moveToWorld: requestNavigationToPoint, + projectNodeBoundsToClient, + projectNodeToClient, + projectWorldToClient, + setTrajectoryDebugDistance: (distance: number | null) => { + trajectoryDebugDistanceRef.current = distance + }, + setTrajectoryDebugMode: (mode: 'fade' | 'hidden' | 'live' | 'opaque') => { + trajectoryDebugModeRef.current = mode + }, + setTrajectoryDebugOpaque: (enabled: boolean) => { + trajectoryDebugOpaqueRef.current = enabled + trajectoryDebugModeRef.current = enabled ? 'opaque' : 'fade' + }, + setTrajectoryDebugPause: (paused: boolean) => { + trajectoryDebugPauseRef.current = paused + }, + resetPerf: resetNavigationPerf, + setMoveItemsEnabled, + setNavigationEnabled, + setActorRenderVisible, + setRobotSkinnedMeshesVisible, + setRobotStaticMeshesVisible, + setRobotMaterialDebugMode, + setRobotToolAttachmentsVisible, + setPascalTruckVisible: (visible: boolean) => { + navigationVisualsStore + .getState() + .setNodeVisibilityOverride(PASCAL_TRUCK_ITEM_NODE_ID, visible ? null : false) + }, + setShadowMapEnabled: (enabled: boolean | null) => { + shadowMapOverrideEnabledRef.current = enabled + }, + setToolConeOverlayEnabled: (enabled: boolean) => { + navigationVisualsStore.getState().setToolConeOverlayEnabled(enabled) + }, + setViewerPostProcessing: ( + mode: ReturnType['runtimePostProcessing'], + ) => { + useViewer.getState().setRuntimePostProcessing(mode) + }, + setRobotMode, + requestDeleteItemById, + requestMoveItemToWorld, + queueMoveItemToWorld, + setToolConeIsolatedOverlay: ( + overlay: ReturnType['toolConeIsolatedOverlay'], + ) => { + navigationVisualsStore.getState().setToolConeIsolatedOverlay(overlay) + }, + startMoveItem, + setLookAt, + } + + void navDebugApi + }, [ + actorCellIndex, + actorComponentId, + actorMoving, + actorSpawnPosition?.join(':') ?? null, + actorVisible, + camera, + candidatePathCollisionAudit.blockedObstacleIds, + candidatePathCollisionAudit.blockedSampleCount, + candidatePathCollisionAudit.blockedWallIds, + candidatePathCurve, + conservativePathCollisionAudit.blockedObstacleIds, + conservativePathCollisionAudit.blockedSampleCount, + conservativePathCollisionAudit.blockedWallIds, + conservativePathCurve, + enabled, + gl.domElement, + graph, + pathIndices.length, + pathLength, + actorRobotWarmupReady, + pathCurve, + pascalTruckExitActive, + pascalTruckIntroTaskReady, + prewarmedGraph, + requestNavigationToPoint, + sceneState.nodes, + sceneState.rootNodeIds.join('|'), + navigationSceneSnapshot?.key, + selection.buildingId, + selection.levelId, + trajectoryMotionProfile, + getActorNavigationPlanningState, + getTaskModeSnapshot, + getResolvedActorWorldPosition, + ]) + + return ( + <> + {pathGraph && ( + + )} + + {taskQueueSourceMarkerSpecs.map((marker) => ( + + ))} + + {enabled && PATH_STATIC_PREVIEW_MODE && pathCurve && pathRenderSegments.length > 0 && ( + + {pathRenderSegments.map((segment, segmentIndex) => ( + + + + + ))} + + )} + + {enabled && !PATH_STATIC_PREVIEW_MODE && trajectoryRibbonGeometry && ( + + + + + + + )} + + {actorMounted && ( + + + + + + )} + + ) +} diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index 95b86dd17..723ee7f04 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -1,7 +1,7 @@ 'use client' import { Icon } from '@iconify/react' -import { Copy, Move, Spline, Trash2 } from 'lucide-react' +import { Copy, Move, Spline, Trash2, Wrench } from 'lucide-react' import type { MouseEventHandler, PointerEventHandler } from 'react' type NodeActionMenuProps = { @@ -9,6 +9,7 @@ type NodeActionMenuProps = { onDelete?: MouseEventHandler onDuplicate?: MouseEventHandler onMove?: MouseEventHandler + onRepair?: MouseEventHandler onCurve?: MouseEventHandler onPointerDown?: PointerEventHandler onPointerUp?: PointerEventHandler @@ -21,6 +22,7 @@ export function NodeActionMenu({ onDelete, onDuplicate, onMove, + onRepair, onCurve, onPointerDown, onPointerUp, @@ -79,6 +81,17 @@ export function NodeActionMenu({ )} + {onRepair && ( + + )} {onDelete && ( + + {robotTooltip} + + + + + {robotMode === 'task' && ( +
+
+ {taskQueue.length === 0 ? ( +
+ Ghost Queue +
+ ) : ( + taskQueueRenderEntries.map((entry, taskIndex) => { + if (entry.type === 'placeholder') { + return ( +
+ ) + } + + const task = entry.task + const { buttonClassName, icon: IconComponent, label } = getTaskMeta(task) + const active = task.taskId === activeTaskId + const taskQueueIndex = taskQueue.findIndex((queuedTask) => queuedTask.taskId === task.taskId) + return ( +
+ +
+ ) + }) + )} +
+
+ )} + + {dragState?.dragging && draggedTask && draggedTaskMeta && DraggedTaskIcon && ( +
+
+ +
+
+ )} +
+ ) +} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index cc20f364b..030fc0cf0 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import useEditor from '../../../store/use-editor' +import useNavigation from '../../../store/use-navigation' import { CeilingPanel } from './ceiling-panel' import { DoorPanel } from './door-panel' import { FencePanel } from './fence-panel' @@ -20,10 +21,14 @@ import { WindowPanel } from './window-panel' export function PanelManager() { const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedReferenceId = useEditor((s) => s.selectedReferenceId) + const navigationEnabled = useNavigation((s) => s.enabled) + const moveItemsEnabled = useNavigation((s) => s.moveItemsEnabled) + const robotMode = useNavigation((s) => s.robotMode) + const suppressItemPanel = navigationEnabled && moveItemsEnabled && robotMode !== null const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen) const mode = useEditor((s) => s.mode) const activePaintMaterial = useEditor((s) => s.activePaintMaterial) - // Only subscribe to the *type* of the single-selected node — string primitive + // Only subscribe to the *type* of the single-selected node - string primitive // so we don't re-render on unrelated scene mutations. const selectedNodeType = useScene((s) => { if (selectedIds.length !== 1) return null @@ -31,7 +36,6 @@ export function PanelManager() { return id ? (s.nodes[id as AnyNodeId]?.type ?? null) : null }) - // Show reference panel if a reference is selected if (selectedReferenceId) { return } @@ -45,10 +49,13 @@ export function PanelManager() { return } - // Show appropriate panel based on selected node type + // Show appropriate panel based on selected node type. if (selectedNodeType) { switch (selectedNodeType) { case 'item': + if (suppressItemPanel) { + return null + } return case 'roof': return diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx index 35a6980d5..dfc3281e0 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx @@ -34,7 +34,7 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({ const setSelection = useViewer((state) => state.setSelection) const handleClick = () => { - setSelection({ buildingId: nodeId }) + setSelection({ buildingId: nodeId as BuildingNode['id'] }) } const handleAddLevel = (e: React.MouseEvent) => { diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx index b467f2c7b..b700fffec 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx @@ -130,8 +130,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - area += polygon[i]?.[0] * polygon[j]?.[1] - area -= polygon[j]?.[0] * polygon[i]?.[1] + const current = polygon[i] + const next = polygon[j] + if (!(current && next)) continue + area += current[0] * next[1] + area -= next[0] * current[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx index 08d6aa641..b37b556bd 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx @@ -38,7 +38,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({ return ( } + actions={} depth={depth} expanded={false} hasChildren={false} @@ -53,7 +53,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({ setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index b88c6a602..9cafe4bf2 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -360,7 +360,7 @@ const ReferenceItem = memo(function ReferenceItem({ setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> @@ -665,7 +665,7 @@ const LevelItem = memo(function LevelItem({ setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> @@ -1087,7 +1087,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx index 7e390870c..bba870270 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx @@ -29,7 +29,10 @@ export const LevelTreeNode = memo(function LevelTreeNode({ const isHovered = useViewer((state) => state.hoveredId === nodeId) const setSelection = useViewer((state) => state.setSelection) - const handleClick = useCallback(() => setSelection({ levelId: nodeId }), [nodeId, setSelection]) + const handleClick = useCallback( + () => setSelection({ levelId: nodeId as LevelNode['id'] }), + [nodeId, setSelection], + ) const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId]) const handleToggle = useCallback(() => setExpanded((prev) => !prev), []) const handleStartEditing = useCallback(() => setIsEditing(true), []) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx index 4ce5dd4e6..e0f146077 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx @@ -91,8 +91,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - area += polygon[i]?.[0] * polygon[j]?.[1] - area -= polygon[j]?.[0] * polygon[i]?.[1] + const current = polygon[i] + const next = polygon[j] + if (!(current && next)) continue + area += current[0] * next[1] + area -= next[0] * current[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx index c1afbc7d7..d95b6193a 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx @@ -27,7 +27,10 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({ const setSelection = useViewer((state) => state.setSelection) const setHoveredId = useViewer((state) => state.setHoveredId) - const handleClick = useCallback(() => setSelection({ zoneId: nodeId }), [nodeId, setSelection]) + const handleClick = useCallback( + () => setSelection({ zoneId: nodeId as ZoneNode['id'] }), + [nodeId, setSelection], + ) const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId]) const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId]) const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId]) @@ -44,7 +47,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({ depth={depth} expanded={false} hasChildren={false} - icon={ updateNode(nodeId, { color: c })} />} + icon={ updateNode(nodeId, { color: c })} />} isHovered={isHovered} isLast={isLast} isSelected={isSelected} @@ -78,8 +81,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - area += polygon[i]?.[0] * polygon[j]?.[1] - area -= polygon[j]?.[0] * polygon[i]?.[1] + const current = polygon[i] + const next = polygon[j] + if (!(current && next)) continue + area += current[0] * next[1] + area -= next[0] * current[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/components/ui/viewer-toolbar.tsx b/packages/editor/src/components/ui/viewer-toolbar.tsx index 53fe873ee..c4c111ea0 100644 --- a/packages/editor/src/components/ui/viewer-toolbar.tsx +++ b/packages/editor/src/components/ui/viewer-toolbar.tsx @@ -1,16 +1,33 @@ 'use client' +import { emitter } from '@pascal-app/core' import { Icon as IconifyIcon } from '@iconify/react' import { useViewer } from '@pascal-app/viewer' -import { Check, ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } from 'lucide-react' +import { + Bot, + Check, + ChevronsLeft, + ChevronsRight, + Columns2, + Eye, + Footprints, + Moon, + Shield, + Sun, +} from 'lucide-react' import { useCallback } from 'react' import { cn } from '../../lib/utils' import useEditor from '../../store/use-editor' +import useNavigation, { + type NavigationRobotModel, + type NavigationRobotMode, +} from '../../store/use-navigation' import type { GridSnapStep, ViewMode } from '../../store/use-editor' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from './primitives/dropdown-menu' import { useSidebarStore } from './primitives/sidebar' @@ -128,6 +145,102 @@ function WalkthroughButton() { ) } +const ROBOT_MODE_OPTIONS: Array<{ label: string; mode: NavigationRobotMode }> = [ + { label: 'Manual mode', mode: 'normal' }, + { label: 'Task mode', mode: 'task' }, +] + +const ROBOT_MODEL_LABELS: Record = { + armored: 'Armored robot', + pascal: 'Pascal robot', +} + +function RobotModeButton() { + const robotModel = useNavigation((state) => state.robotModel) + const robotMode = useNavigation((state) => state.robotMode) + const setRobotModel = useNavigation((state) => state.setRobotModel) + const setRobotMode = useNavigation((state) => state.setRobotMode) + + const activateRobotMode = useCallback( + (mode: NavigationRobotMode) => { + emitter.emit('tool:cancel') + const viewerState = useViewer.getState() + viewerState.setHoveredId(null) + viewerState.setPreviewSelectedIds([]) + viewerState.setSelection({ selectedIds: [], zoneId: null }) + viewerState.outliner.selectedObjects.length = 0 + viewerState.outliner.hoveredObjects.length = 0 + + const editorState = useEditor.getState() + editorState.setEditingHole(null) + editorState.setFloorplanSelectionTool('click') + editorState.setMode('select') + editorState.setSelectedReferenceId(null) + editorState.setTool(null) + + setRobotMode(mode) + }, + [setRobotMode], + ) + + const toggleRobotModel = useCallback(() => { + setRobotModel(robotModel === 'pascal' ? 'armored' : 'pascal') + }, [robotModel, setRobotModel]) + + const nextRobotModel = robotModel === 'pascal' ? 'armored' : 'pascal' + const tooltipLabel = + robotMode === 'normal' + ? `Robot: manual mode (${ROBOT_MODEL_LABELS[robotModel]})` + : robotMode === 'task' + ? `Robot: task mode (${ROBOT_MODEL_LABELS[robotModel]})` + : `Robot (${ROBOT_MODEL_LABELS[robotModel]})` + + return ( + + + + + + + + {tooltipLabel} + + + {ROBOT_MODE_OPTIONS.map((option) => { + const isActive = robotMode === option.mode + return ( + activateRobotMode(option.mode)}> + + {option.label} + {isActive ? : } + + + ) + })} + + + + {ROBOT_MODEL_LABELS[nextRobotModel]} + {nextRobotModel === 'armored' ? ( + + ) : ( + + )} + + + + + ) +} + function UnitToggle() { const unit = useViewer((s) => s.unit) const setUnit = useViewer((s) => s.setUnit) @@ -389,6 +502,7 @@ export function ViewerToolbarRight() {
+
) diff --git a/packages/editor/src/hooks/use-auto-save.ts b/packages/editor/src/hooks/use-auto-save.ts index 021eeeedd..3b1a8bd29 100644 --- a/packages/editor/src/hooks/use-auto-save.ts +++ b/packages/editor/src/hooks/use-auto-save.ts @@ -12,6 +12,7 @@ interface UseAutoSaveOptions { onSave?: (scene: SceneGraph) => Promise onDirty?: () => void onSaveStatusChange?: (status: SaveStatus) => void + suppressSave?: boolean isVersionPreviewMode?: boolean } @@ -25,6 +26,7 @@ export function useAutoSave({ onSave, onDirty, onSaveStatusChange, + suppressSave = false, isVersionPreviewMode = false, }: UseAutoSaveOptions): { isLoadingSceneRef: MutableRefObject } { const saveTimeoutRef = useRef(undefined) @@ -38,6 +40,7 @@ export function useAutoSave({ const onSaveRef = useRef(onSave) const onDirtyRef = useRef(onDirty) const onSaveStatusChangeRef = useRef(onSaveStatusChange) + const suppressSaveRef = useRef(suppressSave) const isVersionPreviewModeRef = useRef(isVersionPreviewMode) useEffect(() => { @@ -49,6 +52,9 @@ export function useAutoSave({ useEffect(() => { onSaveStatusChangeRef.current = onSaveStatusChange }, [onSaveStatusChange]) + useEffect(() => { + suppressSaveRef.current = suppressSave + }, [suppressSave]) useEffect(() => { isVersionPreviewModeRef.current = isVersionPreviewMode }, [isVersionPreviewMode]) @@ -62,7 +68,7 @@ export function useAutoSave({ let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes) async function executeSave() { - if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) { + if (isLoadingSceneRef.current || isVersionPreviewModeRef.current || suppressSaveRef.current) { pendingSaveRef.current = true setSaveStatus('paused') return @@ -107,7 +113,7 @@ export function useAutoSave({ return } - if (isVersionPreviewModeRef.current) { + if (isVersionPreviewModeRef.current || suppressSaveRef.current) { setSaveStatus('paused') lastNodesSnapshot = JSON.stringify(state.nodes) return @@ -135,6 +141,7 @@ export function useAutoSave({ }) function flushOnExit() { + if (suppressSaveRef.current) return if (!hasDirtyChangesRef.current) return const { nodes, rootNodeIds } = useScene.getState() const sceneGraph = { nodes, rootNodeIds } as SceneGraph @@ -159,7 +166,7 @@ export function useAutoSave({ // Handle version preview mode transitions useEffect(() => { - if (isVersionPreviewMode) { + if (isVersionPreviewMode || suppressSave) { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current) saveTimeoutRef.current = undefined @@ -185,7 +192,7 @@ export function useAutoSave({ } setSaveStatus('saved') - }, [isVersionPreviewMode, setSaveStatus]) + }, [isVersionPreviewMode, setSaveStatus, suppressSave]) return { isLoadingSceneRef } } diff --git a/packages/editor/src/hooks/use-grid-events.ts b/packages/editor/src/hooks/use-grid-events.ts index 9c1a22c85..a165d2739 100644 --- a/packages/editor/src/hooks/use-grid-events.ts +++ b/packages/editor/src/hooks/use-grid-events.ts @@ -7,29 +7,28 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' -import { useEffect, useRef } from 'react' +import { type MutableRefObject, useEffect, useRef } from 'react' import { Plane, Raycaster, Vector2, Vector3 } from 'three' /** * Custom grid events hook that uses manual raycasting instead of mesh events. * This ensures grid events work even when other meshes block pointer events with stopPropagation. */ -export function useGridEvents(gridY: number) { +export function useGridEvents(gridYRef: MutableRefObject) { const { camera, gl } = useThree() const raycaster = useRef(new Raycaster()) const pointer = useRef(new Vector2()) const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0)) const intersectionPoint = useRef(new Vector3()) - - // Update ground plane when grid Y changes - useEffect(() => { - groundPlane.current.constant = -gridY - }, [gridY]) + const pendingMoveEventRef = useRef(null) + const moveFrameRef = useRef(null) useEffect(() => { const canvas = gl.domElement const getIntersection = (nativeEvent: MouseEvent | PointerEvent): Vector3 | null => { + groundPlane.current.constant = -gridYRef.current + // Convert mouse position to normalized device coordinates (-1 to +1) const rect = canvas.getBoundingClientRect() pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1 @@ -65,6 +64,14 @@ export function useGridEvents(gridY: number) { emitter.emit(eventKey, payload) } + const flushPendingMove = () => { + moveFrameRef.current = null + const pendingMoveEvent = pendingMoveEventRef.current + pendingMoveEventRef.current = null + if (!pendingMoveEvent) return + emit('move', pendingMoveEvent) + } + const handlePointerDown = (e: PointerEvent) => { if (useViewer.getState().cameraDragging) return if (e.button !== 0) return @@ -85,7 +92,9 @@ export function useGridEvents(gridY: number) { const handlePointerMove = (e: PointerEvent) => { // Emit move even if camera is dragging, so tools like PolygonEditor still work - emit('move', e) + pendingMoveEventRef.current = e + if (moveFrameRef.current !== null) return + moveFrameRef.current = window.requestAnimationFrame(flushPendingMove) } const handleDoubleClick = (e: MouseEvent) => { @@ -107,6 +116,11 @@ export function useGridEvents(gridY: number) { canvas.addEventListener('contextmenu', handleContextMenu) return () => { + if (moveFrameRef.current !== null) { + window.cancelAnimationFrame(moveFrameRef.current) + moveFrameRef.current = null + } + pendingMoveEventRef.current = null canvas.removeEventListener('pointerdown', handlePointerDown) canvas.removeEventListener('pointerup', handlePointerUp) canvas.removeEventListener('click', handleClick) @@ -114,5 +128,5 @@ export function useGridEvents(gridY: number) { canvas.removeEventListener('dblclick', handleDoubleClick) canvas.removeEventListener('contextmenu', handleContextMenu) } - }, [camera, gl]) + }, [camera, gl, gridYRef]) } diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index d0eecc695..60a60739f 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -4,6 +4,7 @@ import { useEffect } from 'react' import { runRedo, runUndo } from '../lib/history' import { sfxEmitter } from '../lib/sfx-bus' import useEditor from '../store/use-editor' +import useNavigation, { requestNavigationItemDelete } from '../store/use-navigation' // Tools call this in their onCancel handler when they have an active mid-action to cancel, // so that the global Escape handler knows not to also switch to select mode. @@ -14,6 +15,12 @@ export const markToolCancelConsumed = () => { export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { useEffect(() => { + const isRobotItemMoveRotationActive = () => { + const { enabled, moveItemsEnabled } = useNavigation.getState() + const movingNode = useEditor.getState().movingNode + return enabled && moveItemsEnabled && movingNode?.type === 'item' + } + const handleKeyDown = (e: KeyboardEvent) => { // Don't handle shortcuts if user is typing in an input if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { @@ -137,6 +144,10 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { } } } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) { + if (isRobotItemMoveRotationActive()) { + return + } + // Rotate selected node clockwise if it supports rotation (items, roofs, etc.) const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { @@ -157,6 +168,10 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { } } } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) { + if (isRobotItemMoveRotationActive()) { + return + } + // Rotate selected node counter-clockwise const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { @@ -202,6 +217,13 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length > 0) { + if (selectedNodeIds.length === 1) { + const node = useScene.getState().nodes[selectedNodeIds[0]!] + if (node?.type === 'item' && requestNavigationItemDelete(node)) { + return + } + } + // Play appropriate SFX based on what's being deleted if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] diff --git a/packages/editor/src/lib/item-move-visuals.ts b/packages/editor/src/lib/item-move-visuals.ts new file mode 100644 index 000000000..e1d71ca71 --- /dev/null +++ b/packages/editor/src/lib/item-move-visuals.ts @@ -0,0 +1,43 @@ +import type { ViewerRuntimeItemMoveVisualState } from '@pascal-app/viewer' + +export const ITEM_MOVE_VISUAL_METADATA_KEY = 'navigationMoveVisual' + +export type ItemMoveVisualState = ViewerRuntimeItemMoveVisualState + +function isMetadataRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function getItemMoveVisualState(metadata: unknown): ItemMoveVisualState | null { + if (!isMetadataRecord(metadata)) { + return null + } + + const value = metadata[ITEM_MOVE_VISUAL_METADATA_KEY] + if ( + value === 'carried' || + value === 'copy-source-pending' || + value === 'destination-ghost' || + value === 'destination-preview' || + value === 'source-pending' + ) { + return value + } + + return null +} + +export function setItemMoveVisualState( + metadata: unknown, + state: ItemMoveVisualState | null, +): Record { + const nextMetadata = isMetadataRecord(metadata) ? { ...metadata } : {} + + if (state) { + nextMetadata[ITEM_MOVE_VISUAL_METADATA_KEY] = state + return nextMetadata + } + + delete nextMetadata[ITEM_MOVE_VISUAL_METADATA_KEY] + return nextMetadata +} diff --git a/packages/editor/src/lib/navigation-performance.ts b/packages/editor/src/lib/navigation-performance.ts new file mode 100644 index 000000000..36805ad53 --- /dev/null +++ b/packages/editor/src/lib/navigation-performance.ts @@ -0,0 +1,17 @@ +'use client' + +export function recordNavigationPerfSample( + _name: string, + _ms: number, + _meta?: Record, +) {} + +export function measureNavigationPerf(_name: string, run: () => T): T { + return run() +} + +export function recordNavigationPerfMark(_name: string, _meta?: Record) {} + +export function mergeNavigationPerfMeta(_meta: Record) {} + +export function resetNavigationPerf() {} diff --git a/packages/editor/src/lib/navigation.ts b/packages/editor/src/lib/navigation.ts new file mode 100644 index 000000000..5bef2cfaa --- /dev/null +++ b/packages/editor/src/lib/navigation.ts @@ -0,0 +1,2823 @@ +'use client' + +import { + type AnyNode, + type BuildingNode, + type CeilingNode, + calculateLevelMiters, + getScaledDimensions, + getWallPlanFootprint, + type ItemNode, + type LevelNode, + type Point2D, + type SlabNode, + type StairNode, + type StairSegmentNode, + type WallNode, +} from '@pascal-app/core' +import { measureNavigationPerf } from './navigation-performance' +import { + buildWalkableStairSurfaceEntries, + buildWalkableSurfaceOverlay, + collectLevelDescendants, + getDoorPortalPolygon, + getItemPlanTransform, + getRotatedRectanglePolygon, + getSlabSurfaceY, + getWallAttachedItemDoorOpening, + isFloorBlockingItem, + isPointInsideDoorPortal, + toWalkablePlanPolygon, + WALKABLE_CELL_SIZE, + WALKABLE_CLEARANCE, + type WalkableSurfaceCell, + type WallOverlayDebugCell, +} from './walkable-surface' + +const DEFAULT_LEVEL_HEIGHT = 2.5 +const NAV_MAX_STEP_HEIGHT = 0.4 +const NAV_NEIGHBOR_RADIUS = 1 +const NAV_SNAP_RADIUS_CELLS = 2 +const NAV_STAIR_TRANSITION_RADIUS_CELLS = 7 +const NAV_STAIR_TRANSITION_MAX_HORIZONTAL_DISTANCE = 1.5 +const NAV_STAIR_TOP_HEIGHT_TOLERANCE = 0.45 +const NAV_LINE_OF_SIGHT_SAMPLE_STEP = WALKABLE_CELL_SIZE * 0.45 +const NAV_LINE_OF_SIGHT_SEARCH_RADIUS_CELLS = 1 +const NAV_LINE_OF_SIGHT_HEIGHT_TOLERANCE = Math.max(0.22, WALKABLE_CELL_SIZE * 1.15) +const NAV_PORTAL_RELIEF_EPSILON = WALKABLE_CELL_SIZE * 0.08 + +// The walkable surface is already eroded by this radius, so valid nav points are +// valid robot-center positions for this footprint. +export const NAVIGATION_AGENT_RADIUS = WALKABLE_CLEARANCE + +const NAV_DOOR_GROUP_AXIS_ALIGNMENT_DOT = 0.98 +const NAV_DOOR_GROUP_GAP_TOLERANCE = Math.max( + WALKABLE_CELL_SIZE * 0.75, + NAVIGATION_AGENT_RADIUS * 0.9, +) +const NAV_DOOR_ENTRY_OFFSET = Math.max(WALKABLE_CELL_SIZE * 0.9, NAVIGATION_AGENT_RADIUS * 1.05) + +export type NavigationCell = { + cellIndex: number + center: [number, number, number] + cornerHeights: [number, number, number, number] + gridX: number + gridY: number + levelId: LevelNode['id'] + localCenter: Point2D + surfaceType: 'floor' | 'stair' +} + +type NavigationCellSeed = Omit + +export type NavigationGraph = { + adjacency: number[][] + cellSize: number + cells: NavigationCell[] + cellsByLevel: Map + cellIndicesByKey: Map + collisionByLevel: Map + componentIdByCell: Int32Array + components: number[][] + doorBridgeEdgeCount: number + doorBridgeEdges: NavigationDoorBridgeEdge[] + doorOpenings: NavigationDoorOpening[] + doorPortals: NavigationDoorPortal[] + doorPortalCount: number + largestComponentId: number + largestComponentSize: number + levelBaseYById: Map + obstacleBlockedCellsByLevel: Map + stairTransitionEdgeCount: number + stairSurfaceCount: number + wallDebugCellsByLevel: Map + wallBlockedCellsByLevel: Map + walkableCellCount: number +} + +export type NavigationPathResult = { + cost: number + elapsedMs: number + indices: number[] +} + +type NavigationLevelResult = { + cells: NavigationCell[] + collision: NavigationCollisionLevel + doorPortals: NavigationDoorPortal[] + doorPortalCount: number + obstacleBlockedCells: NavigationCellSeed[] + stairSurfaceCount: number + wallDebugCells: WallOverlayDebugCell[] + wallBlockedCells: WalkableSurfaceCell[] + walkableCellCount: number +} + +type NavigationBuildOptions = { + includeDoorPortals?: boolean +} + +export type NavigationDoorPortal = { + center: Point2D + depthAxis: Point2D + doorId: string + halfDepth: number + halfWidth: number + levelId: LevelNode['id'] + openingId: string + passageHalfDepth: number + polygon: Point2D[] + wallId: string + widthAxis: Point2D +} + +export type NavigationDoorOpening = { + center: Point2D + depthAxis: Point2D + doorIds: string[] + halfDepth: number + halfWidth: number + levelId: LevelNode['id'] + openingId: string + passageHalfDepth: number + polygon: Point2D[] + wallId: string + widthAxis: Point2D +} + +export type NavigationDoorBridgeEdge = { + cellIndexA: number + cellIndexB: number + doorId: string + openingId: string +} + +export type NavigationDoorTransition = { + approachWorld: [number, number, number] + departureWorld: [number, number, number] + doorIds: string[] + entryWorld: [number, number, number] + exitWorld: [number, number, number] + fromCellIndex: number + fromPathIndex: number + openingId: string + pathPosition: number + progress: number + toCellIndex: number + toPathIndex: number + world: [number, number, number] +} + +export type NavigationCollisionPolygonSample = { + bounds: { maxX: number; maxY: number; minX: number; minY: number } + levelId: LevelNode['id'] + polygon: Point2D[] + sourceId: string + wallId?: string +} + +export type NavigationCollisionLevel = { + obstacleSamples: NavigationCollisionPolygonSample[] + portalSamples: NavigationCollisionPolygonSample[] + wallSamples: NavigationCollisionPolygonSample[] +} + +export type NavigationPointBlockers = { + obstacleIds: string[] + wallIds: string[] +} + +type SearchState = { + cameFrom: Int32Array + closed: Uint8Array + fScore: Float64Array + gScore: Float64Array +} + +type NavigationPathCellSample = { + cellIndex: number + cumulativeDistance: number + pathPosition: number +} + +type NavigationPathSegmentSample = { + cumulativeDistance: number + fromCellIndex: number + length: number + pathPosition: number + toCellIndex: number +} + +class MinHeap { + private heap: Array<{ node: number; score: number }> = [] + + get size() { + return this.heap.length + } + + push(node: number, score: number) { + this.heap.push({ node, score }) + this.bubbleUp(this.heap.length - 1) + } + + pop() { + if (this.heap.length === 0) { + return null + } + + const first = this.heap[0] + const last = this.heap.pop() + + if (last && this.heap.length > 0) { + this.heap[0] = last + this.bubbleDown(0) + } + + return first ?? null + } + + private bubbleUp(index: number) { + while (index > 0) { + const parentIndex = Math.floor((index - 1) / 2) + const entry = this.heap[index] + const parent = this.heap[parentIndex] + + if (!(entry && parent) || entry.score >= parent.score) { + break + } + + this.heap[index] = parent + this.heap[parentIndex] = entry + index = parentIndex + } + } + + private bubbleDown(index: number) { + const length = this.heap.length + + while (true) { + const leftIndex = index * 2 + 1 + const rightIndex = leftIndex + 1 + let smallestIndex = index + + const current = this.heap[smallestIndex] + const left = this.heap[leftIndex] + const right = this.heap[rightIndex] + + if (left && current && left.score < current.score) { + smallestIndex = leftIndex + } + + const smallest = this.heap[smallestIndex] + if (right && smallest && right.score < smallest.score) { + smallestIndex = rightIndex + } + + if (smallestIndex === index) { + break + } + + const next = this.heap[smallestIndex] + if (!(current && next)) { + break + } + + this.heap[index] = next + this.heap[smallestIndex] = current + index = smallestIndex + } + } +} + +function getApproxLevelHeight(level: LevelNode, nodes: Record): number { + let maxTop = 0 + + for (const childId of level.children) { + const child = nodes[childId] + if (!child) { + continue + } + + if (child.type === 'ceiling') { + maxTop = Math.max(maxTop, (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT) + continue + } + + if (child.type === 'wall') { + maxTop = Math.max(maxTop, child.height ?? DEFAULT_LEVEL_HEIGHT) + } + } + + return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT +} + +function getTargetBuilding( + nodes: Record, + rootNodeIds: string[], + buildingId?: BuildingNode['id'] | null, +): BuildingNode | null { + if (buildingId) { + const explicitBuilding = nodes[buildingId] + if (explicitBuilding?.type === 'building') { + return explicitBuilding + } + } + + const rootNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null + if (rootNode?.type === 'site') { + const firstBuilding = rootNode.children + .map((child) => (typeof child === 'string' ? nodes[child] : child)) + .find((node): node is BuildingNode => node?.type === 'building') + + return firstBuilding ?? null + } + + return ( + Object.values(nodes).find((node): node is BuildingNode => node?.type === 'building') ?? null + ) +} + +function getSortedBuildingLevels( + nodes: Record, + rootNodeIds: string[], + buildingId?: BuildingNode['id'] | null, +): LevelNode[] { + const building = getTargetBuilding(nodes, rootNodeIds, buildingId) + if (!building) { + return [] + } + + return building.children + .map((childId) => nodes[childId]) + .filter((node): node is LevelNode => node?.type === 'level') + .sort((left, right) => left.level - right.level) +} + +function getLevelBaseYById(levels: LevelNode[], nodes: Record) { + const levelBaseYById = new Map() + let cumulativeY = 0 + + for (const level of levels) { + levelBaseYById.set(level.id, cumulativeY) + cumulativeY += getApproxLevelHeight(level, nodes) + } + + return levelBaseYById +} + +function getLevelNavigationResult( + level: LevelNode, + nodes: Record, + levelBaseY: number, + options: NavigationBuildOptions = {}, +): NavigationLevelResult { + const includeDoorPortals = options.includeDoorPortals ?? true + const walls = level.children + .map((childId) => nodes[childId]) + .filter((node): node is WallNode => node?.type === 'wall') + const slabs = level.children + .map((childId) => nodes[childId]) + .filter((node): node is SlabNode => node?.type === 'slab') + const levelDescendantNodes = measureNavigationPerf('navigation.build.levelDescendantsMs', () => + collectLevelDescendants(level, nodes), + ) + const levelDescendantNodeById = new Map( + levelDescendantNodes.map((node) => [node.id, node] as const), + ) + const wallById = new Map(walls.map((wall) => [wall.id, wall] as const)) + const wallMiterData = calculateLevelMiters(walls) + const wallSamples = measureNavigationPerf('navigation.build.wallSamplesMs', () => + walls.flatMap((wall) => { + const polygon = getWallPlanFootprint(wall, wallMiterData) + return polygon.length >= 3 + ? [ + { + bounds: getPolygonBounds(polygon), + levelId: level.id, + polygon, + sourceId: wall.id, + wallId: wall.id, + } satisfies NavigationCollisionPolygonSample, + ] + : [] + }), + ) + const wallPolygons = wallSamples.map(({ polygon }) => polygon) + const slabPolygons = measureNavigationPerf('navigation.build.slabPolygonsMs', () => + slabs.flatMap((slab) => { + const polygon = toWalkablePlanPolygon(slab.polygon) + if (polygon.length < 3) { + return [] + } + + const holes = (slab.holes ?? []) + .map((hole) => toWalkablePlanPolygon(hole)) + .filter((hole) => hole.length >= 3) + + return [ + { + polygon, + holes, + surfaceY: getSlabSurfaceY(slab), + }, + ] + }), + ) + const stairSurfacePolygons = measureNavigationPerf( + 'navigation.build.stairSurfacePolygonsMs', + () => + levelDescendantNodes.flatMap((node) => { + if (node.type !== 'stair' || node.visible === false) { + return [] + } + + const segments = (node.children ?? []) + .map((childId) => levelDescendantNodeById.get(childId)) + .filter( + (childNode): childNode is StairSegmentNode => + childNode?.type === 'stair-segment' && childNode.visible !== false, + ) + + return buildWalkableStairSurfaceEntries(node as StairNode, segments) + }), + ) + const itemTransformCache = new Map>() + const doorPortalPolygons = measureNavigationPerf('navigation.build.doorPortalPolygonsMs', () => + includeDoorPortals + ? levelDescendantNodes.flatMap((node) => { + if (node.visible === false || !node.parentId) { + return [] + } + + const wall = wallById.get(node.parentId as WallNode['id']) + if (!wall) { + return [] + } + + const opening = + node.type === 'door' + ? node + : node.type === 'item' + ? getWallAttachedItemDoorOpening( + node as ItemNode, + wall, + levelDescendantNodeById, + itemTransformCache, + ) + : null + if (!opening) { + return [] + } + + const polygon = getDoorPortalPolygon(wall, opening, WALKABLE_CLEARANCE) + return polygon.length >= 3 + ? [ + { + doorId: node.id, + polygon, + wallId: wall.id, + }, + ] + : [] + }) + : [], + ) + const portalSamples = measureNavigationPerf('navigation.build.portalSamplesMs', () => + doorPortalPolygons + .map(({ doorId, polygon, wallId }) => ({ + bounds: getPolygonBounds(polygon), + levelId: level.id, + polygon, + sourceId: doorId, + wallId, + })) + .filter( + ({ bounds, polygon }) => + polygon.length >= 3 && + Number.isFinite(bounds.minX) && + Number.isFinite(bounds.maxX) && + Number.isFinite(bounds.minY) && + Number.isFinite(bounds.maxY), + ), + ) + const doorPortals = measureNavigationPerf('navigation.build.doorPortalsMs', () => + doorPortalPolygons.flatMap(({ doorId, polygon, wallId }) => { + const first = polygon[0] + const second = polygon[1] + const third = polygon[2] + if (!(first && second && third)) { + return [] + } + + const widthVector = { + x: second.x - first.x, + y: second.y - first.y, + } + const depthVector = { + x: third.x - second.x, + y: third.y - second.y, + } + const widthLength = Math.hypot(widthVector.x, widthVector.y) + const depthLength = Math.hypot(depthVector.x, depthVector.y) + + if (widthLength <= Number.EPSILON || depthLength <= Number.EPSILON) { + return [] + } + + return [ + { + center: { + x: polygon.reduce((sum, point) => sum + point.x, 0) / polygon.length, + y: polygon.reduce((sum, point) => sum + point.y, 0) / polygon.length, + }, + depthAxis: { + x: depthVector.x / depthLength, + y: depthVector.y / depthLength, + }, + doorId, + halfDepth: depthLength / 2, + halfWidth: widthLength / 2, + levelId: level.id, + openingId: doorId, + passageHalfDepth: Math.max( + (wallById.get(wallId)?.thickness ?? 0.1) / 2, + WALKABLE_CELL_SIZE * 0.25, + ), + polygon, + wallId, + widthAxis: { + x: widthVector.x / widthLength, + y: widthVector.y / widthLength, + }, + }, + ] + }), + ) + const obstacleSamples = measureNavigationPerf('navigation.build.obstacleSamplesMs', () => + levelDescendantNodes.flatMap((node) => { + if ( + node.type !== 'item' || + node.visible === false || + node.asset.category === 'door' || + node.asset.category === 'window' || + !isFloorBlockingItem(node as ItemNode, levelDescendantNodeById) + ) { + return [] + } + + const transform = getItemPlanTransform( + node as ItemNode, + levelDescendantNodeById, + itemTransformCache, + ) + if (!transform) { + return [] + } + + const [width, , depth] = getScaledDimensions(node as ItemNode) + const polygon = getRotatedRectanglePolygon( + transform.position, + width, + depth, + transform.rotation, + ) + + return polygon.length >= 3 + ? [ + { + bounds: getPolygonBounds(polygon), + levelId: level.id, + polygon, + sourceId: node.id, + } satisfies NavigationCollisionPolygonSample, + ] + : [] + }), + ) + const obstaclePolygons = obstacleSamples.map(({ polygon }) => polygon) + + const overlay = measureNavigationPerf('navigation.build.walkableOverlayMs', () => + buildWalkableSurfaceOverlay( + [...slabPolygons, ...stairSurfacePolygons], + wallPolygons, + obstaclePolygons, + WALKABLE_CELL_SIZE, + WALKABLE_CLEARANCE, + doorPortalPolygons.map(({ polygon }) => polygon), + ), + ) + + if (!overlay) { + return { + cells: [], + collision: { + obstacleSamples, + portalSamples, + wallSamples, + }, + doorPortals, + doorPortalCount: doorPortalPolygons.length, + obstacleBlockedCells: [], + stairSurfaceCount: stairSurfacePolygons.length, + wallDebugCells: [], + wallBlockedCells: [], + walkableCellCount: 0, + } + } + + const createNavigationCellSeed = (cell: WalkableSurfaceCell): NavigationCellSeed => { + const localCenter = { + x: cell.x + cell.width / 2, + y: cell.y + cell.height / 2, + } + + return { + center: [localCenter.x, levelBaseY + cell.surfaceY, localCenter.y] as [ + number, + number, + number, + ], + cornerHeights: [ + levelBaseY + cell.cornerSurfaceY[0], + levelBaseY + cell.cornerSurfaceY[1], + levelBaseY + cell.cornerSurfaceY[2], + levelBaseY + cell.cornerSurfaceY[3], + ] as [number, number, number, number], + gridX: Math.round(cell.x / WALKABLE_CELL_SIZE), + gridY: Math.round(cell.y / WALKABLE_CELL_SIZE), + levelId: level.id, + localCenter, + surfaceType: (stairSurfacePolygons.some(({ polygon }) => + isPointInsidePolygon(localCenter, polygon), + ) + ? 'stair' + : 'floor') as NavigationCell['surfaceType'], + } + } + + const cells: NavigationCell[] = measureNavigationPerf('navigation.build.levelCellsMs', () => + overlay.cells.map((cell, cellOffset) => ({ + ...createNavigationCellSeed(cell), + cellIndex: cellOffset, + })), + ) + const obstacleBlockedCells = measureNavigationPerf( + 'navigation.build.obstacleBlockedCellsMs', + () => overlay.obstacleBlockedCells.map(createNavigationCellSeed), + ) + + return { + cells, + collision: { + obstacleSamples, + portalSamples, + wallSamples, + }, + doorPortals, + doorPortalCount: doorPortalPolygons.length, + obstacleBlockedCells, + stairSurfaceCount: stairSurfacePolygons.length, + wallDebugCells: overlay.wallDebugCells, + wallBlockedCells: overlay.wallBlockedCells, + walkableCellCount: overlay.cellCount, + } +} + +function getCellDistance(a: NavigationCell, b: NavigationCell) { + return Math.hypot(b.center[0] - a.center[0], b.center[1] - a.center[1], b.center[2] - a.center[2]) +} + +type NavigationSegmentAppendOptions = { + endWorldAnchor?: [number, number, number] + startWorldAnchor?: [number, number, number] +} + +function buildNavigationPathSamples(graph: NavigationGraph, pathIndices: number[]) { + const cells: NavigationPathCellSample[] = [] + const segments: NavigationPathSegmentSample[] = [] + let cumulativeDistance = 0 + + for (let index = 0; index < pathIndices.length - 1; index += 1) { + const fromCellIndex = pathIndices[index] + const toCellIndex = pathIndices[index + 1] + if (fromCellIndex === undefined || toCellIndex === undefined) { + continue + } + + if (cells.length === 0) { + cells.push({ + cellIndex: fromCellIndex, + cumulativeDistance: 0, + pathPosition: index, + }) + } + + const fromCell = graph.cells[fromCellIndex] + const toCell = graph.cells[toCellIndex] + if (!(fromCell && toCell)) { + continue + } + + const length = getCellDistance(fromCell, toCell) + if (length <= Number.EPSILON) { + continue + } + + segments.push({ + cumulativeDistance, + fromCellIndex, + length, + pathPosition: index + 0.5, + toCellIndex, + }) + cumulativeDistance += length + cells.push({ + cellIndex: toCellIndex, + cumulativeDistance, + pathPosition: index + 1, + }) + } + + return { + cells, + segments, + totalLength: cumulativeDistance, + } +} + +function getPolygonBounds(points: Point2D[]) { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const point of points) { + minX = Math.min(minX, point.x) + maxX = Math.max(maxX, point.x) + minY = Math.min(minY, point.y) + maxY = Math.max(maxY, point.y) + } + + return { + minX, + maxX, + minY, + maxY, + } +} + +function isPointInsideBounds( + point: Point2D, + bounds: { minX: number; maxX: number; minY: number; maxY: number }, + margin = 0, +) { + return ( + point.x >= bounds.minX - margin && + point.x <= bounds.maxX + margin && + point.y >= bounds.minY - margin && + point.y <= bounds.maxY + margin + ) +} + +function isPointInsidePolygon(point: Point2D, polygon: Point2D[]) { + let inside = false + + for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) { + const current = polygon[index] + const prior = polygon[previous] + + if (!(current && prior)) { + continue + } + + const intersects = + current.y > point.y !== prior.y > point.y && + point.x < ((prior.x - current.x) * (point.y - current.y)) / (prior.y - current.y) + current.x + + if (intersects) { + inside = !inside + } + } + + return inside +} + +function getDistanceToLineSegment(point: Point2D, start: Point2D, end: Point2D): number { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + + if (lengthSquared <= Number.EPSILON) { + return Math.hypot(point.x - start.x, point.y - start.y) + } + + const projection = Math.max( + 0, + Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared), + ) + + return Math.hypot(point.x - (start.x + dx * projection), point.y - (start.y + dy * projection)) +} + +function getPolygonBoundaryDistance(point: Point2D, polygon: Point2D[]): number { + if (polygon.length === 0) { + return Number.POSITIVE_INFINITY + } + + let minDistance = Number.POSITIVE_INFINITY + + for (let index = 0; index < polygon.length; index += 1) { + const start = polygon[index] + const end = polygon[(index + 1) % polygon.length] + + if (!(start && end)) { + continue + } + + minDistance = Math.min(minDistance, getDistanceToLineSegment(point, start, end)) + } + + return minDistance +} + +function getBlockingCollisionSampleIds( + point: Point2D, + radius: number, + samples: NavigationCollisionPolygonSample[], +) { + const ids: string[] = [] + + for (const sample of samples) { + if (!isCollisionSampleBlockingPoint(point, radius, sample)) { + continue + } + + ids.push(sample.sourceId) + } + + return ids +} + +function isCollisionSampleBlockingPoint( + point: Point2D, + radius: number, + sample: NavigationCollisionPolygonSample, +) { + return ( + isPointInsideBounds(point, sample.bounds, radius) && + (isPointInsidePolygon(point, sample.polygon) || + getPolygonBoundaryDistance(point, sample.polygon) < radius) + ) +} + +function getOpenPortalWallIdsAtPoint(collision: NavigationCollisionLevel, point: Point2D) { + let openWallIds: Set | null = null + + for (const portalSample of collision.portalSamples) { + const wallId = portalSample.wallId + if ( + !wallId || + !isPointInsideBounds(point, portalSample.bounds, NAV_PORTAL_RELIEF_EPSILON) || + !isPointInsideDoorPortal(point, portalSample.polygon, { + depthEpsilon: NAV_PORTAL_RELIEF_EPSILON, + }) + ) { + continue + } + + if (!openWallIds) { + openWallIds = new Set() + } + + openWallIds.add(wallId) + } + + return openWallIds +} + +function hasBlockingCollisionSample( + point: Point2D, + radius: number, + samples: NavigationCollisionPolygonSample[], + openWallIds: Set | null = null, +) { + for (const sample of samples) { + if (sample.wallId && openWallIds?.has(sample.wallId)) { + continue + } + + if (isCollisionSampleBlockingPoint(point, radius, sample)) { + return true + } + } + + return false +} + +function hasNavigationPointBlockers( + graph: NavigationGraph, + point: [number, number, number], + levelId: LevelNode['id'] | null, + radius = NAVIGATION_AGENT_RADIUS, +) { + if (!levelId) { + return false + } + + const collision = graph.collisionByLevel.get(levelId) + if (!collision) { + return false + } + + const planPoint = { + x: point[0], + y: point[2], + } + const openWallIds = getOpenPortalWallIdsAtPoint(collision, planPoint) + + return ( + hasBlockingCollisionSample(planPoint, radius, collision.wallSamples, openWallIds) || + hasBlockingCollisionSample(planPoint, radius, collision.obstacleSamples) + ) +} + +export function getNavigationPointBlockers( + graph: NavigationGraph, + point: [number, number, number], + levelId: LevelNode['id'] | null, + radius = NAVIGATION_AGENT_RADIUS, +): NavigationPointBlockers { + if (!levelId) { + return { + obstacleIds: [], + wallIds: [], + } + } + + const collision = graph.collisionByLevel.get(levelId) + if (!collision) { + return { + obstacleIds: [], + wallIds: [], + } + } + + const planPoint = { + x: point[0], + y: point[2], + } + const openWallIds = getOpenPortalWallIdsAtPoint(collision, planPoint) + const wallIds: string[] = [] + + for (const wallSample of collision.wallSamples) { + if (wallSample.wallId && openWallIds?.has(wallSample.wallId)) { + continue + } + + if (!isCollisionSampleBlockingPoint(planPoint, radius, wallSample)) { + continue + } + + wallIds.push(wallSample.sourceId) + } + + const obstacleIds = getBlockingCollisionSampleIds(planPoint, radius, collision.obstacleSamples) + + return { + obstacleIds, + wallIds, + } +} + +function getCellKey(gridX: number, gridY: number) { + return `${gridX},${gridY}` +} + +function getCellBounds(cell: NavigationCell, cellSize: number) { + const halfCell = cellSize / 2 + return { + maxX: cell.center[0] + halfCell, + maxZ: cell.center[2] + halfCell, + minX: cell.center[0] - halfCell, + minZ: cell.center[2] - halfCell, + } +} + +function getCellSurfaceHeightAtPoint( + cell: NavigationCell, + pointX: number, + pointZ: number, + cellSize: number, +) { + const bounds = getCellBounds(cell, cellSize) + const u = Math.max(0, Math.min(1, (pointX - bounds.minX) / cellSize)) + const v = Math.max(0, Math.min(1, (pointZ - bounds.minZ) / cellSize)) + const [h00, h10, h11, h01] = cell.cornerHeights + + return h00 * (1 - u) * (1 - v) + h10 * u * (1 - v) + h11 * u * v + h01 * (1 - u) * v +} + +function dotPlan(a: Point2D, b: Point2D) { + return a.x * b.x + a.y * b.y +} + +function buildDoorOpeningPolygon( + center: Point2D, + widthAxis: Point2D, + depthAxis: Point2D, + halfWidth: number, + halfDepth: number, +): Point2D[] { + return [ + { + x: center.x - widthAxis.x * halfWidth + depthAxis.x * halfDepth, + y: center.y - widthAxis.y * halfWidth + depthAxis.y * halfDepth, + }, + { + x: center.x + widthAxis.x * halfWidth + depthAxis.x * halfDepth, + y: center.y + widthAxis.y * halfWidth + depthAxis.y * halfDepth, + }, + { + x: center.x + widthAxis.x * halfWidth - depthAxis.x * halfDepth, + y: center.y + widthAxis.y * halfWidth - depthAxis.y * halfDepth, + }, + { + x: center.x - widthAxis.x * halfWidth - depthAxis.x * halfDepth, + y: center.y - widthAxis.y * halfWidth - depthAxis.y * halfDepth, + }, + ] +} + +function groupDoorPortals(doorPortals: NavigationDoorPortal[]) { + if (doorPortals.length === 0) { + return { + doorOpenings: [] as NavigationDoorOpening[], + groupedDoorPortals: [] as NavigationDoorPortal[], + } + } + + const groupedDoorPortals: NavigationDoorPortal[] = [] + const doorOpenings: NavigationDoorOpening[] = [] + const portalsByWall = new Map() + + for (const doorPortal of doorPortals) { + const key = `${doorPortal.levelId}:${doorPortal.wallId}` + const bucket = portalsByWall.get(key) + if (bucket) { + bucket.push(doorPortal) + } else { + portalsByWall.set(key, [doorPortal]) + } + } + + for (const wallPortals of portalsByWall.values()) { + const referencePortal = wallPortals[0] + if (!referencePortal) { + continue + } + + const widthAxis = referencePortal.widthAxis + const depthAxis = referencePortal.depthAxis + const origin = referencePortal.center + const sortedPortals = [...wallPortals] + .map((portal) => { + const localOffset = { + x: portal.center.x - origin.x, + y: portal.center.y - origin.y, + } + + return { + portal, + depthDot: Math.abs(dotPlan(portal.depthAxis, depthAxis)), + depthMin: dotPlan(localOffset, depthAxis) - portal.halfDepth, + depthMax: dotPlan(localOffset, depthAxis) + portal.halfDepth, + widthDot: Math.abs(dotPlan(portal.widthAxis, widthAxis)), + widthMin: dotPlan(localOffset, widthAxis) - portal.halfWidth, + widthMax: dotPlan(localOffset, widthAxis) + portal.halfWidth, + } + }) + .sort((left, right) => left.widthMin - right.widthMin) + + let activeGroup: typeof sortedPortals = [] + let groupWidthMin = 0 + let groupWidthMax = 0 + let groupDepthMin = 0 + let groupDepthMax = 0 + + const flushActiveGroup = () => { + if (activeGroup.length === 0) { + return + } + + const doorIds = activeGroup.map(({ portal }) => portal.doorId) + const centerWidth = (groupWidthMin + groupWidthMax) / 2 + const centerDepth = (groupDepthMin + groupDepthMax) / 2 + const center = { + x: origin.x + widthAxis.x * centerWidth + depthAxis.x * centerDepth, + y: origin.y + widthAxis.y * centerWidth + depthAxis.y * centerDepth, + } + const openingId = doorIds.join('|') + const opening: NavigationDoorOpening = { + center, + depthAxis, + doorIds, + halfDepth: Math.max(WALKABLE_CELL_SIZE, (groupDepthMax - groupDepthMin) / 2), + halfWidth: Math.max(WALKABLE_CELL_SIZE, (groupWidthMax - groupWidthMin) / 2), + levelId: referencePortal.levelId, + openingId, + passageHalfDepth: Math.max( + ...activeGroup.map(({ portal }) => portal.passageHalfDepth), + WALKABLE_CELL_SIZE * 0.25, + ), + polygon: buildDoorOpeningPolygon( + center, + widthAxis, + depthAxis, + Math.max(WALKABLE_CELL_SIZE, (groupWidthMax - groupWidthMin) / 2), + Math.max(WALKABLE_CELL_SIZE, (groupDepthMax - groupDepthMin) / 2), + ), + wallId: referencePortal.wallId, + widthAxis, + } + + doorOpenings.push(opening) + groupedDoorPortals.push( + ...activeGroup.map(({ portal }) => ({ + ...portal, + openingId, + })), + ) + + activeGroup = [] + } + + for (const candidate of sortedPortals) { + const startsNewGroup = + activeGroup.length === 0 || + candidate.widthDot < NAV_DOOR_GROUP_AXIS_ALIGNMENT_DOT || + candidate.depthDot < NAV_DOOR_GROUP_AXIS_ALIGNMENT_DOT || + candidate.widthMin - groupWidthMax > NAV_DOOR_GROUP_GAP_TOLERANCE || + candidate.depthMin - groupDepthMax > WALKABLE_CELL_SIZE * 0.5 || + groupDepthMin - candidate.depthMax > WALKABLE_CELL_SIZE * 0.5 + + if (startsNewGroup) { + flushActiveGroup() + activeGroup = [candidate] + groupWidthMin = candidate.widthMin + groupWidthMax = candidate.widthMax + groupDepthMin = candidate.depthMin + groupDepthMax = candidate.depthMax + continue + } + + activeGroup.push(candidate) + groupWidthMin = Math.min(groupWidthMin, candidate.widthMin) + groupWidthMax = Math.max(groupWidthMax, candidate.widthMax) + groupDepthMin = Math.min(groupDepthMin, candidate.depthMin) + groupDepthMax = Math.max(groupDepthMax, candidate.depthMax) + } + + flushActiveGroup() + } + + return { + doorOpenings, + groupedDoorPortals, + } +} + +function hasSupportingNavigationCellAtPoint( + graph: NavigationGraph, + point: [number, number, number], + componentId: number | null = null, +) { + const [x, y, z] = point + const gridX = Math.round((x - graph.cellSize / 2) / graph.cellSize) + const gridY = Math.round((z - graph.cellSize / 2) / graph.cellSize) + const cellBoundsTolerance = graph.cellSize * 0.08 + const pointClearByLevelId = new Map() + + for ( + let offsetX = -NAV_LINE_OF_SIGHT_SEARCH_RADIUS_CELLS; + offsetX <= NAV_LINE_OF_SIGHT_SEARCH_RADIUS_CELLS; + offsetX += 1 + ) { + for ( + let offsetY = -NAV_LINE_OF_SIGHT_SEARCH_RADIUS_CELLS; + offsetY <= NAV_LINE_OF_SIGHT_SEARCH_RADIUS_CELLS; + offsetY += 1 + ) { + const candidateIndices = graph.cellIndicesByKey.get( + getCellKey(gridX + offsetX, gridY + offsetY), + ) + if (!candidateIndices) { + continue + } + + for (const candidateIndex of candidateIndices) { + const candidate = graph.cells[candidateIndex] + if (!candidate) { + continue + } + + if ( + componentId !== null && + componentId !== undefined && + graph.componentIdByCell[candidateIndex] !== componentId + ) { + continue + } + + const bounds = getCellBounds(candidate, graph.cellSize) + if ( + x < bounds.minX - cellBoundsTolerance || + x > bounds.maxX + cellBoundsTolerance || + z < bounds.minZ - cellBoundsTolerance || + z > bounds.maxZ + cellBoundsTolerance + ) { + continue + } + + const surfaceHeight = getCellSurfaceHeightAtPoint(candidate, x, z, graph.cellSize) + if (Math.abs(surfaceHeight - y) <= NAV_LINE_OF_SIGHT_HEIGHT_TOLERANCE) { + let isPointClear = pointClearByLevelId.get(candidate.levelId) + if (isPointClear === undefined) { + isPointClear = !hasNavigationPointBlockers(graph, point, candidate.levelId) + pointClearByLevelId.set(candidate.levelId, isPointClear) + } + + if (isPointClear) { + return true + } + } + } + } + } + + return false +} + +export function isNavigationPointSupported( + graph: NavigationGraph, + point: [number, number, number], + componentId: number | null = null, +) { + return hasSupportingNavigationCellAtPoint(graph, point, componentId) +} + +function hasNavigationWorldLineOfSight( + graph: NavigationGraph, + startPoint: [number, number, number], + endPoint: [number, number, number], + componentId: number | null = null, +) { + const distance = Math.hypot( + endPoint[0] - startPoint[0], + endPoint[1] - startPoint[1], + endPoint[2] - startPoint[2], + ) + const sampleCount = Math.max(2, Math.ceil(distance / NAV_LINE_OF_SIGHT_SAMPLE_STEP)) + + for (let sampleIndex = 0; sampleIndex <= sampleCount; sampleIndex += 1) { + const t = sampleIndex / sampleCount + const samplePoint: [number, number, number] = [ + startPoint[0] + (endPoint[0] - startPoint[0]) * t, + startPoint[1] + (endPoint[1] - startPoint[1]) * t, + startPoint[2] + (endPoint[2] - startPoint[2]) * t, + ] + + if (!isNavigationPointSupported(graph, samplePoint, componentId)) { + return false + } + } + + return true +} + +function hasNavigationLineOfSight( + graph: NavigationGraph, + startCellIndex: number, + endCellIndex: number, +) { + const startCell = graph.cells[startCellIndex] + const endCell = graph.cells[endCellIndex] + if (!(startCell && endCell)) { + return false + } + + const componentId = graph.componentIdByCell[startCellIndex] + if (componentId === undefined) { + return false + } + + if (componentId !== graph.componentIdByCell[endCellIndex]) { + return false + } + + return hasNavigationWorldLineOfSight(graph, startCell.center, endCell.center, componentId) +} + +function hasSupportCellForDiagonal( + sourceCell: NavigationCell, + gridX: number, + gridY: number, + cellIndicesByKey: Map, + cells: NavigationCell[], +) { + const bucket = cellIndicesByKey.get(getCellKey(gridX, gridY)) + if (!bucket) { + return false + } + + return bucket.some((candidateIndex) => { + const candidate = cells[candidateIndex] + if (!candidate) { + return false + } + + if (candidate.levelId !== sourceCell.levelId) { + return false + } + + return Math.abs(candidate.center[1] - sourceCell.center[1]) <= NAV_MAX_STEP_HEIGHT + }) +} + +function connectNavigationCellNeighbors( + cell: NavigationCell, + adjacency: number[][], + cellIndicesByKey: Map, + cells: NavigationCell[], +) { + for (let offsetX = -NAV_NEIGHBOR_RADIUS; offsetX <= NAV_NEIGHBOR_RADIUS; offsetX += 1) { + for (let offsetY = -NAV_NEIGHBOR_RADIUS; offsetY <= NAV_NEIGHBOR_RADIUS; offsetY += 1) { + if (offsetX === 0 && offsetY === 0) { + continue + } + + const neighborKey = getCellKey(cell.gridX + offsetX, cell.gridY + offsetY) + const bucket = cellIndicesByKey.get(neighborKey) + if (!bucket) { + continue + } + + if (offsetX !== 0 && offsetY !== 0) { + const hasHorizontalSupport = hasSupportCellForDiagonal( + cell, + cell.gridX + offsetX, + cell.gridY, + cellIndicesByKey, + cells, + ) + const hasVerticalSupport = hasSupportCellForDiagonal( + cell, + cell.gridX, + cell.gridY + offsetY, + cellIndicesByKey, + cells, + ) + + if (!(hasHorizontalSupport && hasVerticalSupport)) { + continue + } + } + + for (const neighborIndex of bucket) { + if (neighborIndex === cell.cellIndex) { + continue + } + + const neighbor = cells[neighborIndex] + if (!neighbor) { + continue + } + + const verticalDelta = Math.abs(neighbor.center[1] - cell.center[1]) + if (verticalDelta > NAV_MAX_STEP_HEIGHT) { + continue + } + + const horizontalDelta = Math.hypot( + neighbor.center[0] - cell.center[0], + neighbor.center[2] - cell.center[2], + ) + if (horizontalDelta > WALKABLE_CELL_SIZE * Math.SQRT2 + 1e-6) { + continue + } + + const currentNeighbors = adjacency[cell.cellIndex] + const neighborNeighbors = adjacency[neighborIndex] + if (!(currentNeighbors && neighborNeighbors)) { + continue + } + + if (!currentNeighbors.includes(neighborIndex)) { + currentNeighbors.push(neighborIndex) + neighborNeighbors.push(cell.cellIndex) + } + } + } + } +} + +function connectDoorPortalCells( + adjacency: number[][], + cells: NavigationCell[], + doorPortals: NavigationDoorPortal[], + cellSize: number, +) { + let doorBridgeEdgeCount = 0 + const doorBridgeEdges: NavigationDoorBridgeEdge[] = [] + const sideThreshold = cellSize * 0.12 + const widthTolerance = cellSize * 1.25 + + for (const doorPortal of doorPortals) { + const bridgeEdgeCountBeforePortal = doorBridgeEdgeCount + const maxBridgeDistance = Math.max( + cellSize * 2.7, + Math.min(cellSize * 3.3, doorPortal.halfDepth * 1.25), + ) + const bounds = getPolygonBounds(doorPortal.polygon) + const candidateCells = cells + .filter((cell) => cell.levelId === doorPortal.levelId) + .flatMap((cell) => { + const centerPoint = { x: cell.center[0], y: cell.center[2] } + const isCandidate = + isPointInsideBounds(centerPoint, bounds, cellSize * 0.5) && + (isPointInsidePolygon(centerPoint, doorPortal.polygon) || + getPolygonBoundaryDistance(centerPoint, doorPortal.polygon) <= cellSize * 0.75) + + if (!isCandidate) { + return [] + } + + const localOffset = { + x: centerPoint.x - doorPortal.center.x, + y: centerPoint.y - doorPortal.center.y, + } + + return [ + { + cellIndex: cell.cellIndex, + depthCoord: + localOffset.x * doorPortal.depthAxis.x + localOffset.y * doorPortal.depthAxis.y, + widthCoord: + localOffset.x * doorPortal.widthAxis.x + localOffset.y * doorPortal.widthAxis.y, + }, + ] + }) + const negativeSideCells = candidateCells.filter((cell) => cell.depthCoord <= -sideThreshold) + const positiveSideCells = candidateCells.filter((cell) => cell.depthCoord >= sideThreshold) + + if (negativeSideCells.length === 0 || positiveSideCells.length === 0) { + continue + } + + const connectPair = (sourceIndex: number, neighborIndex: number) => { + const neighborNeighbors = adjacency[neighborIndex] + const currentNeighbors = adjacency[sourceIndex] + + if (!(currentNeighbors && neighborNeighbors)) { + return + } + + if (!currentNeighbors.includes(neighborIndex)) { + currentNeighbors.push(neighborIndex) + neighborNeighbors.push(sourceIndex) + doorBridgeEdgeCount += 1 + doorBridgeEdges.push({ + cellIndexA: Math.min(sourceIndex, neighborIndex), + cellIndexB: Math.max(sourceIndex, neighborIndex), + doorId: doorPortal.doorId, + openingId: doorPortal.openingId, + }) + } + } + + const bestCenterlinePair = negativeSideCells + .flatMap((source) => { + const currentCell = cells[source.cellIndex] + if (!currentCell) { + return [] + } + + return positiveSideCells + .map((target) => { + const neighborCell = cells[target.cellIndex] + if (!neighborCell) { + return null + } + + const planarDistance = Math.hypot( + neighborCell.center[0] - currentCell.center[0], + neighborCell.center[2] - currentCell.center[2], + ) + const verticalDelta = Math.abs(neighborCell.center[1] - currentCell.center[1]) + + if (verticalDelta > NAV_MAX_STEP_HEIGHT || planarDistance > maxBridgeDistance) { + return null + } + + const centerlineBias = Math.abs(source.widthCoord) + Math.abs(target.widthCoord) + const widthDelta = Math.abs(target.widthCoord - source.widthCoord) + const mirroredDepthDelta = Math.abs( + Math.abs(target.depthCoord) - Math.abs(source.depthCoord), + ) + + return { + neighborIndex: target.cellIndex, + score: + centerlineBias * 2.4 + + widthDelta * 0.75 + + mirroredDepthDelta * 0.7 + + planarDistance * 0.12, + sourceIndex: source.cellIndex, + } + }) + .filter( + ( + entry, + ): entry is { + neighborIndex: number + score: number + sourceIndex: number + } => Boolean(entry), + ) + }) + .sort((left, right) => left.score - right.score)[0] + + if (bestCenterlinePair) { + connectPair(bestCenterlinePair.sourceIndex, bestCenterlinePair.neighborIndex) + } + + if (doorBridgeEdgeCount !== bridgeEdgeCountBeforePortal) { + continue + } + + for (const source of negativeSideCells) { + const currentCell = cells[source.cellIndex] + if (!currentCell) { + continue + } + + const oppositeMatches = positiveSideCells + .filter((target) => Math.abs(target.widthCoord - source.widthCoord) <= widthTolerance) + .map((target) => { + const neighborCell = cells[target.cellIndex] + if (!neighborCell) { + return null + } + + const mirroredDepthDelta = Math.abs(target.depthCoord + source.depthCoord) + if (mirroredDepthDelta > cellSize * 0.9) { + return null + } + + const planarDistance = Math.hypot( + neighborCell.center[0] - currentCell.center[0], + neighborCell.center[2] - currentCell.center[2], + ) + const verticalDelta = Math.abs(neighborCell.center[1] - currentCell.center[1]) + + if (verticalDelta > NAV_MAX_STEP_HEIGHT || planarDistance > maxBridgeDistance) { + return null + } + + return { + cellIndex: target.cellIndex, + score: + Math.abs(target.widthCoord - source.widthCoord) + + mirroredDepthDelta * 0.35 + + planarDistance * 0.18, + } + }) + .filter((entry): entry is { cellIndex: number; score: number } => Boolean(entry)) + .sort((left, right) => left.score - right.score) + .slice(0, 2) + + for (const target of oppositeMatches) { + connectPair(source.cellIndex, target.cellIndex) + } + } + + if (doorBridgeEdgeCount !== bridgeEdgeCountBeforePortal) { + continue + } + + const fallbackPairCandidates = negativeSideCells + .flatMap((source) => { + const currentCell = cells[source.cellIndex] + if (!currentCell) { + return [] + } + + return positiveSideCells + .map((target) => { + const neighborCell = cells[target.cellIndex] + if (!neighborCell) { + return null + } + + const planarDistance = Math.hypot( + neighborCell.center[0] - currentCell.center[0], + neighborCell.center[2] - currentCell.center[2], + ) + const verticalDelta = Math.abs(neighborCell.center[1] - currentCell.center[1]) + + if (verticalDelta > NAV_MAX_STEP_HEIGHT || planarDistance > maxBridgeDistance) { + return null + } + + const widthDelta = Math.abs(target.widthCoord - source.widthCoord) + const mirroredDepthDelta = Math.abs( + Math.abs(target.depthCoord) - Math.abs(source.depthCoord), + ) + const centerlineBias = Math.abs(source.widthCoord) + Math.abs(target.widthCoord) + + return { + neighborIndex: target.cellIndex, + score: + widthDelta * 1.35 + + mirroredDepthDelta * 0.45 + + centerlineBias * 0.65 + + planarDistance * 0.12, + sourceIndex: source.cellIndex, + } + }) + .filter( + ( + entry, + ): entry is { + neighborIndex: number + score: number + sourceIndex: number + } => Boolean(entry), + ) + }) + .sort((left, right) => left.score - right.score) + + if (fallbackPairCandidates.length === 0) { + continue + } + + const usedSourceIndices = new Set() + const usedNeighborIndices = new Set() + const fallbackPairLimit = Math.max( + 1, + Math.min( + 2, + negativeSideCells.length, + positiveSideCells.length, + Math.round((doorPortal.halfWidth * 2) / cellSize), + ), + ) + + for (const pair of fallbackPairCandidates) { + if (usedSourceIndices.has(pair.sourceIndex) || usedNeighborIndices.has(pair.neighborIndex)) { + continue + } + + connectPair(pair.sourceIndex, pair.neighborIndex) + usedSourceIndices.add(pair.sourceIndex) + usedNeighborIndices.add(pair.neighborIndex) + + if (usedSourceIndices.size >= fallbackPairLimit) { + break + } + } + } + + return { + doorBridgeEdgeCount, + doorBridgeEdges, + } +} + +function connectStairTransitionCells( + adjacency: number[][], + cells: NavigationCell[], + cellIndicesByKey: Map, +) { + let stairTransitionEdgeCount = 0 + const stairTopHeightByLevel = new Map() + + for (const cell of cells) { + if (cell.surfaceType !== 'stair') { + continue + } + + const currentTopHeight = stairTopHeightByLevel.get(cell.levelId) ?? Number.NEGATIVE_INFINITY + if (cell.center[1] > currentTopHeight) { + stairTopHeightByLevel.set(cell.levelId, cell.center[1]) + } + } + + const connectPair = (sourceIndex: number, neighborIndex: number) => { + const currentNeighbors = adjacency[sourceIndex] + const neighborNeighbors = adjacency[neighborIndex] + + if (!(currentNeighbors && neighborNeighbors)) { + return + } + + if (!currentNeighbors.includes(neighborIndex)) { + currentNeighbors.push(neighborIndex) + neighborNeighbors.push(sourceIndex) + stairTransitionEdgeCount += 1 + } + } + + for (const cell of cells) { + if (cell.surfaceType !== 'stair') { + continue + } + + const levelTopHeight = stairTopHeightByLevel.get(cell.levelId) + if ( + levelTopHeight === undefined || + levelTopHeight - cell.center[1] > NAV_STAIR_TOP_HEIGHT_TOLERANCE + ) { + continue + } + + for ( + let offsetX = -NAV_STAIR_TRANSITION_RADIUS_CELLS; + offsetX <= NAV_STAIR_TRANSITION_RADIUS_CELLS; + offsetX += 1 + ) { + for ( + let offsetY = -NAV_STAIR_TRANSITION_RADIUS_CELLS; + offsetY <= NAV_STAIR_TRANSITION_RADIUS_CELLS; + offsetY += 1 + ) { + const candidateIndices = + cellIndicesByKey.get(getCellKey(cell.gridX + offsetX, cell.gridY + offsetY)) ?? [] + + for (const candidateIndex of candidateIndices) { + if (candidateIndex === cell.cellIndex) { + continue + } + + const candidate = cells[candidateIndex] + if (!(candidate && candidate.levelId !== cell.levelId)) { + continue + } + + const verticalDelta = Math.abs(candidate.center[1] - cell.center[1]) + if (verticalDelta > NAV_MAX_STEP_HEIGHT) { + continue + } + + const horizontalDelta = Math.hypot( + candidate.center[0] - cell.center[0], + candidate.center[2] - cell.center[2], + ) + if (horizontalDelta > NAV_STAIR_TRANSITION_MAX_HORIZONTAL_DISTANCE) { + continue + } + + connectPair(cell.cellIndex, candidateIndex) + } + } + } + } + + return stairTransitionEdgeCount +} + +function computeConnectedComponents(adjacency: number[][]) { + const componentIdByCell = new Int32Array(adjacency.length) + componentIdByCell.fill(-1) + + const components: number[][] = [] + let largestComponentId = -1 + let largestComponentSize = 0 + + for (let cellIndex = 0; cellIndex < adjacency.length; cellIndex += 1) { + if (componentIdByCell[cellIndex] !== -1) { + continue + } + + const componentId = components.length + const stack = [cellIndex] + const component: number[] = [] + componentIdByCell[cellIndex] = componentId + + while (stack.length > 0) { + const currentIndex = stack.pop() + if (currentIndex === undefined) { + continue + } + + component.push(currentIndex) + + for (const neighborIndex of adjacency[currentIndex] ?? []) { + if (componentIdByCell[neighborIndex] !== -1) { + continue + } + + componentIdByCell[neighborIndex] = componentId + stack.push(neighborIndex) + } + } + + components.push(component) + + if (component.length > largestComponentSize) { + largestComponentId = componentId + largestComponentSize = component.length + } + } + + return { + componentIdByCell, + components, + largestComponentId, + largestComponentSize, + } +} + +export function buildNavigationGraph( + nodes: Record, + rootNodeIds: string[], + buildingId?: BuildingNode['id'] | null, + options: NavigationBuildOptions = {}, +): NavigationGraph | null { + const levels = measureNavigationPerf('navigation.build.levelsMs', () => + getSortedBuildingLevels(nodes, rootNodeIds, buildingId), + ) + if (levels.length === 0) { + return null + } + + const levelBaseYById = measureNavigationPerf('navigation.build.levelBaseYMs', () => + getLevelBaseYById(levels, nodes), + ) + const cells: NavigationCell[] = [] + const cellsByLevel = new Map() + const collisionByLevel = new Map() + const doorPortals: NavigationDoorPortal[] = [] + const obstacleBlockedCellsByLevel = new Map() + const wallDebugCellsByLevel = new Map() + const wallBlockedCellsByLevel = new Map() + let doorPortalCount = 0 + let stairSurfaceCount = 0 + let walkableCellCount = 0 + + measureNavigationPerf('navigation.build.levelResultsMs', () => { + for (const level of levels) { + const levelBaseY = levelBaseYById.get(level.id) ?? 0 + const levelResult = getLevelNavigationResult(level, nodes, levelBaseY, options) + collisionByLevel.set(level.id, levelResult.collision) + obstacleBlockedCellsByLevel.set(level.id, levelResult.obstacleBlockedCells) + wallDebugCellsByLevel.set(level.id, levelResult.wallDebugCells) + wallBlockedCellsByLevel.set(level.id, levelResult.wallBlockedCells) + doorPortalCount += levelResult.doorPortalCount + stairSurfaceCount += levelResult.stairSurfaceCount + walkableCellCount += levelResult.walkableCellCount + doorPortals.push(...levelResult.doorPortals) + + const levelCellIndices: number[] = [] + for (const levelCell of levelResult.cells) { + const cellIndex = cells.length + cells.push({ + ...levelCell, + cellIndex, + }) + levelCellIndices.push(cellIndex) + } + cellsByLevel.set(level.id, levelCellIndices) + } + }) + + if (cells.length === 0) { + return null + } + + const { doorOpenings, groupedDoorPortals } = measureNavigationPerf( + 'navigation.build.groupDoorPortalsMs', + () => groupDoorPortals(doorPortals), + ) + + const cellIndicesByKey = measureNavigationPerf('navigation.build.cellIndicesByKeyMs', () => { + const nextCellIndicesByKey = new Map() + for (const cell of cells) { + const key = getCellKey(cell.gridX, cell.gridY) + const bucket = nextCellIndicesByKey.get(key) + if (bucket) { + bucket.push(cell.cellIndex) + } else { + nextCellIndicesByKey.set(key, [cell.cellIndex]) + } + } + return nextCellIndicesByKey + }) + + const adjacency = Array.from({ length: cells.length }, () => [] as number[]) + + measureNavigationPerf('navigation.build.adjacencyMs', () => { + for (const cell of cells) { + for (let offsetX = -NAV_NEIGHBOR_RADIUS; offsetX <= NAV_NEIGHBOR_RADIUS; offsetX += 1) { + for (let offsetY = -NAV_NEIGHBOR_RADIUS; offsetY <= NAV_NEIGHBOR_RADIUS; offsetY += 1) { + if (offsetX === 0 && offsetY === 0) { + continue + } + + const neighborKey = getCellKey(cell.gridX + offsetX, cell.gridY + offsetY) + const bucket = cellIndicesByKey.get(neighborKey) + if (!bucket) { + continue + } + + if (offsetX !== 0 && offsetY !== 0) { + const hasHorizontalSupport = hasSupportCellForDiagonal( + cell, + cell.gridX + offsetX, + cell.gridY, + cellIndicesByKey, + cells, + ) + const hasVerticalSupport = hasSupportCellForDiagonal( + cell, + cell.gridX, + cell.gridY + offsetY, + cellIndicesByKey, + cells, + ) + + if (!(hasHorizontalSupport && hasVerticalSupport)) { + continue + } + } + + for (const neighborIndex of bucket) { + if (neighborIndex <= cell.cellIndex) { + continue + } + + const neighbor = cells[neighborIndex] + if (!neighbor) { + continue + } + + const verticalDelta = Math.abs(neighbor.center[1] - cell.center[1]) + if (verticalDelta > NAV_MAX_STEP_HEIGHT) { + continue + } + + const horizontalDelta = Math.hypot( + neighbor.center[0] - cell.center[0], + neighbor.center[2] - cell.center[2], + ) + if (horizontalDelta > WALKABLE_CELL_SIZE * Math.SQRT2 + 1e-6) { + continue + } + + adjacency[cell.cellIndex]?.push(neighborIndex) + adjacency[neighborIndex]?.push(cell.cellIndex) + } + } + } + } + }) + + const { doorBridgeEdgeCount, doorBridgeEdges } = measureNavigationPerf( + 'navigation.build.doorBridgeMs', + () => connectDoorPortalCells(adjacency, cells, groupedDoorPortals, WALKABLE_CELL_SIZE), + ) + const stairTransitionEdgeCount = measureNavigationPerf('navigation.build.stairTransitionMs', () => + connectStairTransitionCells(adjacency, cells, cellIndicesByKey), + ) + + const { componentIdByCell, components, largestComponentId, largestComponentSize } = + measureNavigationPerf('navigation.build.connectedComponentsMs', () => + computeConnectedComponents(adjacency), + ) + + return { + adjacency, + cellSize: WALKABLE_CELL_SIZE, + cells, + cellsByLevel, + cellIndicesByKey, + collisionByLevel, + componentIdByCell, + components, + doorBridgeEdgeCount, + doorBridgeEdges, + doorOpenings, + doorPortals: groupedDoorPortals, + doorPortalCount, + largestComponentId, + largestComponentSize, + levelBaseYById, + obstacleBlockedCellsByLevel, + stairTransitionEdgeCount, + stairSurfaceCount, + wallDebugCellsByLevel, + wallBlockedCellsByLevel, + walkableCellCount, + } +} + +export function deriveNavigationGraphWithoutObstacles( + graph: NavigationGraph, + obstacleIds: Iterable, +): NavigationGraph { + const removedObstacleIds = new Set( + Array.from(obstacleIds).filter((obstacleId): obstacleId is string => obstacleId.length > 0), + ) + if (removedObstacleIds.size === 0) { + return graph + } + + const collisionByLevel = new Map() + const touchedLevels = new Set() + const removedSamplesByLevel = new Map() + + for (const [levelId, collision] of graph.collisionByLevel) { + const removedSamples = collision.obstacleSamples.filter((sample) => + removedObstacleIds.has(sample.sourceId), + ) + removedSamplesByLevel.set(levelId, removedSamples) + + if (removedSamples.length === 0) { + collisionByLevel.set(levelId, collision) + continue + } + + touchedLevels.add(levelId) + collisionByLevel.set(levelId, { + ...collision, + obstacleSamples: collision.obstacleSamples.filter( + (sample) => !removedObstacleIds.has(sample.sourceId), + ), + }) + } + + if (touchedLevels.size === 0) { + return graph + } + + const cells = graph.cells.slice() + const adjacency = graph.adjacency.map((neighbors) => neighbors.slice()) + const cellsByLevel = new Map( + Array.from( + graph.cellsByLevel, + ([levelId, cellIndices]) => [levelId, cellIndices.slice()] as const, + ), + ) + const cellIndicesByKey = new Map( + Array.from(graph.cellIndicesByKey, ([key, cellIndices]) => [key, cellIndices.slice()] as const), + ) + const restoredCellIndices: number[] = [] + + for (const levelId of touchedLevels) { + const collision = collisionByLevel.get(levelId) + const removedSamples = removedSamplesByLevel.get(levelId) ?? [] + const obstacleBlockedCells = graph.obstacleBlockedCellsByLevel.get(levelId) ?? [] + + if (!(collision && removedSamples.length > 0 && obstacleBlockedCells.length > 0)) { + continue + } + + for (const blockedCell of obstacleBlockedCells) { + const existingCellIndices = cellIndicesByKey.get( + getCellKey(blockedCell.gridX, blockedCell.gridY), + ) + if ( + existingCellIndices?.some((cellIndex) => { + const existingCell = cells[cellIndex] + return existingCell?.levelId === blockedCell.levelId + }) + ) { + continue + } + + if ( + !removedSamples.some((sample) => + isCollisionSampleBlockingPoint(blockedCell.localCenter, NAVIGATION_AGENT_RADIUS, sample), + ) + ) { + continue + } + + const openWallIds = getOpenPortalWallIdsAtPoint(collision, blockedCell.localCenter) + if ( + hasBlockingCollisionSample( + blockedCell.localCenter, + NAVIGATION_AGENT_RADIUS, + collision.wallSamples, + openWallIds, + ) || + hasBlockingCollisionSample( + blockedCell.localCenter, + NAVIGATION_AGENT_RADIUS, + collision.obstacleSamples, + ) + ) { + continue + } + + const cellIndex = cells.length + const restoredCell: NavigationCell = { + ...blockedCell, + cellIndex, + } + cells.push(restoredCell) + adjacency.push([]) + restoredCellIndices.push(cellIndex) + + const levelCellIndices = cellsByLevel.get(levelId) + if (levelCellIndices) { + levelCellIndices.push(cellIndex) + } else { + cellsByLevel.set(levelId, [cellIndex]) + } + + const cellKey = getCellKey(restoredCell.gridX, restoredCell.gridY) + const keyedCellIndices = cellIndicesByKey.get(cellKey) + if (keyedCellIndices) { + keyedCellIndices.push(cellIndex) + } else { + cellIndicesByKey.set(cellKey, [cellIndex]) + } + } + } + + if (restoredCellIndices.length === 0) { + return { + ...graph, + collisionByLevel, + } + } + + for (const cellIndex of restoredCellIndices) { + const cell = cells[cellIndex] + if (!cell) { + continue + } + + connectNavigationCellNeighbors(cell, adjacency, cellIndicesByKey, cells) + } + + const newDoorBridgeEdges = connectDoorPortalCells( + adjacency, + cells, + graph.doorPortals, + graph.cellSize, + ) + const newStairTransitionEdgeCount = connectStairTransitionCells( + adjacency, + cells, + cellIndicesByKey, + ) + const { componentIdByCell, components, largestComponentId, largestComponentSize } = + computeConnectedComponents(adjacency) + + return { + ...graph, + adjacency, + cells, + cellsByLevel, + cellIndicesByKey, + collisionByLevel, + componentIdByCell, + components, + doorBridgeEdgeCount: graph.doorBridgeEdgeCount + newDoorBridgeEdges.doorBridgeEdgeCount, + doorBridgeEdges: + newDoorBridgeEdges.doorBridgeEdges.length > 0 + ? [...graph.doorBridgeEdges, ...newDoorBridgeEdges.doorBridgeEdges] + : graph.doorBridgeEdges, + largestComponentId, + largestComponentSize, + stairTransitionEdgeCount: graph.stairTransitionEdgeCount + newStairTransitionEdgeCount, + walkableCellCount: graph.walkableCellCount + restoredCellIndices.length, + } +} + +function createSearchState(cellCount: number): SearchState { + const cameFrom = new Int32Array(cellCount) + cameFrom.fill(-1) + + const closed = new Uint8Array(cellCount) + const gScore = new Float64Array(cellCount) + gScore.fill(Number.POSITIVE_INFINITY) + + const fScore = new Float64Array(cellCount) + fScore.fill(Number.POSITIVE_INFINITY) + + return { + cameFrom, + closed, + fScore, + gScore, + } +} + +function reconstructPath(cameFrom: Int32Array, goalIndex: number) { + const path: number[] = [] + let current = goalIndex + + while (current >= 0) { + path.push(current) + current = cameFrom[current] ?? -1 + } + + path.reverse() + return path +} + +function getHeuristic(graph: NavigationGraph, startIndex: number, goalIndex: number) { + const start = graph.cells[startIndex] + const goal = graph.cells[goalIndex] + if (!(start && goal)) { + return Number.POSITIVE_INFINITY + } + + return getCellDistance(start, goal) +} + +export function findNavigationPath( + graph: NavigationGraph, + startIndex: number, + goalIndex: number, +): NavigationPathResult | null { + return measureNavigationPerf('navigation.pathfindMs', () => { + const startTime = performance.now() + + if (startIndex === goalIndex) { + return { + cost: 0, + elapsedMs: 0, + indices: [startIndex], + } + } + + const start = graph.cells[startIndex] + const goal = graph.cells[goalIndex] + if (!(start && goal)) { + return null + } + + const searchState = createSearchState(graph.cells.length) + const openSet = new MinHeap() + + searchState.gScore[startIndex] = 0 + searchState.fScore[startIndex] = getHeuristic(graph, startIndex, goalIndex) + openSet.push(startIndex, searchState.fScore[startIndex]) + + while (openSet.size > 0) { + const currentEntry = openSet.pop() + if (!currentEntry) { + break + } + + const currentIndex = currentEntry.node + if (searchState.closed[currentIndex]) { + continue + } + + if (currentIndex === goalIndex) { + const goalCost = searchState.gScore[goalIndex] ?? Number.POSITIVE_INFINITY + return { + cost: goalCost, + elapsedMs: performance.now() - startTime, + indices: reconstructPath(searchState.cameFrom, goalIndex), + } + } + + searchState.closed[currentIndex] = 1 + + const neighbors = graph.adjacency[currentIndex] ?? [] + const currentCell = graph.cells[currentIndex] + if (!currentCell) { + continue + } + + for (const neighborIndex of neighbors) { + if (searchState.closed[neighborIndex]) { + continue + } + + const neighborCell = graph.cells[neighborIndex] + if (!neighborCell) { + continue + } + + const currentGScore = searchState.gScore[currentIndex] ?? Number.POSITIVE_INFINITY + const tentativeGScore = currentGScore + getCellDistance(currentCell, neighborCell) + + const neighborGScore = searchState.gScore[neighborIndex] ?? Number.POSITIVE_INFINITY + if (tentativeGScore >= neighborGScore) { + continue + } + + searchState.cameFrom[neighborIndex] = currentIndex + searchState.gScore[neighborIndex] = tentativeGScore + searchState.fScore[neighborIndex] = + tentativeGScore + getHeuristic(graph, neighborIndex, goalIndex) + openSet.push(neighborIndex, searchState.fScore[neighborIndex]) + } + } + + return null + }) +} + +export function findClosestNavigationCell( + graph: NavigationGraph, + point: [number, number, number], + preferredLevelId?: LevelNode['id'] | null, + componentId?: number | null, +): number | null { + const [x, y, z] = point + const gridX = Math.round((x - graph.cellSize / 2) / graph.cellSize) + const gridY = Math.round((z - graph.cellSize / 2) / graph.cellSize) + const targetLevelId = preferredLevelId ?? null + const targetComponentId = componentId ?? null + let bestCellIndex: number | null = null + let bestDistanceSquared = Number.POSITIVE_INFINITY + + const updateBestCandidate = (cellIndex: number) => { + const cell = graph.cells[cellIndex] + if (!cell) { + return + } + + if (targetLevelId && cell.levelId !== targetLevelId) { + return + } + + if ( + targetComponentId !== null && + targetComponentId !== undefined && + graph.componentIdByCell[cellIndex] !== targetComponentId + ) { + return + } + + const dx = cell.center[0] - x + const dy = (cell.center[1] - y) * 1.5 + const dz = cell.center[2] - z + const distanceSquared = dx * dx + dy * dy + dz * dz + + if (distanceSquared < bestDistanceSquared) { + bestDistanceSquared = distanceSquared + bestCellIndex = cell.cellIndex + } + } + + for (let offsetX = -NAV_SNAP_RADIUS_CELLS; offsetX <= NAV_SNAP_RADIUS_CELLS; offsetX += 1) { + for (let offsetY = -NAV_SNAP_RADIUS_CELLS; offsetY <= NAV_SNAP_RADIUS_CELLS; offsetY += 1) { + const key = getCellKey(gridX + offsetX, gridY + offsetY) + const candidateIndices = graph.cellIndicesByKey.get(key) + if (!candidateIndices) { + continue + } + + for (const candidateIndex of candidateIndices) { + updateBestCandidate(candidateIndex) + } + } + } + + if (bestCellIndex !== null) { + return bestCellIndex + } + + const levelCellIndices = targetLevelId ? (graph.cellsByLevel.get(targetLevelId) ?? null) : null + const componentCellIndices = + targetComponentId !== null && targetComponentId >= 0 + ? (graph.components[targetComponentId] ?? null) + : null + const fallbackIndices = + levelCellIndices && componentCellIndices + ? levelCellIndices.length <= componentCellIndices.length + ? levelCellIndices + : componentCellIndices + : (levelCellIndices ?? componentCellIndices) + + if (fallbackIndices) { + for (const cellIndex of fallbackIndices) { + updateBestCandidate(cellIndex) + } + + return bestCellIndex + } + + for (let cellIndex = 0; cellIndex < graph.cells.length; cellIndex += 1) { + updateBestCandidate(cellIndex) + } + + return bestCellIndex +} + +function getDoorOpeningPassagePoints( + opening: NavigationDoorOpening, + fromCell: NavigationCell, + toCell: NavigationCell, + cellSize: number, +) { + const fromOffset = { + x: fromCell.center[0] - opening.center.x, + y: fromCell.center[2] - opening.center.y, + } + const toOffset = { + x: toCell.center[0] - opening.center.x, + y: toCell.center[2] - opening.center.y, + } + const segmentOffset = { + x: toCell.center[0] - fromCell.center[0], + y: toCell.center[2] - fromCell.center[2], + } + const fromDepth = dotPlan(fromOffset, opening.depthAxis) + const toDepth = dotPlan(toOffset, opening.depthAxis) + const fromWidth = dotPlan(fromOffset, opening.widthAxis) + const toWidth = dotPlan(toOffset, opening.widthAxis) + let fromSide = Math.sign(fromDepth) + let toSide = Math.sign(toDepth) + + if (fromSide === 0 && toSide !== 0) { + fromSide = -toSide + } + if (toSide === 0 && fromSide !== 0) { + toSide = -fromSide + } + + if (fromSide === 0 && toSide === 0) { + const segmentDepthDelta = dotPlan(segmentOffset, opening.depthAxis) + if (Math.abs(segmentDepthDelta) > Number.EPSILON) { + fromSide = segmentDepthDelta > 0 ? -1 : 1 + toSide = -fromSide + } else { + fromSide = -1 + toSide = 1 + } + } else if (fromSide === toSide) { + toSide = -fromSide + } + + const passageOffset = Math.max( + opening.passageHalfDepth + Math.max(cellSize * 0.25, NAVIGATION_AGENT_RADIUS * 0.35), + NAV_DOOR_ENTRY_OFFSET, + ) + const centerlineOffsetBase = + passageOffset + Math.max(cellSize * 0.4, NAVIGATION_AGENT_RADIUS * 0.5) + const centerlineOffsetLimit = Math.max( + centerlineOffsetBase, + passageOffset + Math.max(cellSize * 1.1, NAVIGATION_AGENT_RADIUS * 0.9), + ) + const approachOffset = Math.max( + centerlineOffsetBase, + Math.min(centerlineOffsetLimit, Math.abs(fromDepth)), + ) + const departureOffset = Math.max( + centerlineOffsetBase, + Math.min(centerlineOffsetLimit, Math.abs(toDepth)), + ) + const crossingWidth = 0 + const centerY = Math.min(fromCell.center[1], toCell.center[1]) + const buildWorldPoint = (depthScale: number): [number, number, number] => [ + opening.center.x + opening.widthAxis.x * crossingWidth + opening.depthAxis.x * depthScale, + centerY, + opening.center.y + opening.widthAxis.y * crossingWidth + opening.depthAxis.y * depthScale, + ] + + return { + approachWorld: buildWorldPoint(fromSide * approachOffset), + departureWorld: buildWorldPoint(toSide * departureOffset), + entryWorld: buildWorldPoint(fromSide * passageOffset), + exitWorld: buildWorldPoint(toSide * passageOffset), + world: buildWorldPoint(0), + } +} + +function isNavigationPathSegmentInsideDoorOpening( + opening: NavigationDoorOpening, + fromCell: NavigationCell, + toCell: NavigationCell, + cellSize: number, +) { + if (fromCell.levelId !== opening.levelId || toCell.levelId !== opening.levelId) { + return false + } + + const fromPoint = { x: fromCell.center[0], y: fromCell.center[2] } + const toPoint = { x: toCell.center[0], y: toCell.center[2] } + const portalOptions = { + depthEpsilon: Math.max(cellSize * 0.35, NAVIGATION_AGENT_RADIUS * 0.35), + widthEpsilon: Math.max(cellSize * 0.55, NAVIGATION_AGENT_RADIUS * 0.5), + } + const fromInsidePortal = isPointInsideDoorPortal(fromPoint, opening.polygon, portalOptions) + const toInsidePortal = isPointInsideDoorPortal(toPoint, opening.polygon, portalOptions) + + const fromOffset = { + x: fromPoint.x - opening.center.x, + y: fromPoint.y - opening.center.y, + } + const toOffset = { + x: toPoint.x - opening.center.x, + y: toPoint.y - opening.center.y, + } + const fromDepth = dotPlan(fromOffset, opening.depthAxis) + const toDepth = dotPlan(toOffset, opening.depthAxis) + const depthDelta = toDepth - fromDepth + const depthEpsilon = portalOptions.depthEpsilon + const fromSide = Math.abs(fromDepth) <= depthEpsilon ? 0 : Math.sign(fromDepth) + const toSide = Math.abs(toDepth) <= depthEpsilon ? 0 : Math.sign(toDepth) + const hasOppositeSideCrossing = + fromSide !== 0 && toSide !== 0 && fromSide !== toSide && Math.abs(depthDelta) > depthEpsilon + const hasDoorwayEndpointCrossing = + Math.abs(depthDelta) > Number.EPSILON && + ((fromInsidePortal && fromSide === 0 && toSide !== 0 && fromDepth * toDepth <= 0) || + (toInsidePortal && toSide === 0 && fromSide !== 0 && fromDepth * toDepth <= 0) || + (fromInsidePortal && + toInsidePortal && + fromSide === 0 && + toSide === 0 && + fromDepth * toDepth <= 0 && + Math.abs(depthDelta) > cellSize * 0.05)) + const crossesOpeningPlane = hasOppositeSideCrossing || hasDoorwayEndpointCrossing + + if (!crossesOpeningPlane) { + return false + } + + const fromWidth = dotPlan(fromOffset, opening.widthAxis) + const toWidth = dotPlan(toOffset, opening.widthAxis) + const crossingT = Math.min(Math.max(-fromDepth / depthDelta, 0), 1) + const crossingWidth = fromWidth + (toWidth - fromWidth) * crossingT + const crossingPoint = { + x: opening.center.x + opening.widthAxis.x * crossingWidth, + y: opening.center.y + opening.widthAxis.y * crossingWidth, + } + + return isPointInsideDoorPortal(crossingPoint, opening.polygon, portalOptions) +} + +export function getNavigationDoorTransitions( + graph: NavigationGraph, + pathIndices: number[], +): NavigationDoorTransition[] { + if (pathIndices.length < 2 || graph.doorOpenings.length === 0) { + return [] + } + + const { segments, totalLength } = buildNavigationPathSamples(graph, pathIndices) + if (segments.length === 0 || totalLength <= Number.EPSILON) { + return [] + } + + const openingIdByBridgeKey = new Map() + for (const doorBridgeEdge of graph.doorBridgeEdges) { + openingIdByBridgeKey.set( + `${doorBridgeEdge.cellIndexA}:${doorBridgeEdge.cellIndexB}`, + doorBridgeEdge.openingId, + ) + } + + const doorOpeningById = new Map( + graph.doorOpenings.map((doorOpening) => [doorOpening.openingId, doorOpening]), + ) + const earliestTransitionByOpeningId = new Map() + + for (const segment of segments) { + const pairKey = `${Math.min(segment.fromCellIndex, segment.toCellIndex)}:${Math.max(segment.fromCellIndex, segment.toCellIndex)}` + const fromCell = graph.cells[segment.fromCellIndex] + const toCell = graph.cells[segment.toCellIndex] + if (!(fromCell && toCell)) { + continue + } + + const bridgeOpeningId = openingIdByBridgeKey.get(pairKey) + const doorOpening = + (bridgeOpeningId ? doorOpeningById.get(bridgeOpeningId) : undefined) ?? + graph.doorOpenings.find((opening) => + isNavigationPathSegmentInsideDoorOpening(opening, fromCell, toCell, graph.cellSize), + ) + + if (!doorOpening || earliestTransitionByOpeningId.has(doorOpening.openingId)) { + continue + } + + earliestTransitionByOpeningId.set(doorOpening.openingId, { + doorIds: doorOpening.doorIds, + openingId: doorOpening.openingId, + ...getDoorOpeningPassagePoints(doorOpening, fromCell, toCell, graph.cellSize), + fromCellIndex: segment.fromCellIndex, + fromPathIndex: Math.floor(segment.pathPosition), + pathPosition: segment.pathPosition, + progress: (segment.cumulativeDistance + segment.length * 0.5) / totalLength, + toCellIndex: segment.toCellIndex, + toPathIndex: Math.ceil(segment.pathPosition), + }) + } + + return [...earliestTransitionByOpeningId.values()].sort( + (left, right) => left.pathPosition - right.pathPosition, + ) +} + +function getNavigationCellCenters(graph: NavigationGraph, pathIndices: number[]) { + return pathIndices.flatMap((cellIndex) => { + const cell = graph.cells[cellIndex] + return cell ? [cell.center] : [] + }) +} + +function getSegmentComponentId(graph: NavigationGraph, pathIndices: number[]) { + for (const cellIndex of pathIndices) { + const componentId = graph.componentIdByCell[cellIndex] + if (componentId !== undefined && componentId >= 0) { + return componentId + } + } + + return null +} + +function pushUniqueNavigationPoint( + points: Array<[number, number, number]>, + point: [number, number, number], +) { + const lastPoint = points[points.length - 1] + if ( + lastPoint && + Math.hypot(lastPoint[0] - point[0], lastPoint[1] - point[1], lastPoint[2] - point[2]) <= 1e-4 + ) { + return + } + + points.push(point) +} + +export function getNavigationPathWorldPoints( + graph: NavigationGraph, + pathIndices: number[], +): Array<[number, number, number]> { + const doorTransitions = getNavigationDoorTransitions(graph, pathIndices) + const points: Array<[number, number, number]> = [] + + if (pathIndices.length === 0) { + return points + } + + const appendSimplifiedNavigationSegment = ( + segmentPathIndices: number[], + options: NavigationSegmentAppendOptions = {}, + ) => { + const { endWorldAnchor, startWorldAnchor } = options + const validSegmentPathIndices = segmentPathIndices.filter( + (cellIndex): cellIndex is number => + cellIndex !== undefined && Boolean(graph.cells[cellIndex]), + ) + const simplifiedSegmentPathIndices = + validSegmentPathIndices.length > 0 + ? simplifyNavigationPath(graph, validSegmentPathIndices) + : [] + const segmentPoints = getNavigationCellCenters(graph, simplifiedSegmentPathIndices) + const componentId = getSegmentComponentId( + graph, + simplifiedSegmentPathIndices.length > 0 + ? simplifiedSegmentPathIndices + : validSegmentPathIndices, + ) + + if ( + startWorldAnchor && + endWorldAnchor && + hasNavigationWorldLineOfSight(graph, startWorldAnchor, endWorldAnchor, componentId) + ) { + pushUniqueNavigationPoint(points, startWorldAnchor) + pushUniqueNavigationPoint(points, endWorldAnchor) + return + } + + let startTrimIndex = 0 + if (startWorldAnchor) { + while (startTrimIndex < segmentPoints.length - 1) { + const nextPoint = segmentPoints[startTrimIndex + 1] + if ( + !( + nextPoint && + hasNavigationWorldLineOfSight(graph, startWorldAnchor, nextPoint, componentId) + ) + ) { + break + } + + startTrimIndex += 1 + } + } + + let endTrimIndex = segmentPoints.length - 1 + if (endWorldAnchor) { + while (endTrimIndex > startTrimIndex) { + const previousPoint = segmentPoints[endTrimIndex - 1] + if ( + !( + previousPoint && + hasNavigationWorldLineOfSight(graph, previousPoint, endWorldAnchor, componentId) + ) + ) { + break + } + + endTrimIndex -= 1 + } + } + + if (startWorldAnchor) { + pushUniqueNavigationPoint(points, startWorldAnchor) + } + + for (let pointIndex = startTrimIndex; pointIndex <= endTrimIndex; pointIndex += 1) { + const point = segmentPoints[pointIndex] + if (point) { + pushUniqueNavigationPoint(points, point) + } + } + + if (endWorldAnchor) { + pushUniqueNavigationPoint(points, endWorldAnchor) + } + } + + if (doorTransitions.length === 0) { + appendSimplifiedNavigationSegment(pathIndices) + return points + } + + let segmentStartPathIndex = 0 + let currentStartWorldAnchor: [number, number, number] | undefined + + for (const transition of doorTransitions) { + const segmentEndPathIndex = Math.max(segmentStartPathIndex, transition.fromPathIndex) + appendSimplifiedNavigationSegment( + pathIndices.slice(segmentStartPathIndex, segmentEndPathIndex + 1), + { + endWorldAnchor: transition.approachWorld, + startWorldAnchor: currentStartWorldAnchor, + }, + ) + pushUniqueNavigationPoint(points, transition.entryWorld) + pushUniqueNavigationPoint(points, transition.world) + pushUniqueNavigationPoint(points, transition.exitWorld) + pushUniqueNavigationPoint(points, transition.departureWorld) + + segmentStartPathIndex = Math.min( + pathIndices.length - 1, + Math.max(segmentStartPathIndex, transition.toPathIndex), + ) + currentStartWorldAnchor = transition.departureWorld + } + + appendSimplifiedNavigationSegment(pathIndices.slice(segmentStartPathIndex), { + startWorldAnchor: currentStartWorldAnchor, + }) + + return points +} + +export function simplifyNavigationPath(graph: NavigationGraph, pathIndices: number[]): number[] { + if (pathIndices.length <= 2) { + return [...pathIndices] + } + + const simplifiedPath = [pathIndices[0]!] + let anchorIndex = 0 + + while (anchorIndex < pathIndices.length - 1) { + let bestVisibleIndex = anchorIndex + 1 + + for ( + let candidateIndex = anchorIndex + 2; + candidateIndex < pathIndices.length; + candidateIndex += 1 + ) { + const anchorCellIndex = pathIndices[anchorIndex] + const candidateCellIndex = pathIndices[candidateIndex] + + if ( + anchorCellIndex === undefined || + candidateCellIndex === undefined || + !hasNavigationLineOfSight(graph, anchorCellIndex, candidateCellIndex) + ) { + break + } + + bestVisibleIndex = candidateIndex + } + + const nextCellIndex = pathIndices[bestVisibleIndex] + if (nextCellIndex === undefined) { + break + } + + simplifiedPath.push(nextCellIndex) + anchorIndex = bestVisibleIndex + } + + return simplifiedPath +} diff --git a/packages/editor/src/lib/pascal-truck.ts b/packages/editor/src/lib/pascal-truck.ts new file mode 100644 index 000000000..03fc5b62a --- /dev/null +++ b/packages/editor/src/lib/pascal-truck.ts @@ -0,0 +1,608 @@ +import type { AssetInput, ItemNode } from '@pascal-app/core' +import { MathUtils } from 'three' +import type { SceneGraph } from './scene' + +export const PASCAL_TRUCK_ASSET_ID = 'pascal-truck' +export const PASCAL_TRUCK_ITEM_NODE_ID = 'item_pascal_truck_seed' + +export const PASCAL_TRUCK_ASSET: AssetInput = { + id: PASCAL_TRUCK_ASSET_ID, + category: 'outdoor', + tags: ['floor', 'garage', 'vehicle'], + name: 'Pascal Truck', + thumbnail: '/items/pascal-truck/thumbnail.png', + src: '/items/pascal-truck/model.glb', + scale: [1, 1, 1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + dimensions: [4.42, 2.5, 2.28], +} + +export const PASCAL_TRUCK_SCENE_POSITION: [number, number, number] = [0, 0, 0] +export const PASCAL_TRUCK_SCENE_ROTATION: [number, number, number] = [0, 0, 0] +export const PASCAL_TRUCK_SCENE_SCALE: [number, number, number] = [1, 1, 1] + +export const PASCAL_TRUCK_ENTRY_CLIP_NAME = 'Jumping_Down' +export const PASCAL_TRUCK_ENTRY_CLIP_DURATION_SECONDS = 2.45 +export const PASCAL_TRUCK_ENTRY_REVEAL_DURATION_MS = 1500 +export const PASCAL_TRUCK_ENTRY_MAX_STEP_MS = 1000 +export const PASCAL_TRUCK_ENTRY_REAR_EDGE_INSET = 0.2 +export const PASCAL_TRUCK_ENTRY_REAR_TRAVEL_DISTANCE = 0.5 +export const PASCAL_TRUCK_ENTRY_REVEAL_TRAVEL_RATIO = 0 +export const PASCAL_TRUCK_ENTRY_TRAVEL_END_PROGRESS = 0.78 +export const PASCAL_TRUCK_REAR_LOCAL_X_SIGN = 1 +export const PASCAL_TRUCK_ENTRY_RELEASE_BLEND_RESPONSE = 8 +export const PASCAL_TRUCK_ENTRY_RELEASE_END_WEIGHT = 1e-3 + +export function getPascalTruckIntroPositionBlend( + revealProgress: number, + animationProgress: number, +) { + const revealTravelProgress = + (1 - (1 - revealProgress) * (1 - revealProgress)) * PASCAL_TRUCK_ENTRY_REVEAL_TRAVEL_RATIO + const animationTravelProgress = + MathUtils.smoothstep( + MathUtils.clamp(animationProgress / PASCAL_TRUCK_ENTRY_TRAVEL_END_PROGRESS, 0, 1), + 0, + 1, + ) * + (1 - PASCAL_TRUCK_ENTRY_REVEAL_TRAVEL_RATIO) + + return Math.min(1, revealTravelProgress + animationTravelProgress) +} + +export function getPascalTruckIntroReleaseWeight(releaseElapsedMs: number) { + return MathUtils.damp( + 1, + 0, + PASCAL_TRUCK_ENTRY_RELEASE_BLEND_RESPONSE, + Math.max(0, releaseElapsedMs) / 1000, + ) +} + +export function getPascalTruckIntroReleaseDurationMs() { + return Math.ceil( + (-Math.log(PASCAL_TRUCK_ENTRY_RELEASE_END_WEIGHT) / PASCAL_TRUCK_ENTRY_RELEASE_BLEND_RESPONSE) * + 1000, + ) +} + +const PASCAL_TRUCK_NODE_ASSET = { + ...PASCAL_TRUCK_ASSET, + dimensions: PASCAL_TRUCK_ASSET.dimensions ?? [4.42, 2.5, 2.28], + offset: PASCAL_TRUCK_ASSET.offset ?? [0, 0, 0], + rotation: PASCAL_TRUCK_ASSET.rotation ?? [0, 0, 0], + scale: PASCAL_TRUCK_ASSET.scale ?? [1, 1, 1], +} as ItemNode['asset'] + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function shouldPreservePascalTruckPlacement(sourceTruckNode?: ItemNode | null) { + const metadata = isRecord(sourceTruckNode?.metadata) ? sourceTruckNode.metadata : null + return metadata?.manualPlacement === true +} + +function getScaledItemDimensions(node: Record): [number, number, number] | null { + const asset = isRecord(node.asset) ? node.asset : null + const assetDimensions = Array.isArray(asset?.dimensions) ? asset.dimensions : null + if ( + !assetDimensions || + assetDimensions.length < 3 || + assetDimensions.some((value) => typeof value !== 'number') + ) { + return null + } + const dimensions = assetDimensions as [number, number, number] + + const assetScale: [number, number, number] = + Array.isArray(asset?.scale) && asset.scale.length >= 3 + ? (asset.scale as [number, number, number]) + : [1, 1, 1] + const nodeScale: [number, number, number] = + Array.isArray(node.scale) && node.scale.length >= 3 + ? (node.scale as [number, number, number]) + : [1, 1, 1] + + return [ + dimensions[0] * assetScale[0] * nodeScale[0], + dimensions[1] * assetScale[1] * nodeScale[1], + dimensions[2] * assetScale[2] * nodeScale[2], + ] +} + +function getSitePolygonPoints(sceneGraph: SceneGraph): [number, number][] | null { + for (const node of Object.values(sceneGraph.nodes)) { + if ( + isRecord(node) && + node.type === 'site' && + isRecord(node.polygon) && + Array.isArray(node.polygon.points) && + node.polygon.points.length >= 3 + ) { + return node.polygon.points as [number, number][] + } + } + + return null +} + +function getPolygonCenter(points: [number, number][]): [number, number] { + let sumX = 0 + let sumZ = 0 + for (const [x, z] of points) { + sumX += x + sumZ += z + } + return [sumX / points.length, sumZ / points.length] +} + +function getPolygonAreaAndCentroid(points: [number, number][]) { + let doubledArea = 0 + let centroidXTimesArea = 0 + let centroidZTimesArea = 0 + + for (let index = 0; index < points.length; index += 1) { + const current = points[index] + const next = points[(index + 1) % points.length] + if (!(current && next)) { + continue + } + + const cross = current[0] * next[1] - next[0] * current[1] + doubledArea += cross + centroidXTimesArea += (current[0] + next[0]) * cross + centroidZTimesArea += (current[1] + next[1]) * cross + } + + if (Math.abs(doubledArea) <= Number.EPSILON) { + return { + area: 0, + centroid: getPolygonCenter(points), + } + } + + return { + area: Math.abs(doubledArea) / 2, + centroid: [centroidXTimesArea / (3 * doubledArea), centroidZTimesArea / (3 * doubledArea)] as [ + number, + number, + ], + } +} + +function getLevelGeometryCenter(sceneGraph: SceneGraph, levelId: string): [number, number] | null { + let weightedCenterX = 0 + let weightedCenterZ = 0 + let totalArea = 0 + + for (const rawNode of Object.values(sceneGraph.nodes)) { + if (!isRecord(rawNode) || rawNode.type !== 'slab' || rawNode.parentId !== levelId) { + continue + } + + const polygon = rawNode.polygon + if ( + !Array.isArray(polygon) || + polygon.length < 3 || + polygon.some( + (point) => + !Array.isArray(point) || + point.length < 2 || + typeof point[0] !== 'number' || + typeof point[1] !== 'number', + ) + ) { + continue + } + + const { area, centroid } = getPolygonAreaAndCentroid(polygon as [number, number][]) + weightedCenterX += centroid[0] * area + weightedCenterZ += centroid[1] * area + totalArea += area + } + + if (totalArea <= Number.EPSILON) { + return null + } + + return [weightedCenterX / totalArea, weightedCenterZ / totalArea] +} + +function getCardinalRearDirectionTowardTarget( + sourceX: number, + sourceZ: number, + targetX: number, + targetZ: number, +) { + const deltaX = targetX - sourceX + const deltaZ = targetZ - sourceZ + + if (Math.abs(deltaX) >= Math.abs(deltaZ)) { + return deltaX >= 0 + ? { directionX: 1, directionZ: 0, yaw: Math.PI } + : { directionX: -1, directionZ: 0, yaw: 0 } + } + + return deltaZ >= 0 + ? { directionX: 0, directionZ: 1, yaw: Math.PI * 1.5 } + : { directionX: 0, directionZ: -1, yaw: Math.PI / 2 } +} + +function pointInPolygon2D(x: number, z: number, polygon: [number, number][]) { + let inside = false + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { + const [xi, zi] = polygon[i]! + const [xj, zj] = polygon[j]! + const intersects = + zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi || Number.EPSILON) + xi + if (intersects) { + inside = !inside + } + } + return inside +} + +function getOrientedRectCorners( + centerX: number, + centerZ: number, + halfLength: number, + halfDepth: number, + yaw: number, +): [number, number][] { + const cosYaw = Math.cos(yaw) + const sinYaw = Math.sin(yaw) + const localCorners: [number, number][] = [ + [-halfLength, -halfDepth], + [halfLength, -halfDepth], + [halfLength, halfDepth], + [-halfLength, halfDepth], + ] + + return localCorners.map(([localX, localZ]) => [ + centerX + localX * cosYaw - localZ * sinYaw, + centerZ + localX * sinYaw + localZ * cosYaw, + ]) +} + +function getPolygonAxes(points: [number, number][]) { + const axes: [number, number][] = [] + for (let index = 0; index < points.length; index += 1) { + const current = points[index] + const next = points[(index + 1) % points.length] + if (!(current && next)) { + continue + } + + const edgeX = next[0] - current[0] + const edgeZ = next[1] - current[1] + const length = Math.hypot(edgeX, edgeZ) + if (length <= Number.EPSILON) { + continue + } + + axes.push([-edgeZ / length, edgeX / length]) + } + return axes +} + +function projectPolygon(points: [number, number][], axis: [number, number]) { + let min = Number.POSITIVE_INFINITY + let max = Number.NEGATIVE_INFINITY + + for (const point of points) { + const projection = point[0] * axis[0] + point[1] * axis[1] + min = Math.min(min, projection) + max = Math.max(max, projection) + } + + return { max, min } +} + +function polygonsOverlap(a: [number, number][], b: [number, number][]) { + const axes = [...getPolygonAxes(a), ...getPolygonAxes(b)] + for (const axis of axes) { + const projectionA = projectPolygon(a, axis) + const projectionB = projectPolygon(b, axis) + if (projectionA.max < projectionB.min || projectionB.max < projectionA.min) { + return false + } + } + return true +} + +function collectTruckPlacementObstacles( + sceneGraph: SceneGraph, + levelId: string, + excludedItemId: string, +) { + const obstacles: Array<{ + center: [number, number] + corners: [number, number][] + }> = [] + + for (const rawNode of Object.values(sceneGraph.nodes)) { + if (!isRecord(rawNode) || rawNode.type !== 'item' || rawNode.id === excludedItemId) { + continue + } + + const asset = isRecord(rawNode.asset) ? rawNode.asset : null + if (rawNode.parentId !== levelId || asset?.attachTo) { + continue + } + + const dimensions = getScaledItemDimensions(rawNode) + const position = + Array.isArray(rawNode.position) && rawNode.position.length >= 3 + ? (rawNode.position as [number, number, number]) + : null + if (!dimensions || !position) { + continue + } + + const yaw = + Array.isArray(rawNode.rotation) && + rawNode.rotation.length >= 2 && + typeof rawNode.rotation[1] === 'number' + ? rawNode.rotation[1] + : 0 + + obstacles.push({ + center: [position[0], position[2]], + corners: getOrientedRectCorners( + position[0], + position[2], + dimensions[0] / 2, + dimensions[2] / 2, + yaw, + ), + }) + } + + return obstacles +} + +function computePascalTruckSeedTransform(sceneGraph: SceneGraph, levelId: string | null) { + const fallback = { + position: PASCAL_TRUCK_SCENE_POSITION, + rotation: PASCAL_TRUCK_SCENE_ROTATION, + scale: PASCAL_TRUCK_SCENE_SCALE, + } + + if (!levelId) { + return fallback + } + + const sitePolygon = getSitePolygonPoints(sceneGraph) + if (!sitePolygon) { + return fallback + } + + const targetCenter = getLevelGeometryCenter(sceneGraph, levelId) ?? getPolygonCenter(sitePolygon) + const obstacles = collectTruckPlacementObstacles(sceneGraph, levelId, PASCAL_TRUCK_ITEM_NODE_ID) + const [truckLength, , truckDepth] = PASCAL_TRUCK_ASSET.dimensions ?? [4.42, 2.5, 2.28] + const edgeSamples = [0.18, 0.35, 0.5, 0.65, 0.82] + const insetDistances = [truckLength / 2 + 0.15, truckLength / 2 + 0.45, truckLength / 2 + 0.75] + + let bestCandidate: { + clearanceScore: number + position: [number, number, number] + rotation: [number, number, number] + } | null = null + + for (let index = 0; index < sitePolygon.length; index += 1) { + const start = sitePolygon[index] + const end = sitePolygon[(index + 1) % sitePolygon.length] + if (!(start && end)) { + continue + } + + const edgeLength = Math.hypot(end[0] - start[0], end[1] - start[1]) + if (edgeLength <= Number.EPSILON) { + continue + } + + for (const sample of edgeSamples) { + const borderX = start[0] + (end[0] - start[0]) * sample + const borderZ = start[1] + (end[1] - start[1]) * sample + const { directionX, directionZ, yaw } = getCardinalRearDirectionTowardTarget( + borderX, + borderZ, + targetCenter[0], + targetCenter[1], + ) + + for (const inset of insetDistances) { + const centerX = borderX + directionX * inset + const centerZ = borderZ + directionZ * inset + const corners = getOrientedRectCorners( + centerX, + centerZ, + truckLength / 2, + truckDepth / 2, + yaw, + ) + + if (!corners.every(([x, z]) => pointInPolygon2D(x, z, sitePolygon))) { + continue + } + + if (obstacles.some((obstacle) => polygonsOverlap(corners, obstacle.corners))) { + continue + } + + const clearanceScore = obstacles.reduce((minDistance, obstacle) => { + const distance = Math.hypot(centerX - obstacle.center[0], centerZ - obstacle.center[1]) + return Math.min(minDistance, distance) + }, Number.POSITIVE_INFINITY) + + if (!bestCandidate || clearanceScore > bestCandidate.clearanceScore) { + bestCandidate = { + clearanceScore, + position: [centerX, 0, centerZ], + rotation: [0, yaw, 0], + } + } + } + } + } + + return bestCandidate + ? { + position: bestCandidate.position, + rotation: bestCandidate.rotation, + scale: [1, 1, 1] as [number, number, number], + } + : fallback +} + +function cloneValue(value: T): T { + if (typeof structuredClone === 'function') { + return structuredClone(value) + } + + return JSON.parse(JSON.stringify(value)) as T +} + +export function isPascalTruckNode(node: unknown): node is ItemNode { + return ( + isRecord(node) && + node.type === 'item' && + isRecord(node.asset) && + (node.asset.id === PASCAL_TRUCK_ASSET_ID || + node.asset.src === PASCAL_TRUCK_ASSET.src || + (typeof node.asset.src === 'string' && node.asset.src.endsWith(PASCAL_TRUCK_ASSET.src))) + ) +} + +function resolvePascalTruckLevelId( + sceneGraph: SceneGraph, + preferredLevelId?: string | null, +): string | null { + if ( + preferredLevelId && + isRecord(sceneGraph.nodes[preferredLevelId]) && + sceneGraph.nodes[preferredLevelId].type === 'level' + ) { + return preferredLevelId + } + + let fallbackLevelId: string | null = null + for (const node of Object.values(sceneGraph.nodes)) { + if (!isRecord(node) || node.type !== 'level' || typeof node.id !== 'string') { + continue + } + + fallbackLevelId ??= node.id + if (node.level === 0) { + return node.id + } + } + + return fallbackLevelId +} + +export function stripPascalTruckFromSceneGraph(sceneGraph?: SceneGraph | null): { + sceneGraph: SceneGraph | null | undefined + truckNode: ItemNode | null +} { + if (!sceneGraph) { + return { sceneGraph, truckNode: null } + } + + const truckNode = Object.values(sceneGraph.nodes).find((node) => isPascalTruckNode(node)) ?? null + if (!truckNode) { + return { sceneGraph, truckNode: null } + } + + const truckIds = new Set( + Object.entries(sceneGraph.nodes) + .filter(([, node]) => isPascalTruckNode(node)) + .map(([id]) => id), + ) + const nextSceneGraph = cloneValue(sceneGraph) + + for (const truckId of truckIds) { + delete nextSceneGraph.nodes[truckId] + } + + for (const [nodeId, node] of Object.entries(nextSceneGraph.nodes)) { + if (!isRecord(node) || !Array.isArray(node.children)) { + continue + } + + const nextChildren = node.children.filter( + (childId) => typeof childId !== 'string' || !truckIds.has(childId), + ) + if (nextChildren.length !== node.children.length) { + nextSceneGraph.nodes[nodeId] = { + ...node, + children: nextChildren, + } + } + } + + nextSceneGraph.rootNodeIds = nextSceneGraph.rootNodeIds.filter( + (rootNodeId) => !truckIds.has(rootNodeId), + ) + + return { + sceneGraph: nextSceneGraph, + truckNode: cloneValue(truckNode), + } +} + +export function buildPascalTruckNodeForScene( + sceneGraph: SceneGraph, + sourceTruckNode?: ItemNode | null, +): { + node: ItemNode + parentId: string | null +} { + const parentId = resolvePascalTruckLevelId(sceneGraph, sourceTruckNode?.parentId) + const preserveManualPlacement = shouldPreservePascalTruckPlacement(sourceTruckNode) + const seededTransform = computePascalTruckSeedTransform(sceneGraph, parentId) + const node: ItemNode = sourceTruckNode + ? { + ...cloneValue(sourceTruckNode), + asset: PASCAL_TRUCK_NODE_ASSET, + children: Array.isArray(sourceTruckNode.children) ? [...sourceTruckNode.children] : [], + id: PASCAL_TRUCK_ITEM_NODE_ID, + parentId: parentId ?? sourceTruckNode.parentId, + position: + preserveManualPlacement && Array.isArray(sourceTruckNode.position) + ? sourceTruckNode.position + : seededTransform.position, + rotation: + preserveManualPlacement && Array.isArray(sourceTruckNode.rotation) + ? sourceTruckNode.rotation + : seededTransform.rotation, + scale: + preserveManualPlacement && Array.isArray(sourceTruckNode.scale) + ? sourceTruckNode.scale + : seededTransform.scale, + visible: sourceTruckNode.visible ?? true, + } + : { + asset: PASCAL_TRUCK_NODE_ASSET, + children: [], + id: PASCAL_TRUCK_ITEM_NODE_ID, + metadata: { + manualPlacement: false, + }, + name: PASCAL_TRUCK_ASSET.name, + object: 'node', + parentId, + position: seededTransform.position, + rotation: seededTransform.rotation, + scale: seededTransform.scale, + type: 'item', + visible: true, + } + + return { + node, + parentId, + } +} diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 6eea48f34..909daf249 100755 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -1,23 +1,29 @@ 'use client' -import { resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core' +import type { BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core' +import { resolveLevelId, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import useEditor, { hasCustomPersistedEditorUiState, normalizePersistedEditorUiState, type PersistedEditorUiState, } from '../store/use-editor' +import useNavigation from '../store/use-navigation' +import navigationVisualsStore from '../store/use-navigation-visuals' +import { getItemMoveVisualState, setItemMoveVisualState } from './item-move-visuals' export type SceneGraph = { nodes: Record rootNodeIds: string[] } +type ApplySceneGraphMode = 'full' | 'task-loop' + type PersistedSelectionPath = { - buildingId: string | null - levelId: string | null - zoneId: string | null - selectedIds: string[] + buildingId: BuildingNode['id'] | null + levelId: LevelNode['id'] | null + zoneId: ZoneNode['id'] | null + selectedIds: BaseNode['id'][] } const EMPTY_PERSISTED_SELECTION: PersistedSelectionPath = { @@ -29,6 +35,20 @@ const EMPTY_PERSISTED_SELECTION: PersistedSelectionPath = { const SELECTION_STORAGE_KEY = 'pascal-editor-selection' +function toBuildingNodeId(value: string | null | undefined): BuildingNode['id'] | null { + return typeof value === 'string' && value.startsWith('building_') + ? (value as BuildingNode['id']) + : null +} + +function toLevelNodeId(value: string | null | undefined): LevelNode['id'] | null { + return typeof value === 'string' && value.startsWith('level_') ? (value as LevelNode['id']) : null +} + +function toZoneNodeId(value: string | null | undefined): ZoneNode['id'] | null { + return typeof value === 'string' && value.startsWith('zone_') ? (value as ZoneNode['id']) : null +} + function getSelectionStorageKey(): string { const projectId = useViewer.getState().projectId return projectId ? `${SELECTION_STORAGE_KEY}:${projectId}` : SELECTION_STORAGE_KEY @@ -41,8 +61,8 @@ function getSelectionStorageReadKeys(): string[] { function getDefaultLevelIdForBuilding( sceneNodes: Record, - buildingId: string | null, -): string | null { + buildingId: BuildingNode['id'] | null, +): LevelNode['id'] | null { if (!buildingId) { return null } @@ -52,7 +72,7 @@ function getDefaultLevelIdForBuilding( return null } - let firstLevelId: string | null = null + let firstLevelId: LevelNode['id'] | null = null for (const childId of buildingNode.children) { const levelNode = sceneNodes[childId] @@ -71,15 +91,23 @@ function getDefaultLevelIdForBuilding( } function normalizePersistedSelectionPath( - selection: Partial | null | undefined, + selection: + | Partial<{ + buildingId: string | null + levelId: string | null + zoneId: string | null + selectedIds: string[] + }> + | null + | undefined, ): PersistedSelectionPath { return { - buildingId: typeof selection?.buildingId === 'string' ? selection.buildingId : null, - levelId: typeof selection?.levelId === 'string' ? selection.levelId : null, - zoneId: typeof selection?.zoneId === 'string' ? selection.zoneId : null, - selectedIds: Array.isArray(selection?.selectedIds) - ? selection.selectedIds.filter((id): id is string => typeof id === 'string') - : [], + buildingId: toBuildingNodeId(selection?.buildingId), + levelId: toLevelNodeId(selection?.levelId), + zoneId: toZoneNodeId(selection?.zoneId), + // Branch-only selection persistence should restore scene context, not reopen + // node panels from the last session. + selectedIds: [], } } @@ -248,16 +276,22 @@ function getRestoredSelectionForScene( return getValidatedSelectionForScene(sceneNodes, persistedSelection) } -export function syncEditorSelectionFromCurrentScene() { +export function syncEditorSelectionFromCurrentScene(options?: { + restorePersistedUiState?: boolean +}) { const sceneNodes = useScene.getState().nodes as Record const sceneRootIds = useScene.getState().rootNodeIds const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child) const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building') const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level') + const restorePersistedUiState = options?.restorePersistedUiState ?? true const restoredEditorUiState = normalizePersistedEditorUiState(useEditor.getState()) - const shouldRestoreEditorUiState = hasCustomPersistedEditorUiState(restoredEditorUiState) - const restoredSelection = getRestoredSelectionForScene(sceneNodes) + const shouldRestoreEditorUiState = + restorePersistedUiState && hasCustomPersistedEditorUiState(restoredEditorUiState) + const restoredSelection = restorePersistedUiState + ? getRestoredSelectionForScene(sceneNodes) + : null const selectionDrivenEditorUiState = restoredSelection ? getEditorUiStateForRestoredSelection(sceneNodes, restoredSelection, restoredEditorUiState) : null @@ -331,28 +365,97 @@ export function syncEditorSelectionFromCurrentScene() { } } -function resetEditorInteractionState() { +function resetEditorInteractionState(mode: ApplySceneGraphMode) { useViewer.getState().setHoveredId(null) useViewer.getState().resetSelection() + useViewer.setState({ + hoverHighlightMode: 'default', + nodeEventsSuppressed: false, + previewSelectedIds: [], + }) + if (mode === 'task-loop') { + navigationVisualsStore.getState().resetTaskQueueVisuals() + navigationVisualsStore.setState({ + navigationPostWarmupCompletedToken: 0, + navigationPostWarmupRequestToken: 0, + navigationPostWarmupScope: null, + nodeVisibilityOverrides: {}, + taskPreviewNodeIds: {}, + toolConeIsolatedOverlay: null, + toolConeOverlayCamera: null, + toolConeOverlayWarmupReady: false, + }) + } else { + navigationVisualsStore.setState({ + itemDeleteActivations: {}, + itemMovePreview: null, + itemMoveVisualStates: {}, + navigationPostWarmupCompletedToken: 0, + navigationPostWarmupRequestToken: 0, + navigationPostWarmupScope: null, + nodeVisibilityOverrides: {}, + toolConeIsolatedOverlay: null, + toolConeOverlayCamera: null, + toolConeOverlayEnabled: false, + toolConeOverlayWarmupReady: false, + taskPreviewNodeIds: {}, + }) + } // Clear outliner arrays synchronously so stale Object3D refs from the old // scene don't leak into the post-processing pipeline's outline passes. const outliner = useViewer.getState().outliner outliner.selectedObjects.length = 0 outliner.hoveredObjects.length = 0 - sceneRegistry.clear() - useEditor.setState({ - phase: 'site', - mode: 'select', - tool: null, - structureLayer: 'elements', - catalogCategory: null, - selectedItem: null, - movingNode: null, - selectedReferenceId: null, - spaces: {}, - editingHole: null, - isPreviewMode: false, - }) + useNavigation.setState((state) => + mode === 'task-loop' + ? { + actorAvailable: false, + actorWorldPosition: null, + itemMoveControllers: {}, + itemMoveLocked: false, + navigationClickSuppressedUntil: 0, + walkableOverlayVisible: false, + } + : { + actorAvailable: false, + actorWorldPosition: null, + itemDeleteRequest: null, + itemMoveControllers: {}, + itemMoveLocked: false, + itemMoveRequest: null, + itemRepairRequest: null, + navigationClickSuppressedUntil: 0, + taskQueue: [], + walkableOverlayVisible: false, + }, + ) + useEditor.setState((state) => + mode === 'task-loop' + ? { + ...state, + tool: null, + selectedItem: null, + movingNode: null, + selectedReferenceId: null, + spaces: {}, + editingHole: null, + isPreviewMode: false, + } + : { + ...state, + phase: 'site', + mode: 'select', + tool: null, + structureLayer: 'elements', + catalogCategory: null, + selectedItem: null, + movingNode: null, + selectedReferenceId: null, + spaces: {}, + editingHole: null, + isPreviewMode: false, + }, + ) } function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is SceneGraph { @@ -363,22 +466,109 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen ) } -export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { - if (hasUsableSceneGraph(sceneGraph)) { - const { nodes, rootNodeIds } = sceneGraph +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isTaskPreviewNodeId(nodeId: string) { + const taskPreviewNodeIds = navigationVisualsStore.getState().taskPreviewNodeIds + return ( + taskPreviewNodeIds[nodeId] === true || + nodeId.startsWith('item_debug_move_preview_') || + nodeId.startsWith('item_debug_copy_preview_') + ) +} + +function isRuntimeTaskPreviewNode(nodeId: string, node: unknown) { + if (!isRecord(node) || node.type !== 'item') { + return false + } + + return isTaskPreviewNodeId(nodeId) +} + +function stripTransientTaskVisuals(sceneGraph?: SceneGraph | null): SceneGraph | null | undefined { + if (!sceneGraph) { + return sceneGraph + } + + let changed = false + const sanitizedNodes: Record = {} + const removedNodeIds = new Set() + + for (const [id, node] of Object.entries(sceneGraph.nodes ?? {})) { + if (isRuntimeTaskPreviewNode(id, node)) { + removedNodeIds.add(id) + changed = true + continue + } + + if (!isRecord(node) || getItemMoveVisualState(node.metadata) === null) { + sanitizedNodes[id] = node + continue + } + + sanitizedNodes[id] = { + ...node, + metadata: setItemMoveVisualState(node.metadata, null), + } + changed = true + } + + if (!changed) { + return sceneGraph + } + + if (removedNodeIds.size > 0) { + for (const [id, node] of Object.entries(sanitizedNodes)) { + if (!isRecord(node) || !Array.isArray(node.children)) { + continue + } + + const nextChildren = node.children.filter( + (childId) => typeof childId !== 'string' || !removedNodeIds.has(childId), + ) + if (nextChildren.length !== node.children.length) { + sanitizedNodes[id] = { + ...node, + children: nextChildren, + } + } + } + } + + return { + ...sceneGraph, + nodes: sanitizedNodes, + rootNodeIds: sceneGraph.rootNodeIds.filter((nodeId) => !removedNodeIds.has(nodeId)), + } +} + +export function applySceneGraphToEditor( + sceneGraph?: SceneGraph | null, + options?: { mode?: ApplySceneGraphMode }, +) { + const mode = options?.mode ?? 'full' + const sanitizedSceneGraph = stripTransientTaskVisuals(sceneGraph) + resetEditorInteractionState(mode) + + if (hasUsableSceneGraph(sanitizedSceneGraph)) { + const { nodes, rootNodeIds } = sanitizedSceneGraph useScene.getState().setScene(nodes as any, rootNodeIds as any) } else { useScene.getState().clearScene() } - syncEditorSelectionFromCurrentScene() + syncEditorSelectionFromCurrentScene({ + restorePersistedUiState: mode !== 'task-loop', + }) } const LOCAL_STORAGE_KEY = 'pascal-editor-scene' export function saveSceneToLocalStorage(scene: SceneGraph): void { try { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(scene)) + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(stripTransientTaskVisuals(scene))) } catch { // Swallow storage quota errors } @@ -387,7 +577,9 @@ export function saveSceneToLocalStorage(scene: SceneGraph): void { export function loadSceneFromLocalStorage(): SceneGraph | null { try { const raw = localStorage.getItem(LOCAL_STORAGE_KEY) - return raw ? (JSON.parse(raw) as SceneGraph) : null + return raw + ? ((stripTransientTaskVisuals(JSON.parse(raw) as SceneGraph) as SceneGraph | null) ?? null) + : null } catch { return null } diff --git a/packages/editor/src/lib/sfx-player.ts b/packages/editor/src/lib/sfx-player.ts index dba8acbd6..369e59678 100644 --- a/packages/editor/src/lib/sfx-player.ts +++ b/packages/editor/src/lib/sfx-player.ts @@ -65,10 +65,6 @@ export const SFX: Record = { volumeRange: [0.9, 1.0], panJitter: 0.15, }, - snapshotCapture: { - // Shutter should sound consistent, no variation. - src: '/audios/sfx/snapshot_capture.mp3', - }, } as const export type SFXName = keyof typeof SFX diff --git a/packages/editor/src/lib/walkable-surface.ts b/packages/editor/src/lib/walkable-surface.ts new file mode 100644 index 000000000..772c719d1 --- /dev/null +++ b/packages/editor/src/lib/walkable-surface.ts @@ -0,0 +1,1434 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + getScaledDimensions, + type ItemNode, + type LevelNode, + type Point2D, + type SlabNode, + type StairNode, + type StairSegmentNode, + sceneRegistry, + useLiveTransforms, + type WallNode, +} from '@pascal-app/core' +import { Matrix4, type Mesh, type Object3D, Vector3 } from 'three' + +const GRID_COORDINATE_PRECISION = 6 +const MAX_BRIDGE_SOURCE_COMPONENT_CELLS = 60 +const WALKABLE_BRIDGE_NEIGHBOR_OFFSETS: Array = [ + [-1, 0], + [1, 0], + [0, -1], + [0, 1], +] + +export const WALKABLE_CELL_SIZE = 0.2 +export const WALKABLE_CLEARANCE = 0.25 +export const WALKABLE_FILL_OPACITY = 0.22 +export const WALKABLE_OVERLAY_Y_OFFSET = 0.02 +const WALKABLE_PORTAL_RELIEF_EPSILON = WALKABLE_CELL_SIZE * 0.08 +const WALKABLE_PORTAL_WIDTH_EPSILON = WALKABLE_CELL_SIZE * 0.08 +const WALKABLE_PORTAL_AXIS_EPSILON = 1e-6 + +type WalkableNodeTransform = { + position: Point2D + rotation: number +} + +type WalkableBounds = { + minX: number + maxX: number + minY: number + maxY: number + width: number + height: number +} + +export type WalkableSlabPolygonEntry = { + polygon: Point2D[] + holes: Point2D[][] + surfaceY?: number + surfaceYAt?: (point: Point2D) => number +} + +export type WalkableSurfaceRun = { + x: number + y: number + width: number + height: number + surfaceY: number +} + +export type WalkableSurfaceCell = { + x: number + y: number + width: number + height: number + surfaceY: number + cornerSurfaceY: [number, number, number, number] +} + +export type WallOverlayDebugCell = WalkableSurfaceCell & { + blockedByObstacle: boolean + hasSupportingSurface: boolean + insidePortal: boolean + insideWallFootprint: boolean + withinWallClearance: boolean +} + +export type WallOverlayFilters = { + carveDoorPortals: boolean + excludeObstacleItems: boolean + expandByClearance: boolean + requireSupportingSurface: boolean +} + +export const DEFAULT_WALL_OVERLAY_FILTERS: WallOverlayFilters = { + carveDoorPortals: true, + excludeObstacleItems: true, + expandByClearance: true, + requireSupportingSurface: true, +} + +export type WalkableSurfaceOverlay = { + cellCount: number + cells: WalkableSurfaceCell[] + obstacleBlockedCellCount: number + obstacleBlockedCells: WalkableSurfaceCell[] + path: string + runs: WalkableSurfaceRun[] + wallDebugCellCount: number + wallDebugCells: WallOverlayDebugCell[] + wallBlockedCellCount: number + wallBlockedCells: WalkableSurfaceCell[] + wallBlockedPath: string + wallBlockedRuns: WalkableSurfaceRun[] +} + +export type WallOpeningLike = { + position: [number, number, number] + width: number +} + +type WalkablePolygonSample = { + bounds: WalkableBounds + polygon: Point2D[] +} + +export function toWalkablePlanPolygon(points: Array<[number, number]>): Point2D[] { + return points.map(([x, y]) => ({ x, y })) +} + +export function getSlabSurfaceY(slab: SlabNode): number { + const elevation = slab.elevation ?? 0.05 + return elevation < 0 ? 0 : elevation +} + +function rotatePlanVector(x: number, y: number, rotation: number): [number, number] { + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return [x * cos + y * sin, -x * sin + y * cos] +} + +type StairSegmentTransform = { + position: [number, number, number] + rotation: number +} + +function getPolygonBounds(points: Point2D[]): WalkableBounds { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const point of points) { + minX = Math.min(minX, point.x) + maxX = Math.max(maxX, point.x) + minY = Math.min(minY, point.y) + maxY = Math.max(maxY, point.y) + } + + return { + minX, + maxX, + minY, + maxY, + width: maxX - minX, + height: maxY - minY, + } +} + +function isPointInsideBounds(point: Point2D, bounds: WalkableBounds, margin = 0): boolean { + return ( + point.x >= bounds.minX - margin && + point.x <= bounds.maxX + margin && + point.y >= bounds.minY - margin && + point.y <= bounds.maxY + margin + ) +} + +function isPointInsidePolygon(point: Point2D, polygon: Point2D[]): boolean { + let inside = false + + for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) { + const current = polygon[index] + const prior = polygon[previous] + + if (!(current && prior)) { + continue + } + + const intersects = + current.y > point.y !== prior.y > point.y && + point.x < ((prior.x - current.x) * (point.y - current.y)) / (prior.y - current.y) + current.x + + if (intersects) { + inside = !inside + } + } + + return inside +} + +function getDistanceToLineSegment(point: Point2D, start: Point2D, end: Point2D): number { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + + if (lengthSquared <= Number.EPSILON) { + return Math.hypot(point.x - start.x, point.y - start.y) + } + + const projection = Math.max( + 0, + Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared), + ) + + return Math.hypot(point.x - (start.x + dx * projection), point.y - (start.y + dy * projection)) +} + +function getPolygonBoundaryDistance(point: Point2D, polygon: Point2D[]): number { + if (polygon.length === 0) { + return Number.POSITIVE_INFINITY + } + + let minDistance = Number.POSITIVE_INFINITY + + for (let index = 0; index < polygon.length; index += 1) { + const start = polygon[index] + const end = polygon[(index + 1) % polygon.length] + + if (!(start && end)) { + continue + } + + minDistance = Math.min(minDistance, getDistanceToLineSegment(point, start, end)) + } + + return minDistance +} + +function isPointBlockedByPolygon(point: Point2D, polygon: Point2D[], clearance: number): boolean { + if (polygon.length < 3) { + return false + } + + return ( + isPointInsidePolygon(point, polygon) || getPolygonBoundaryDistance(point, polygon) < clearance + ) +} + +function buildRectanglePathSegment(x: number, y: number, width: number, height: number): string { + return [ + `M ${-x} ${-y}`, + `L ${-(x + width)} ${-y}`, + `L ${-(x + width)} ${-(y + height)}`, + `L ${-x} ${-(y + height)}`, + 'Z', + ].join(' ') +} + +function createWalkableSurfaceCell( + x: number, + y: number, + cellSize: number, + surfaceY: number, + surfaceYAt?: (point: Point2D) => number, +): WalkableSurfaceCell { + const cornerSurfaceY = [ + { x, y }, + { x: x + cellSize, y }, + { x: x + cellSize, y: y + cellSize }, + { x, y: y + cellSize }, + ].map((cornerPoint) => surfaceYAt?.(cornerPoint) ?? surfaceY) as [number, number, number, number] + + return { + x, + y, + width: cellSize, + height: cellSize, + surfaceY, + cornerSurfaceY, + } +} + +function getWalkableCellKey(x: number, y: number): string { + return `${x.toFixed(GRID_COORDINATE_PRECISION)},${y.toFixed(GRID_COORDINATE_PRECISION)}` +} + +export function getRotatedRectanglePolygon( + center: Point2D, + width: number, + depth: number, + rotation: number, +): Point2D[] { + const halfWidth = width / 2 + const halfDepth = depth / 2 + const corners: Array<[number, number]> = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] + + return corners.map(([localX, localY]) => { + const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation) + return { + x: center.x + offsetX, + y: center.y + offsetY, + } + }) +} + +export function getWallOpeningPolygon(wall: WallNode, opening: WallOpeningLike): Point2D[] { + const [x1, z1] = wall.start + const [x2, z2] = wall.end + const dx = x2 - x1 + const dz = z2 - z1 + const length = Math.sqrt(dx * dx + dz * dz) + + if (length < 1e-9) { + return [] + } + + const dirX = dx / length + const dirZ = dz / length + const perpX = -dirZ + const perpZ = dirX + const centerDistance = opening.position[0] + const width = opening.width + const depth = wall.thickness ?? 0.1 + const centerX = x1 + dirX * centerDistance + const centerZ = z1 + dirZ * centerDistance + const halfWidth = width / 2 + const halfDepth = depth / 2 + + return [ + { + x: centerX - dirX * halfWidth + perpX * halfDepth, + y: centerZ - dirZ * halfWidth + perpZ * halfDepth, + }, + { + x: centerX + dirX * halfWidth + perpX * halfDepth, + y: centerZ + dirZ * halfWidth + perpZ * halfDepth, + }, + { + x: centerX + dirX * halfWidth - perpX * halfDepth, + y: centerZ + dirZ * halfWidth - perpZ * halfDepth, + }, + { + x: centerX - dirX * halfWidth - perpX * halfDepth, + y: centerZ - dirZ * halfWidth - perpZ * halfDepth, + }, + ] +} + +export function getDoorPortalPolygon( + wall: WallNode, + door: WallOpeningLike, + clearance: number, +): Point2D[] { + const [x1, z1] = wall.start + const [x2, z2] = wall.end + const dx = x2 - x1 + const dz = z2 - z1 + const length = Math.sqrt(dx * dx + dz * dz) + + if (length < 1e-9) { + return [] + } + + // Keep the portal depth generous for navigation, but do not widen it sideways + // beyond the actual door opening on the wall axis. + const effectiveWidth = Math.max(door.width + WALKABLE_PORTAL_WIDTH_EPSILON * 2, Number.EPSILON) + if (effectiveWidth <= 0) { + return [] + } + + const wallThickness = wall.thickness ?? 0.1 + const centerDistance = door.position[0] + const dirX = dx / length + const dirZ = dz / length + const perpX = -dirZ + const perpZ = dirX + const portalApproachDepth = Math.max(clearance * 2 + WALKABLE_CELL_SIZE, WALKABLE_CELL_SIZE * 2.5) + const portalDepth = wallThickness + portalApproachDepth * 2 + const halfWidth = effectiveWidth / 2 + const halfDepth = portalDepth / 2 + const centerX = x1 + dirX * centerDistance + const centerZ = z1 + dirZ * centerDistance + + return [ + { + x: centerX - dirX * halfWidth + perpX * halfDepth, + y: centerZ - dirZ * halfWidth + perpZ * halfDepth, + }, + { + x: centerX + dirX * halfWidth + perpX * halfDepth, + y: centerZ + dirZ * halfWidth + perpZ * halfDepth, + }, + { + x: centerX + dirX * halfWidth - perpX * halfDepth, + y: centerZ + dirZ * halfWidth - perpZ * halfDepth, + }, + { + x: centerX - dirX * halfWidth - perpX * halfDepth, + y: centerZ - dirZ * halfWidth - perpZ * halfDepth, + }, + ] +} + +export function isPointInsideDoorPortal( + point: Point2D, + polygon: Point2D[], + options?: { + depthEpsilon?: number + widthEpsilon?: number + }, +): boolean { + const first = polygon[0] + const second = polygon[1] + const third = polygon[2] + + if (!(first && second && third)) { + return false + } + + const widthVector = { + x: second.x - first.x, + y: second.y - first.y, + } + const depthVector = { + x: third.x - second.x, + y: third.y - second.y, + } + const widthLength = Math.hypot(widthVector.x, widthVector.y) + const depthLength = Math.hypot(depthVector.x, depthVector.y) + + if (widthLength <= Number.EPSILON || depthLength <= Number.EPSILON) { + return false + } + + const center = { + x: polygon.reduce((sum, corner) => sum + corner.x, 0) / polygon.length, + y: polygon.reduce((sum, corner) => sum + corner.y, 0) / polygon.length, + } + const widthAxis = { + x: widthVector.x / widthLength, + y: widthVector.y / widthLength, + } + const depthAxis = { + x: depthVector.x / depthLength, + y: depthVector.y / depthLength, + } + const widthEpsilon = options?.widthEpsilon ?? WALKABLE_PORTAL_AXIS_EPSILON + const depthEpsilon = options?.depthEpsilon ?? WALKABLE_PORTAL_RELIEF_EPSILON + const offset = { + x: point.x - center.x, + y: point.y - center.y, + } + const widthCoord = offset.x * widthAxis.x + offset.y * widthAxis.y + const depthCoord = offset.x * depthAxis.x + offset.y * depthAxis.y + + return ( + Math.abs(widthCoord) <= widthLength / 2 + widthEpsilon && + Math.abs(depthCoord) <= depthLength / 2 + depthEpsilon + ) +} + +export function getWallAttachedItemDoorOpening( + item: ItemNode, + wall: WallNode, + nodeById: ReadonlyMap, + cache: Map, +): WallOpeningLike | null { + if (item.asset.category !== 'door' || item.asset.attachTo !== 'wall') { + return null + } + + const sceneOpening = getWallAttachedItemDoorOpeningFromScene(item, wall) + if (sceneOpening) { + return sceneOpening + } + + const transform = getItemPlanTransform(item, nodeById, cache) + if (!transform) { + return null + } + + const wallVectorX = wall.end[0] - wall.start[0] + const wallVectorY = wall.end[1] - wall.start[1] + const wallLength = Math.hypot(wallVectorX, wallVectorY) + + if (wallLength <= Number.EPSILON) { + return null + } + + const [offsetX, offsetY] = rotatePlanVector( + item.asset.offset[0] ?? 0, + item.asset.offset[2] ?? 0, + transform.rotation, + ) + const openingCenter = { + x: transform.position.x + offsetX, + y: transform.position.y + offsetY, + } + const wallDirX = wallVectorX / wallLength + const wallDirY = wallVectorY / wallLength + const localCenterX = openingCenter.x - wall.start[0] + const localCenterY = openingCenter.y - wall.start[1] + const centerDistance = localCenterX * wallDirX + localCenterY * wallDirY + const [width, , depth] = getScaledDimensions(item) + const wallRotation = -Math.atan2(wallVectorY, wallVectorX) + const assetRotationY = item.asset.rotation[1] ?? 0 + const relativeRotation = transform.rotation + assetRotationY - wallRotation + const openingWidth = Math.max( + Math.abs(width * Math.cos(relativeRotation)) + Math.abs(depth * Math.sin(relativeRotation)), + WALKABLE_CELL_SIZE, + ) + + return { + position: [centerDistance, item.position[1] ?? 0, 0], + width: openingWidth, + } +} + +function getWallAttachedItemDoorOpeningFromScene( + item: ItemNode, + wall: WallNode, +): WallOpeningLike | null { + const wallObject = sceneRegistry.nodes.get(wall.id) as Object3D | undefined + const itemObject = sceneRegistry.nodes.get(item.id) as Object3D | undefined + const cutoutMesh = itemObject?.getObjectByName('cutout') as Mesh | undefined + const positions = cutoutMesh?.geometry?.getAttribute?.('position') + + if (!(wallObject && itemObject && cutoutMesh && positions && positions.count > 0)) { + return null + } + + wallObject.updateMatrixWorld(true) + cutoutMesh.updateMatrixWorld(true) + + const wallWorldInverse = new Matrix4().copy(wallObject.matrixWorld).invert() + const cutoutStableWorldMatrix = getStableWallDoorCutoutWorldMatrix(itemObject, cutoutMesh) + const point = new Vector3() + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + + for (let index = 0; index < positions.count; index += 1) { + point.fromBufferAttribute(positions, index) + point.applyMatrix4(cutoutStableWorldMatrix) + point.applyMatrix4(wallWorldInverse) + minX = Math.min(minX, point.x) + maxX = Math.max(maxX, point.x) + } + + if (!(Number.isFinite(minX) && Number.isFinite(maxX) && maxX - minX > Number.EPSILON)) { + return null + } + + return { + position: [minX + (maxX - minX) / 2, item.position[1] ?? 0, 0], + width: Math.max(maxX - minX, WALKABLE_CELL_SIZE), + } +} + +function getStableWallDoorCutoutWorldMatrix(itemObject: Object3D, cutoutMesh: Mesh) { + const stableLocalMatrix = new Matrix4() + const localChain: Object3D[] = [] + let current: Object3D | null = cutoutMesh + + while (current && current !== itemObject) { + localChain.push(current) + current = current.parent + } + + for (let index = localChain.length - 1; index >= 0; index -= 1) { + const object = localChain[index] + if (!object) { + continue + } + + if (object.name === 'door-leaf-group' || object.name === 'door-leaf-pivot') { + continue + } + + stableLocalMatrix.multiply(object.matrix) + } + + return new Matrix4().multiplyMatrices(itemObject.matrixWorld, stableLocalMatrix) +} + +export function collectLevelDescendants( + levelNode: LevelNode, + nodes: Record, +): AnyNode[] { + const descendants: AnyNode[] = [] + const stack = [...levelNode.children].reverse() as AnyNodeId[] + + while (stack.length > 0) { + const nodeId = stack.pop() + if (!nodeId) { + continue + } + + const node = nodes[nodeId] + if (!node) { + continue + } + + descendants.push(node) + + if ('children' in node && Array.isArray(node.children) && node.children.length > 0) { + for (let index = node.children.length - 1; index >= 0; index -= 1) { + stack.push(node.children[index] as AnyNodeId) + } + } + } + + return descendants +} + +export function computeStairSegmentTransforms( + segments: StairSegmentNode[], +): StairSegmentTransform[] { + const transforms: StairSegmentTransform[] = [] + let currentX = 0 + let currentY = 0 + let currentZ = 0 + let currentRotation = 0 + + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index] + if (!segment) { + continue + } + + if (index === 0) { + transforms.push({ + position: [currentX, currentY, currentZ], + rotation: currentRotation, + }) + continue + } + + const previousSegment = segments[index - 1] + if (!previousSegment) { + continue + } + + let attachX = 0 + let attachY = previousSegment.height + let attachZ = previousSegment.length + let rotationDelta = 0 + + if (segment.attachmentSide === 'left') { + attachX = previousSegment.width / 2 + attachZ = previousSegment.length / 2 + rotationDelta = Math.PI / 2 + } else if (segment.attachmentSide === 'right') { + attachX = -previousSegment.width / 2 + attachZ = previousSegment.length / 2 + rotationDelta = -Math.PI / 2 + } + + const [rotatedAttachX, rotatedAttachZ] = rotatePlanVector(attachX, attachZ, currentRotation) + currentX += rotatedAttachX + currentY += attachY + currentZ += rotatedAttachZ + currentRotation += rotationDelta + + transforms.push({ + position: [currentX, currentY, currentZ], + rotation: currentRotation, + }) + } + + return transforms +} + +export function getStairSegmentPolygon( + stair: StairNode, + segment: StairSegmentNode, + transform: StairSegmentTransform, +): Point2D[] { + const halfWidth = segment.width / 2 + const localCorners: Array<[number, number]> = [ + [-halfWidth, 0], + [halfWidth, 0], + [halfWidth, segment.length], + [-halfWidth, segment.length], + ] + + return localCorners.map(([localX, localY]) => { + const [segmentX, segmentY] = rotatePlanVector(localX, localY, transform.rotation) + const groupX = transform.position[0] + segmentX + const groupY = transform.position[2] + segmentY + const [worldOffsetX, worldOffsetY] = rotatePlanVector(groupX, groupY, stair.rotation) + + return { + x: stair.position[0] + worldOffsetX, + y: stair.position[2] + worldOffsetY, + } + }) +} + +function getStairSegmentSurfaceYAtPoint( + stair: StairNode, + segment: StairSegmentNode, + transform: StairSegmentTransform, + point: Point2D, +): number { + const planOffsetX = point.x - stair.position[0] + const planOffsetY = point.y - stair.position[2] + const [groupX, groupY] = rotatePlanVector(planOffsetX, planOffsetY, -stair.rotation) + const [localX, localY] = rotatePlanVector( + groupX - transform.position[0], + groupY - transform.position[2], + -transform.rotation, + ) + + const progress = Math.max(0, Math.min(1, localY / Math.max(segment.length, Number.EPSILON))) + const baseY = stair.position[1] + transform.position[1] + + if (segment.segmentType !== 'stair') { + return baseY + } + + return baseY + segment.height * progress +} + +export function buildWalkableStairSurfaceEntries( + stair: StairNode, + segments: StairSegmentNode[], +): WalkableSlabPolygonEntry[] { + const transforms = computeStairSegmentTransforms(segments) + + return segments.flatMap((segment, index) => { + const transform = transforms[index] + if (!transform) { + return [] + } + + const polygon = getStairSegmentPolygon(stair, segment, transform) + if (polygon.length < 3) { + return [] + } + + const baseY = stair.position[1] + transform.position[1] + + return [ + { + polygon, + holes: [], + surfaceY: baseY, + surfaceYAt: + segment.segmentType === 'stair' + ? (point: Point2D) => getStairSegmentSurfaceYAtPoint(stair, segment, transform, point) + : undefined, + }, + ] + }) +} + +export function isFloorBlockingItem( + item: ItemNode, + nodeById: ReadonlyMap, +): boolean { + if (item.asset.attachTo) { + return false + } + + const parentNode = item.parentId ? nodeById.get(item.parentId as AnyNodeId) : null + return parentNode?.type !== 'item' +} + +export function getItemPlanTransform( + item: ItemNode, + nodeById: ReadonlyMap, + cache: Map, +): WalkableNodeTransform | null { + const cached = cache.get(item.id) + if (cached !== undefined) { + return cached + } + + const localRotation = item.rotation[1] ?? 0 + let result: WalkableNodeTransform | null = null + const itemMetadata = + typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata) + ? (item.metadata as Record) + : null + + if (itemMetadata?.isTransient === true) { + const live = useLiveTransforms.getState().get(item.id) + if (live) { + result = { + position: { + x: live.position[0], + y: live.position[2], + }, + rotation: live.rotation, + } + + cache.set(item.id, result) + return result + } + } + + if (item.parentId) { + const parentNode = nodeById.get(item.parentId as AnyNodeId) + + if (parentNode?.type === 'wall') { + const wallRotation = -Math.atan2( + parentNode.end[1] - parentNode.start[1], + parentNode.end[0] - parentNode.start[0], + ) + const wallLocalZ = + item.asset.attachTo === 'wall-side' + ? ((parentNode.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1) + : item.position[2] + const [offsetX, offsetY] = rotatePlanVector(item.position[0], wallLocalZ, wallRotation) + + result = { + position: { + x: parentNode.start[0] + offsetX, + y: parentNode.start[1] + offsetY, + }, + rotation: wallRotation + localRotation, + } + } else if (parentNode?.type === 'item') { + const parentTransform = getItemPlanTransform(parentNode, nodeById, cache) + if (parentTransform) { + const [offsetX, offsetY] = rotatePlanVector( + item.position[0], + item.position[2], + parentTransform.rotation, + ) + result = { + position: { + x: parentTransform.position.x + offsetX, + y: parentTransform.position.y + offsetY, + }, + rotation: parentTransform.rotation + localRotation, + } + } + } else { + result = { + position: { x: item.position[0], y: item.position[2] }, + rotation: localRotation, + } + } + } else { + result = { + position: { x: item.position[0], y: item.position[2] }, + rotation: localRotation, + } + } + + cache.set(item.id, result) + return result +} + +export function buildWalkableSurfaceOverlay( + slabPolygons: WalkableSlabPolygonEntry[], + wallPolygons: Point2D[][], + obstaclePolygons: Point2D[][], + cellSize: number, + clearance: number, + wallPortalPolygons: Point2D[][] = [], +): WalkableSurfaceOverlay | null { + const slabSamples = slabPolygons + .map(({ polygon, holes, surfaceY = 0, surfaceYAt }) => ({ + bounds: getPolygonBounds(polygon), + holes: holes.map((hole) => ({ + bounds: getPolygonBounds(hole), + polygon: hole, + })), + polygon, + surfaceY, + surfaceYAt, + })) + .filter( + ({ bounds, polygon }) => + polygon.length >= 3 && + Number.isFinite(bounds.minX) && + Number.isFinite(bounds.maxX) && + Number.isFinite(bounds.minY) && + Number.isFinite(bounds.maxY), + ) + + if (slabSamples.length === 0) { + return null + } + + const wallSamples = wallPolygons + .map((polygon) => ({ + bounds: getPolygonBounds(polygon), + polygon, + })) + .filter( + ({ bounds, polygon }) => + polygon.length >= 3 && + Number.isFinite(bounds.minX) && + Number.isFinite(bounds.maxX) && + Number.isFinite(bounds.minY) && + Number.isFinite(bounds.maxY), + ) + + const obstacleSamples = obstaclePolygons + .map((polygon) => ({ + bounds: getPolygonBounds(polygon), + polygon, + })) + .filter( + ({ bounds, polygon }) => + polygon.length >= 3 && + Number.isFinite(bounds.minX) && + Number.isFinite(bounds.maxX) && + Number.isFinite(bounds.minY) && + Number.isFinite(bounds.maxY), + ) + + const portalSamples = wallPortalPolygons + .map((polygon) => ({ + bounds: getPolygonBounds(polygon), + polygon, + })) + .filter( + ({ bounds, polygon }) => + polygon.length >= 3 && + Number.isFinite(bounds.minX) && + Number.isFinite(bounds.maxX) && + Number.isFinite(bounds.minY) && + Number.isFinite(bounds.maxY), + ) + + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const { bounds } of [...slabSamples, ...wallSamples]) { + minX = Math.min(minX, bounds.minX) + maxX = Math.max(maxX, bounds.maxX) + minY = Math.min(minY, bounds.minY) + maxY = Math.max(maxY, bounds.maxY) + } + + if ( + !( + Number.isFinite(minX) && + Number.isFinite(maxX) && + Number.isFinite(minY) && + Number.isFinite(maxY) + ) + ) { + return null + } + + const halfCell = cellSize / 2 + const startX = Math.floor(minX / cellSize) * cellSize + const endX = Math.ceil(maxX / cellSize) * cellSize + const startY = Math.floor(minY / cellSize) * cellSize + const endY = Math.ceil(maxY / cellSize) * cellSize + const cells: WalkableSurfaceCell[] = [] + const obstacleBlockedCells: WalkableSurfaceCell[] = [] + const bridgeableCells: WalkableSurfaceCell[] = [] + const wallDebugCells: WallOverlayDebugCell[] = [] + const wallBlockedCells: WalkableSurfaceCell[] = [] + + const resolveSurface = (point: Point2D) => { + let topSurface: { + surfaceY: number + surfaceYAt?: (point: Point2D) => number + } | null = null + + for (const { bounds, holes, polygon, surfaceY, surfaceYAt } of slabSamples) { + if (!isPointInsideBounds(point, bounds)) { + continue + } + + if (!isPointInsidePolygon(point, polygon)) { + continue + } + + const intersectsHole = holes.some( + ({ bounds: holeBounds, polygon: holePolygon }) => + isPointInsideBounds(point, holeBounds) && isPointInsidePolygon(point, holePolygon), + ) + + if (intersectsHole) { + continue + } + + const resolvedSurfaceY = surfaceYAt?.(point) ?? surfaceY + if (topSurface === null || resolvedSurfaceY > topSurface.surfaceY) { + topSurface = { + surfaceY: resolvedSurfaceY, + surfaceYAt, + } + } + } + + return topSurface + } + + for (let y = startY; y < endY; y = Number((y + cellSize).toFixed(GRID_COORDINATE_PRECISION))) { + for (let x = startX; x < endX; x = Number((x + cellSize).toFixed(GRID_COORDINATE_PRECISION))) { + const point = { x: x + halfCell, y: y + halfCell } + const surfaceMatch = resolveSurface(point) + const surfaceY = surfaceMatch?.surfaceY ?? null + + const isInsidePortal = portalSamples.some( + ({ bounds, polygon }) => + isPointInsideBounds(point, bounds, WALKABLE_PORTAL_RELIEF_EPSILON) && + isPointInsideDoorPortal(point, polygon), + ) + + const isInsideWallFootprint = wallSamples.some( + ({ bounds, polygon }) => + isPointInsideBounds(point, bounds) && + (isPointInsidePolygon(point, polygon) || + getPolygonBoundaryDistance(point, polygon) <= WALKABLE_PORTAL_RELIEF_EPSILON), + ) + + const isWithinWallClearance = wallSamples.some( + ({ bounds, polygon }) => + isPointInsideBounds(point, bounds, clearance) && + isPointBlockedByPolygon(point, polygon, clearance), + ) + + const isObstacleBlocked = obstacleSamples.some( + ({ bounds, polygon }) => + isPointInsideBounds(point, bounds, clearance) && + isPointBlockedByPolygon(point, polygon, clearance), + ) + + const isWallBlocked = surfaceY !== null && isWithinWallClearance && !isInsidePortal + + if (isInsideWallFootprint || isWithinWallClearance) { + const baseCell = createWalkableSurfaceCell( + x, + y, + cellSize, + surfaceY ?? 0, + surfaceMatch?.surfaceYAt, + ) + + wallDebugCells.push({ + ...baseCell, + blockedByObstacle: isObstacleBlocked, + hasSupportingSurface: surfaceY !== null, + insidePortal: isInsidePortal, + insideWallFootprint: isInsideWallFootprint, + withinWallClearance: isWithinWallClearance, + }) + } + + if (surfaceY !== null && !isWallBlocked && !isObstacleBlocked) { + cells.push(createWalkableSurfaceCell(x, y, cellSize, surfaceY, surfaceMatch?.surfaceYAt)) + } + + if (surfaceY !== null && !isWallBlocked && isObstacleBlocked) { + obstacleBlockedCells.push( + createWalkableSurfaceCell(x, y, cellSize, surfaceY, surfaceMatch?.surfaceYAt), + ) + } + + if (surfaceY !== null && isWallBlocked && !isObstacleBlocked) { + wallBlockedCells.push( + createWalkableSurfaceCell(x, y, cellSize, surfaceY, surfaceMatch?.surfaceYAt), + ) + } + + if (surfaceY !== null && !isWallBlocked) { + bridgeableCells.push( + createWalkableSurfaceCell(x, y, cellSize, surfaceY, surfaceMatch?.surfaceYAt), + ) + } + } + } + + const bridgedCells = bridgeDisconnectedWalkableCells(cells, bridgeableCells, cellSize) + const { pathSegments, runs } = buildWalkableRuns(bridgedCells, cellSize) + const { pathSegments: wallBlockedPathSegments, runs: wallBlockedRuns } = buildWalkableRuns( + wallBlockedCells, + cellSize, + ) + + if (pathSegments.length === 0) { + return null + } + + return { + cellCount: bridgedCells.length, + cells: bridgedCells, + obstacleBlockedCellCount: obstacleBlockedCells.length, + obstacleBlockedCells, + path: pathSegments.join(' '), + runs, + wallDebugCellCount: wallDebugCells.length, + wallDebugCells, + wallBlockedCellCount: wallBlockedCells.length, + wallBlockedCells, + wallBlockedPath: wallBlockedPathSegments.join(' '), + wallBlockedRuns, + } +} + +export function filterWallOverlayCells( + cells: WallOverlayDebugCell[], + filters: WallOverlayFilters, +): WalkableSurfaceCell[] { + return cells + .filter((cell) => { + if ( + (filters.expandByClearance ? cell.withinWallClearance : cell.insideWallFootprint) === false + ) { + return false + } + + if (filters.requireSupportingSurface && !cell.hasSupportingSurface) { + return false + } + + if (filters.carveDoorPortals && cell.insidePortal) { + return false + } + + if (filters.excludeObstacleItems && cell.blockedByObstacle) { + return false + } + + return true + }) + .map((cell) => ({ + cornerSurfaceY: cell.cornerSurfaceY, + height: cell.height, + surfaceY: cell.surfaceY, + width: cell.width, + x: cell.x, + y: cell.y, + })) +} + +export function buildOverlayPathFromCells(cells: WalkableSurfaceCell[], cellSize: number) { + const { pathSegments, runs } = buildWalkableRuns(cells, cellSize) + return { + path: pathSegments.join(' '), + runs, + } +} + +function bridgeDisconnectedWalkableCells( + cells: WalkableSurfaceCell[], + bridgeableCells: WalkableSurfaceCell[], + cellSize: number, +): WalkableSurfaceCell[] { + if (cells.length === 0) { + return cells + } + + const walkableCellByKey = new Map() + for (const cell of cells) { + walkableCellByKey.set(getWalkableCellKey(cell.x, cell.y), cell) + } + + const bridgeableCellByKey = new Map() + for (const cell of bridgeableCells) { + bridgeableCellByKey.set(getWalkableCellKey(cell.x, cell.y), cell) + } + + const components = buildWalkableComponents([...walkableCellByKey.values()], cellSize) + .filter((component) => component.keys.length > 0) + .sort((left, right) => left.keys.length - right.keys.length) + + for (const component of components) { + if (component.keys.length === 0 || component.keys.length > MAX_BRIDGE_SOURCE_COMPONENT_CELLS) { + continue + } + + const sourceKeys = new Set(component.keys.filter((key) => walkableCellByKey.has(key))) + if (sourceKeys.size === 0) { + continue + } + + const targetKeys = new Set([...walkableCellByKey.keys()].filter((key) => !sourceKeys.has(key))) + if (targetKeys.size === 0) { + continue + } + + const bridgeKeys = findMinimalBridgeKeys( + sourceKeys, + targetKeys, + walkableCellByKey, + bridgeableCellByKey, + cellSize, + ) + + for (const bridgeKey of bridgeKeys) { + const bridgeCell = bridgeableCellByKey.get(bridgeKey) + if (bridgeCell) { + walkableCellByKey.set(bridgeKey, bridgeCell) + } + } + } + + return [...walkableCellByKey.values()] +} + +function buildWalkableComponents(cells: WalkableSurfaceCell[], cellSize: number) { + const cellByKey = new Map() + for (const cell of cells) { + cellByKey.set(getWalkableCellKey(cell.x, cell.y), cell) + } + + const visited = new Set() + + return cells + .map((cell) => getWalkableCellKey(cell.x, cell.y)) + .flatMap((startKey) => { + if (visited.has(startKey)) { + return [] + } + + const stack = [startKey] + const keys: string[] = [] + visited.add(startKey) + + while (stack.length > 0) { + const currentKey = stack.pop() + if (!currentKey) { + continue + } + + const currentCell = cellByKey.get(currentKey) + if (!currentCell) { + continue + } + + keys.push(currentKey) + + for (const [offsetX, offsetY] of WALKABLE_BRIDGE_NEIGHBOR_OFFSETS) { + const neighborKey = getWalkableCellKey( + currentCell.x + offsetX * cellSize, + currentCell.y + offsetY * cellSize, + ) + if (!cellByKey.has(neighborKey) || visited.has(neighborKey)) { + continue + } + + visited.add(neighborKey) + stack.push(neighborKey) + } + } + + return [ + { + keys, + }, + ] + }) +} + +function findMinimalBridgeKeys( + sourceKeys: ReadonlySet, + targetKeys: ReadonlySet, + walkableCellByKey: ReadonlyMap, + bridgeableCellByKey: ReadonlyMap, + cellSize: number, +) { + const bestBlockedCost = new Map() + const bestStepCount = new Map() + const previousByKey = new Map() + const open: Array<{ blockedCost: number; key: string; stepCount: number }> = [] + const closed = new Set() + + for (const sourceKey of sourceKeys) { + bestBlockedCost.set(sourceKey, 0) + bestStepCount.set(sourceKey, 0) + previousByKey.set(sourceKey, null) + open.push({ + blockedCost: 0, + key: sourceKey, + stepCount: 0, + }) + } + + const popBestEntry = () => { + if (open.length === 0) { + return null + } + + let bestIndex = 0 + for (let index = 1; index < open.length; index += 1) { + const candidate = open[index] + const best = open[bestIndex] + if (!candidate || !best) { + continue + } + + if ( + candidate.blockedCost < best.blockedCost || + (candidate.blockedCost === best.blockedCost && candidate.stepCount < best.stepCount) + ) { + bestIndex = index + } + } + + const [entry] = open.splice(bestIndex, 1) + return entry ?? null + } + + let goalKey: string | null = null + + while (open.length > 0) { + const current = popBestEntry() + if (!current || closed.has(current.key)) { + continue + } + + if (targetKeys.has(current.key)) { + goalKey = current.key + break + } + + closed.add(current.key) + + const currentCell = bridgeableCellByKey.get(current.key) + if (!currentCell) { + continue + } + + for (const [offsetX, offsetY] of WALKABLE_BRIDGE_NEIGHBOR_OFFSETS) { + const neighborKey = getWalkableCellKey( + currentCell.x + offsetX * cellSize, + currentCell.y + offsetY * cellSize, + ) + if (!bridgeableCellByKey.has(neighborKey) || closed.has(neighborKey)) { + continue + } + + const nextBlockedCost = current.blockedCost + (walkableCellByKey.has(neighborKey) ? 0 : 1) + const nextStepCount = current.stepCount + 1 + const previousBlockedCost = bestBlockedCost.get(neighborKey) ?? Number.POSITIVE_INFINITY + const previousStepCount = bestStepCount.get(neighborKey) ?? Number.POSITIVE_INFINITY + + if ( + nextBlockedCost > previousBlockedCost || + (nextBlockedCost === previousBlockedCost && nextStepCount >= previousStepCount) + ) { + continue + } + + bestBlockedCost.set(neighborKey, nextBlockedCost) + bestStepCount.set(neighborKey, nextStepCount) + previousByKey.set(neighborKey, current.key) + open.push({ + blockedCost: nextBlockedCost, + key: neighborKey, + stepCount: nextStepCount, + }) + } + } + + if (!goalKey) { + return [] + } + + const bridgeKeys: string[] = [] + let currentKey: string | null = goalKey + while (currentKey) { + if (!walkableCellByKey.has(currentKey) && !sourceKeys.has(currentKey)) { + bridgeKeys.push(currentKey) + } + currentKey = previousByKey.get(currentKey) ?? null + } + + bridgeKeys.reverse() + return bridgeKeys +} + +function buildWalkableRuns(cells: WalkableSurfaceCell[], cellSize: number) { + const sortedCells = [...cells].sort((left, right) => + left.y === right.y ? left.x - right.x : left.y - right.y, + ) + const pathSegments: string[] = [] + const runs: WalkableSurfaceRun[] = [] + let activeY: number | null = null + let runStartX: number | null = null + let runSurfaceY = 0 + let previousX: number | null = null + + const flushRun = () => { + if (runStartX === null || activeY === null || previousX === null) { + runStartX = null + previousX = null + return + } + + const width = previousX + cellSize - runStartX + if (width <= 0) { + runStartX = null + previousX = null + return + } + + pathSegments.push(buildRectanglePathSegment(runStartX, activeY, width, cellSize)) + runs.push({ + x: runStartX, + y: activeY, + width, + height: cellSize, + surfaceY: runSurfaceY, + }) + runStartX = null + previousX = null + } + + for (const cell of sortedCells) { + const sameRow = activeY !== null && Math.abs(cell.y - activeY) <= 1e-6 + const contiguousX = previousX !== null && Math.abs(cell.x - (previousX + cellSize)) <= 1e-6 + const sameSurface = Math.abs(cell.surfaceY - runSurfaceY) <= 1e-6 + + if (!(sameRow && contiguousX && sameSurface)) { + flushRun() + activeY = cell.y + runStartX = cell.x + runSurfaceY = cell.surfaceY + } + + previousX = cell.x + } + + flushRun() + + return { + pathSegments, + runs, + } +} diff --git a/packages/editor/src/react-three-fiber.d.ts b/packages/editor/src/react-three-fiber.d.ts new file mode 100644 index 000000000..bcad821e7 --- /dev/null +++ b/packages/editor/src/react-three-fiber.d.ts @@ -0,0 +1,24 @@ +import type { ThreeElement, ThreeElements } from '@react-three/fiber' +import { LineBasicNodeMaterial } from 'three/webgpu' + +interface EditorThreeElements extends ThreeElements { + lineBasicNodeMaterial: ThreeElement +} + +declare module 'react' { + namespace JSX { + interface IntrinsicElements extends EditorThreeElements {} + } +} + +declare module 'react/jsx-runtime' { + namespace JSX { + interface IntrinsicElements extends EditorThreeElements {} + } +} + +declare module 'react/jsx-dev-runtime' { + namespace JSX { + interface IntrinsicElements extends EditorThreeElements {} + } +} diff --git a/packages/editor/src/store/use-navigation-drafts.ts b/packages/editor/src/store/use-navigation-drafts.ts new file mode 100644 index 000000000..319b8c803 --- /dev/null +++ b/packages/editor/src/store/use-navigation-drafts.ts @@ -0,0 +1,46 @@ +import type { ItemNode } from '@pascal-app/core' +import { create } from 'zustand' + +type NavigationDraftState = { + robotCopySourceIds: Partial> + setRobotCopySourceId: (draftId: ItemNode['id'], sourceId: ItemNode['id'] | null) => void +} + +const useNavigationDraftState = create((set) => ({ + robotCopySourceIds: {}, + setRobotCopySourceId: (draftId, sourceId) => + set((state) => { + const currentSourceId = state.robotCopySourceIds[draftId] ?? null + if (currentSourceId === sourceId) { + return state + } + + const robotCopySourceIds = { ...state.robotCopySourceIds } + if (sourceId === null) { + delete robotCopySourceIds[draftId] + } else { + robotCopySourceIds[draftId] = sourceId + } + + return { robotCopySourceIds } + }), +})) + +export function getNavigationDraftRobotCopySourceId( + draftId: ItemNode['id'] | null | undefined, +): ItemNode['id'] | null { + if (!draftId) { + return null + } + + return useNavigationDraftState.getState().robotCopySourceIds[draftId] ?? null +} + +export function setNavigationDraftRobotCopySourceId( + draftId: ItemNode['id'], + sourceId: ItemNode['id'] | null, +) { + useNavigationDraftState.getState().setRobotCopySourceId(draftId, sourceId) +} + +export default useNavigationDraftState diff --git a/packages/editor/src/store/use-navigation-visuals.ts b/packages/editor/src/store/use-navigation-visuals.ts new file mode 100644 index 000000000..8c0ac5e13 --- /dev/null +++ b/packages/editor/src/store/use-navigation-visuals.ts @@ -0,0 +1,222 @@ +import type { BaseNode } from '@pascal-app/core' +import type { + ViewerRuntimeItemMoveVisualState as ItemMoveVisualState, + ViewerRuntimeItemDeleteActivation, + ViewerRuntimeItemMovePreview, + ViewerRuntimePostWarmupScope, + ViewerRuntimeState, +} from '@pascal-app/viewer' +import { useStore } from 'zustand' +import { createStore } from 'zustand/vanilla' + +export type ToolConeIsolatedOverlayPoint = { + isApex: boolean + worldPoint: [number, number, number] +} + +export type ToolConeIsolatedOverlay = { + apexWorldPoint?: [number, number, number] | null + color?: string | null + hullPoints: ToolConeIsolatedOverlayPoint[] + supportWorldPoints?: Array<[number, number, number]> + visible: boolean +} + +export type ToolConeOverlayCamera = { + position: [number, number, number] + projectionMatrix: number[] + projectionMatrixInverse: number[] + quaternion: [number, number, number, number] +} + +type NavigationVisualState = ViewerRuntimeState & { + activateItemDelete: (id: BaseNode['id']) => void + beginItemDeleteFade: (id: BaseNode['id'], startedAtMs?: number) => void + clearItemDelete: (id?: BaseNode['id'] | null) => void + registerTaskPreviewNode: (id: string) => void + resetTaskQueueVisuals: () => void + setItemMovePreview: (preview: ViewerRuntimeItemMovePreview | null) => void + setItemMoveVisualState: (id: BaseNode['id'], state: ItemMoveVisualState | null) => void + setNodeVisibilityOverride: (id: BaseNode['id'], visible: boolean | null) => void + setToolConeIsolatedOverlay: (overlay: ToolConeIsolatedOverlay | null) => void + setToolConeOverlayCamera: (camera: ToolConeOverlayCamera | null) => void + setToolConeOverlayEnabled: (enabled: boolean) => void + setToolConeOverlayWarmupReady: (ready: boolean) => void + taskPreviewNodeIds: Record + toolConeIsolatedOverlay: ToolConeIsolatedOverlay | null + toolConeOverlayCamera: ToolConeOverlayCamera | null + toolConeOverlayEnabled: boolean + toolConeOverlayWarmupReady: boolean + unregisterTaskPreviewNode: (id?: string | null) => void +} + +const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) + +const navigationVisualsStore = createStore()((set) => ({ + activateItemDelete: (id) => + set((state) => ({ + itemDeleteActivations: { + ...state.itemDeleteActivations, + [id]: { + fadeStartedAtMs: null, + startedAtMs: now(), + }, + }, + })), + beginItemDeleteFade: (id, startedAtMs) => + set((state) => { + const activation = state.itemDeleteActivations[id] + if (!activation || activation.fadeStartedAtMs !== null) { + return state + } + + return { + itemDeleteActivations: { + ...state.itemDeleteActivations, + [id]: { + ...activation, + fadeStartedAtMs: startedAtMs ?? now(), + }, + }, + } + }), + clearItemDelete: (id) => + set((state) => { + if (!id) { + return Object.keys(state.itemDeleteActivations).length === 0 + ? state + : { itemDeleteActivations: {} } + } + + if (!state.itemDeleteActivations[id]) { + return state + } + + const itemDeleteActivations = { ...state.itemDeleteActivations } + delete itemDeleteActivations[id] + return { itemDeleteActivations } + }), + registerTaskPreviewNode: (id) => + set((state) => + state.taskPreviewNodeIds[id] + ? state + : { + taskPreviewNodeIds: { + ...state.taskPreviewNodeIds, + [id]: true, + }, + }, + ), + resetTaskQueueVisuals: () => + set((state) => { + const nextItemMoveVisualStates: Partial> = {} + let removedPendingMoveVisual = false + + for (const [itemId, visualState] of Object.entries(state.itemMoveVisualStates)) { + if (visualState === 'copy-source-pending' || visualState === 'source-pending') { + removedPendingMoveVisual = true + continue + } + + nextItemMoveVisualStates[itemId as BaseNode['id']] = visualState + } + + const hasTaskQueueVisuals = + state.itemMovePreview !== null || + Object.keys(state.itemDeleteActivations).length > 0 || + Object.keys(state.taskPreviewNodeIds).length > 0 || + removedPendingMoveVisual + + if (!hasTaskQueueVisuals) { + return state + } + + return { + itemDeleteActivations: {}, + itemMovePreview: null, + itemMoveVisualStates: nextItemMoveVisualStates, + taskPreviewNodeIds: {}, + } + }), + completeNavigationPostWarmup: (token) => + set((state) => + token <= state.navigationPostWarmupCompletedToken + ? state + : { navigationPostWarmupCompletedToken: token }, + ), + itemDeleteActivations: {} as Partial>, + itemMovePreview: null, + itemMoveVisualStates: {} as Partial>, + navigationPostWarmupCompletedToken: 0, + navigationPostWarmupRequestToken: 0, + navigationPostWarmupScope: null as ViewerRuntimePostWarmupScope, + nodeVisibilityOverrides: {} as Partial>, + requestNavigationPostWarmup: () => { + let nextToken = 0 + set((state) => { + nextToken = state.navigationPostWarmupRequestToken + 1 + return { navigationPostWarmupRequestToken: nextToken } + }) + return nextToken + }, + setItemMovePreview: (itemMovePreview) => set({ itemMovePreview }), + setItemMoveVisualState: (id, state) => + set((currentState) => { + const currentValue = currentState.itemMoveVisualStates[id] + if ((currentValue ?? null) === state) { + return currentState + } + + const itemMoveVisualStates = { ...currentState.itemMoveVisualStates } + if (state) { + itemMoveVisualStates[id] = state + } else { + delete itemMoveVisualStates[id] + } + + return { itemMoveVisualStates } + }), + setNavigationPostWarmupScope: (navigationPostWarmupScope) => set({ navigationPostWarmupScope }), + setNodeVisibilityOverride: (id, visible) => + set((currentState) => { + const currentValue = currentState.nodeVisibilityOverrides[id] + if ((currentValue ?? null) === visible) { + return currentState + } + + const nodeVisibilityOverrides = { ...currentState.nodeVisibilityOverrides } + if (visible === null) { + delete nodeVisibilityOverrides[id] + } else { + nodeVisibilityOverrides[id] = visible + } + + return { nodeVisibilityOverrides } + }), + setToolConeIsolatedOverlay: (toolConeIsolatedOverlay) => set({ toolConeIsolatedOverlay }), + setToolConeOverlayCamera: (toolConeOverlayCamera) => set({ toolConeOverlayCamera }), + setToolConeOverlayEnabled: (toolConeOverlayEnabled) => set({ toolConeOverlayEnabled }), + setToolConeOverlayWarmupReady: (toolConeOverlayWarmupReady) => + set({ toolConeOverlayWarmupReady }), + taskPreviewNodeIds: {}, + toolConeIsolatedOverlay: null, + toolConeOverlayCamera: null, + toolConeOverlayEnabled: false, + toolConeOverlayWarmupReady: false, + unregisterTaskPreviewNode: (id) => + set((state) => { + if (!id || !state.taskPreviewNodeIds[id]) { + return state + } + + const taskPreviewNodeIds = { ...state.taskPreviewNodeIds } + delete taskPreviewNodeIds[id] + return { taskPreviewNodeIds } + }), +})) + +export function useNavigationVisuals(selector: (state: NavigationVisualState) => T): T { + return useStore(navigationVisualsStore, selector) +} + +export default navigationVisualsStore diff --git a/packages/editor/src/store/use-navigation.ts b/packages/editor/src/store/use-navigation.ts new file mode 100644 index 000000000..2e1163b2d --- /dev/null +++ b/packages/editor/src/store/use-navigation.ts @@ -0,0 +1,729 @@ +'use client' + +import { type AnyNodeId, getScaledDimensions, type ItemNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import mitt from 'mitt' +import { create } from 'zustand' +import useEditor from './use-editor' +import navigationVisualsStore from './use-navigation-visuals' + +type NavigationEvents = { + 'navigation:actor-transform': { + moving: boolean + position: [number, number, number] | null + rotationY: number + } + 'navigation:look-at': { + position: [number, number, number] + target: [number, number, number] + } +} + +export const navigationEmitter = mitt() + +export type WallOverlayFilters = { + carveDoorPortals: boolean + excludeObstacleItems: boolean + expandByClearance: boolean + requireSupportingSurface: boolean +} + +export const DEFAULT_WALL_OVERLAY_FILTERS: WallOverlayFilters = { + carveDoorPortals: true, + excludeObstacleItems: true, + expandByClearance: true, + requireSupportingSurface: true, +} + +export type NavigationItemMoveController = { + beginCarry: () => void + cancel: () => void + commit: ( + finalUpdate: Partial, + finalCarryTransform?: { position: [number, number, number]; rotation: number }, + ) => void + itemId: ItemNode['id'] + updateCarryTransform: (position: [number, number, number], rotationY: number) => void +} + +export type NavigationItemMoveRequest = { + finalUpdate: Partial + itemDimensions: [number, number, number] + itemId: ItemNode['id'] + levelId: string | null + sourcePosition: [number, number, number] + sourceRotation: [number, number, number] + targetPreviewItemId?: ItemNode['id'] | null + visualItemId?: ItemNode['id'] | null +} + +export type NavigationItemDeleteRequest = { + itemDimensions: [number, number, number] + itemId: ItemNode['id'] + levelId: string | null + sourcePosition: [number, number, number] + sourceRotation: [number, number, number] +} + +export type NavigationItemRepairRequest = { + itemDimensions: [number, number, number] + itemId: ItemNode['id'] + levelId: string | null + sourcePosition: [number, number, number] + sourceRotation: [number, number, number] +} + +export type NavigationTaskKind = 'delete' | 'move' | 'repair' +export type NavigationRobotModel = 'armored' | 'pascal' +export type NavigationRobotMode = 'normal' | 'task' + +export type NavigationQueuedTask = + | { + kind: 'delete' + request: NavigationItemDeleteRequest + taskId: string + } + | { + kind: 'move' + request: NavigationItemMoveRequest + taskId: string + } + | { + kind: 'repair' + request: NavigationItemRepairRequest + taskId: string + } + +export type NavigationTaskAdvanceResult = { + hasQueuedTask: boolean + wrappedToStart: boolean +} + +let navigationTaskSequence = 0 + +function createNavigationTaskId(kind: NavigationTaskKind) { + navigationTaskSequence += 1 + return `${kind}-${navigationTaskSequence}` +} + +function cloneNavigationItemDeleteRequest( + request: NavigationItemDeleteRequest, +): NavigationItemDeleteRequest { + return { + ...request, + itemDimensions: [...request.itemDimensions] as [number, number, number], + sourcePosition: [...request.sourcePosition] as [number, number, number], + sourceRotation: [...request.sourceRotation] as [number, number, number], + } +} + +function cloneNavigationItemMoveRequest( + request: NavigationItemMoveRequest, +): NavigationItemMoveRequest { + return { + ...request, + finalUpdate: { + ...request.finalUpdate, + position: request.finalUpdate.position + ? ([...request.finalUpdate.position] as [number, number, number]) + : request.finalUpdate.position, + rotation: request.finalUpdate.rotation + ? ([...request.finalUpdate.rotation] as [number, number, number]) + : request.finalUpdate.rotation, + }, + itemDimensions: [...request.itemDimensions] as [number, number, number], + sourcePosition: [...request.sourcePosition] as [number, number, number], + sourceRotation: [...request.sourceRotation] as [number, number, number], + } +} + +function isNavigationItemMoveCopyRequest(request: NavigationItemMoveRequest) { + return Boolean(request.visualItemId && request.visualItemId !== request.itemId) +} + +function getNavigationItemMoveQueueKey(request: NavigationItemMoveRequest) { + if (!isNavigationItemMoveCopyRequest(request)) { + return `move:${request.itemId}` + } + + return `copy:${request.itemId}:${request.targetPreviewItemId ?? request.visualItemId}` +} + +function deriveActiveRequestsAfterQueueEdit( + taskQueue: NavigationQueuedTask[], + activeTaskIndex: number, + activeTaskId: string | null, + queueRestartToken: number, + shouldRestart: boolean, +) { + if (shouldRestart) { + return deriveRestartedActiveRequests(taskQueue, queueRestartToken) + } + + if (activeTaskId) { + const nextActiveTaskIndex = taskQueue.findIndex((task) => task.taskId === activeTaskId) + if (nextActiveTaskIndex >= 0) { + return deriveActiveRequests(taskQueue, nextActiveTaskIndex) + } + } + + return deriveActiveRequests(taskQueue, activeTaskIndex) +} + +function cloneNavigationItemRepairRequest( + request: NavigationItemRepairRequest, +): NavigationItemRepairRequest { + return { + ...request, + itemDimensions: [...request.itemDimensions] as [number, number, number], + sourcePosition: [...request.sourcePosition] as [number, number, number], + sourceRotation: [...request.sourceRotation] as [number, number, number], + } +} + +function getNormalizedTaskIndex(taskQueue: NavigationQueuedTask[], activeTaskIndex: number) { + if (taskQueue.length === 0) { + return 0 + } + + return Math.min(Math.max(activeTaskIndex, 0), taskQueue.length - 1) +} + +function deriveActiveRequests(taskQueue: NavigationQueuedTask[], activeTaskIndex: number) { + const normalizedTaskIndex = getNormalizedTaskIndex(taskQueue, activeTaskIndex) + const activeTask = taskQueue[normalizedTaskIndex] ?? null + return { + activeTaskId: activeTask?.taskId ?? null, + activeTaskIndex: activeTask ? normalizedTaskIndex : 0, + itemDeleteRequest: + activeTask?.kind === 'delete' ? cloneNavigationItemDeleteRequest(activeTask.request) : null, + itemMoveRequest: + activeTask?.kind === 'move' ? cloneNavigationItemMoveRequest(activeTask.request) : null, + itemRepairRequest: + activeTask?.kind === 'repair' ? cloneNavigationItemRepairRequest(activeTask.request) : null, + taskQueue, + } +} + +function deriveRestartedActiveRequests( + taskQueue: NavigationQueuedTask[], + queueRestartToken: number, +) { + return { + ...deriveActiveRequests(taskQueue, 0), + queueRestartToken: queueRestartToken + 1, + } +} + +function moveTaskToIndex( + taskQueue: NavigationQueuedTask[], + taskId: string, + targetIndex: number, +): NavigationQueuedTask[] | null { + const sourceIndex = taskQueue.findIndex((task) => task.taskId === taskId) + if (sourceIndex < 0) { + return null + } + + const normalizedTargetIndex = Math.min( + Math.max(targetIndex, 0), + Math.max(0, taskQueue.length - 1), + ) + if (sourceIndex === normalizedTargetIndex) { + return null + } + + const nextTaskQueue = [...taskQueue] + const [movedTask] = nextTaskQueue.splice(sourceIndex, 1) + if (!movedTask) { + return null + } + + nextTaskQueue.splice(normalizedTargetIndex, 0, movedTask) + return nextTaskQueue +} + +function removeActiveTaskOfKind( + taskQueue: NavigationQueuedTask[], + activeTaskIndex: number, + kind: NavigationTaskKind, +) { + const normalizedTaskIndex = getNormalizedTaskIndex(taskQueue, activeTaskIndex) + const activeTask = taskQueue[normalizedTaskIndex] ?? null + if (!activeTask || activeTask.kind !== kind) { + return null + } + + const nextTaskQueue = taskQueue.filter((task) => task.taskId !== activeTask.taskId) + if (nextTaskQueue.length === 0) { + return deriveActiveRequests([], 0) + } + + const nextTaskIndex = normalizedTaskIndex % nextTaskQueue.length + return deriveActiveRequests(nextTaskQueue, nextTaskIndex) +} + +function deriveAdvancedActiveRequests(taskQueue: NavigationQueuedTask[], activeTaskIndex: number) { + if (taskQueue.length === 0) { + return deriveActiveRequests([], 0) + } + + const normalizedTaskIndex = getNormalizedTaskIndex(taskQueue, activeTaskIndex) + const nextTaskIndex = (normalizedTaskIndex + 1) % taskQueue.length + return deriveActiveRequests(taskQueue, nextTaskIndex) +} + +type NavigationState = { + activeTaskId: string | null + activeTaskIndex: number + advanceTaskQueue: () => NavigationTaskAdvanceResult + beginTaskLoopReset: () => number + actorAvailable: boolean + actorWorldPosition: [number, number, number] | null + enabled: boolean + followRobotEnabled: boolean + itemDeleteRequest: NavigationItemDeleteRequest | null + itemMoveControllers: Partial> + itemMoveLocked: boolean + itemMoveRequest: NavigationItemMoveRequest | null + itemRepairRequest: NavigationItemRepairRequest | null + moveItemsEnabled: boolean + moveQueuedTask: (taskId: string, targetIndex: number) => void + navigationClickSuppressedUntil: number + queueRestartToken: number + removeQueuedTask: (taskId: string) => void + robotModel: NavigationRobotModel + robotMode: NavigationRobotMode | null + registerItemMoveController: ( + itemId: ItemNode['id'], + controller: NavigationItemMoveController | null, + ) => void + removeQueuedTasksForItem: (kind: NavigationTaskKind, itemId: ItemNode['id']) => void + reorderQueuedTask: (taskId: string, targetTaskId: string) => void + requestItemDelete: (request: NavigationItemDeleteRequest | null) => void + requestItemMove: (request: NavigationItemMoveRequest | null) => void + requestItemRepair: (request: NavigationItemRepairRequest | null) => void + setActiveTask: (taskId: string) => void + setActorAvailable: (actorAvailable: boolean) => void + setActorWorldPosition: (actorWorldPosition: [number, number, number] | null) => void + setEnabled: (enabled: boolean) => void + setRobotModel: (model: NavigationRobotModel) => void + setRobotMode: (mode: NavigationRobotMode | null) => void + setWallOverlayFilter: ( + key: K, + value: WallOverlayFilters[K], + ) => void + wallOverlayFilters: WallOverlayFilters + setFollowRobotEnabled: (followRobotEnabled: boolean) => void + setItemMoveLocked: (locked: boolean) => void + setMoveItemsEnabled: (enabled: boolean) => void + setTaskLoopSettledToken: (token: number) => void + setWalkableOverlayVisible: (walkableOverlayVisible: boolean) => void + suppressNavigationClick: (durationMs?: number) => void + taskQueue: NavigationQueuedTask[] + taskLoopSettledToken: number + taskLoopToken: number + walkableOverlayVisible: boolean +} + +const useNavigation = create((set) => ({ + activeTaskId: null, + activeTaskIndex: 0, + advanceTaskQueue: () => { + const result: NavigationTaskAdvanceResult = { + hasQueuedTask: false, + wrappedToStart: false, + } + + set((state) => { + if (state.taskQueue.length === 0) { + return state + } + + const normalizedTaskIndex = getNormalizedTaskIndex(state.taskQueue, state.activeTaskIndex) + const nextTaskIndex = (normalizedTaskIndex + 1) % state.taskQueue.length + const wrappedToStart = nextTaskIndex === 0 + const nextState = deriveAdvancedActiveRequests(state.taskQueue, normalizedTaskIndex) + result.hasQueuedTask = nextState.taskQueue.length > 0 + result.wrappedToStart = wrappedToStart + return nextState + }) + + return result + }, + beginTaskLoopReset: () => { + let nextTaskLoopToken = 0 + set((state) => { + nextTaskLoopToken = state.taskLoopToken + 1 + return { + taskLoopToken: nextTaskLoopToken, + } + }) + return nextTaskLoopToken + }, + actorAvailable: false, + actorWorldPosition: null, + enabled: false, + followRobotEnabled: false, + itemDeleteRequest: null, + itemMoveControllers: {}, + itemMoveLocked: false, + itemMoveRequest: null, + itemRepairRequest: null, + moveItemsEnabled: false, + moveQueuedTask: (taskId, targetIndex) => + set((state) => { + const nextTaskQueue = moveTaskToIndex(state.taskQueue, taskId, targetIndex) + if (!nextTaskQueue) { + return state + } + + return deriveRestartedActiveRequests(nextTaskQueue, state.queueRestartToken) + }), + navigationClickSuppressedUntil: 0, + queueRestartToken: 0, + removeQueuedTask: (taskId) => + set((state) => { + const nextTaskQueue = state.taskQueue.filter((task) => task.taskId !== taskId) + if (nextTaskQueue.length === state.taskQueue.length) { + return state + } + + const removedActiveTask = + state.taskQueue[getNormalizedTaskIndex(state.taskQueue, state.activeTaskIndex)]?.taskId === + taskId + return deriveActiveRequestsAfterQueueEdit( + nextTaskQueue, + state.activeTaskIndex, + state.activeTaskId, + state.queueRestartToken, + removedActiveTask, + ) + }), + robotModel: 'pascal', + robotMode: null, + registerItemMoveController: (itemId, controller) => + set((state) => { + const currentController = state.itemMoveControllers[itemId] ?? null + if (currentController === controller) { + return state + } + + const itemMoveControllers = { ...state.itemMoveControllers } + if (controller) { + itemMoveControllers[itemId] = controller + } else { + delete itemMoveControllers[itemId] + } + + return { itemMoveControllers } + }), + removeQueuedTasksForItem: (kind, itemId) => + set((state) => { + const activeTask = + state.taskQueue[getNormalizedTaskIndex(state.taskQueue, state.activeTaskIndex)] ?? null + const nextTaskQueue = state.taskQueue.filter( + (task) => !(task.kind === kind && task.request.itemId === itemId), + ) + if (nextTaskQueue.length === state.taskQueue.length) { + return state + } + + const removedActiveTask = + activeTask !== null && activeTask.kind === kind && activeTask.request.itemId === itemId + return deriveActiveRequestsAfterQueueEdit( + nextTaskQueue, + state.activeTaskIndex, + state.activeTaskId, + state.queueRestartToken, + removedActiveTask, + ) + }), + reorderQueuedTask: (taskId, targetTaskId) => + set((state) => { + const targetTaskIndex = state.taskQueue.findIndex((task) => task.taskId === targetTaskId) + if (taskId === targetTaskId || targetTaskIndex < 0) { + return state + } + + const nextTaskQueue = moveTaskToIndex(state.taskQueue, taskId, targetTaskIndex) + if (!nextTaskQueue) { + return state + } + + return deriveRestartedActiveRequests(nextTaskQueue, state.queueRestartToken) + }), + requestItemDelete: (itemDeleteRequest) => + set((state) => { + if (itemDeleteRequest === null) { + return removeActiveTaskOfKind(state.taskQueue, state.activeTaskIndex, 'delete') ?? state + } + + const existingTaskIndex = state.taskQueue.findIndex( + (task) => task.kind === 'delete' && task.request.itemId === itemDeleteRequest.itemId, + ) + if (existingTaskIndex >= 0) { + const nextTaskQueue = [...state.taskQueue] + const existingTask = nextTaskQueue[existingTaskIndex] + if (!existingTask || existingTask.kind !== 'delete') { + return state + } + + nextTaskQueue[existingTaskIndex] = { + ...existingTask, + request: cloneNavigationItemDeleteRequest(itemDeleteRequest), + } + return deriveActiveRequestsAfterQueueEdit( + nextTaskQueue, + state.activeTaskIndex, + state.activeTaskId, + state.queueRestartToken, + existingTaskIndex === getNormalizedTaskIndex(state.taskQueue, state.activeTaskIndex), + ) + } + + const nextTaskQueue: NavigationQueuedTask[] = [ + ...state.taskQueue, + { + kind: 'delete', + request: cloneNavigationItemDeleteRequest(itemDeleteRequest), + taskId: createNavigationTaskId('delete'), + }, + ] + return deriveActiveRequestsAfterQueueEdit( + nextTaskQueue, + state.activeTaskIndex, + state.activeTaskId, + state.queueRestartToken, + state.taskQueue.length === 0, + ) + }), + requestItemMove: (itemMoveRequest) => + set((state) => { + if (itemMoveRequest === null) { + return removeActiveTaskOfKind(state.taskQueue, state.activeTaskIndex, 'move') ?? state + } + + const nextMoveRequestKey = getNavigationItemMoveQueueKey(itemMoveRequest) + const existingTaskIndex = state.taskQueue.findIndex( + (task) => + task.kind === 'move' && + getNavigationItemMoveQueueKey(task.request) === nextMoveRequestKey, + ) + if (existingTaskIndex >= 0) { + const nextTaskQueue = [...state.taskQueue] + const existingTask = nextTaskQueue[existingTaskIndex] + if (!existingTask || existingTask.kind !== 'move') { + return state + } + + nextTaskQueue[existingTaskIndex] = { + ...existingTask, + request: cloneNavigationItemMoveRequest(itemMoveRequest), + } + return deriveActiveRequestsAfterQueueEdit( + nextTaskQueue, + state.activeTaskIndex, + state.activeTaskId, + state.queueRestartToken, + existingTaskIndex === getNormalizedTaskIndex(state.taskQueue, state.activeTaskIndex), + ) + } + + const nextTaskQueue: NavigationQueuedTask[] = [ + ...state.taskQueue, + { + kind: 'move', + request: cloneNavigationItemMoveRequest(itemMoveRequest), + taskId: createNavigationTaskId('move'), + }, + ] + return deriveActiveRequestsAfterQueueEdit( + nextTaskQueue, + state.activeTaskIndex, + state.activeTaskId, + state.queueRestartToken, + state.taskQueue.length === 0, + ) + }), + requestItemRepair: (itemRepairRequest) => + set((state) => { + if (itemRepairRequest === null) { + return removeActiveTaskOfKind(state.taskQueue, state.activeTaskIndex, 'repair') ?? state + } + + const existingTaskIndex = state.taskQueue.findIndex( + (task) => task.kind === 'repair' && task.request.itemId === itemRepairRequest.itemId, + ) + if (existingTaskIndex >= 0) { + const nextTaskQueue = [...state.taskQueue] + const existingTask = nextTaskQueue[existingTaskIndex] + if (!existingTask || existingTask.kind !== 'repair') { + return state + } + + nextTaskQueue[existingTaskIndex] = { + ...existingTask, + request: cloneNavigationItemRepairRequest(itemRepairRequest), + } + return deriveActiveRequestsAfterQueueEdit( + nextTaskQueue, + state.activeTaskIndex, + state.activeTaskId, + state.queueRestartToken, + existingTaskIndex === getNormalizedTaskIndex(state.taskQueue, state.activeTaskIndex), + ) + } + + const nextTaskQueue: NavigationQueuedTask[] = [ + ...state.taskQueue, + { + kind: 'repair', + request: cloneNavigationItemRepairRequest(itemRepairRequest), + taskId: createNavigationTaskId('repair'), + }, + ] + return deriveActiveRequestsAfterQueueEdit( + nextTaskQueue, + state.activeTaskIndex, + state.activeTaskId, + state.queueRestartToken, + state.taskQueue.length === 0, + ) + }), + setActiveTask: (taskId) => + set((state) => { + const nextTaskIndex = state.taskQueue.findIndex((task) => task.taskId === taskId) + if (nextTaskIndex < 0) { + return state + } + + return deriveActiveRequests(state.taskQueue, nextTaskIndex) + }), + setActorAvailable: (actorAvailable) => set({ actorAvailable }), + setActorWorldPosition: (actorWorldPosition) => set({ actorWorldPosition }), + setEnabled: (enabled) => + set((state) => { + const nextRobotMode = enabled ? (state.robotMode ?? 'task') : null + return { + enabled, + followRobotEnabled: enabled ? state.followRobotEnabled : false, + moveItemsEnabled: enabled, + robotMode: nextRobotMode, + } + }), + setRobotModel: (robotModel) => set({ robotModel }), + setRobotMode: (robotMode) => + set((state) => { + if (state.robotMode === robotMode) { + return state + } + + return { + enabled: robotMode !== null, + followRobotEnabled: robotMode !== null ? state.followRobotEnabled : false, + moveItemsEnabled: robotMode !== null, + robotMode, + } + }), + setWallOverlayFilter: (key, value) => + set((state) => ({ + wallOverlayFilters: { + ...state.wallOverlayFilters, + [key]: value, + }, + })), + wallOverlayFilters: DEFAULT_WALL_OVERLAY_FILTERS, + setFollowRobotEnabled: (followRobotEnabled) => set({ followRobotEnabled }), + setItemMoveLocked: (itemMoveLocked) => set({ itemMoveLocked }), + setMoveItemsEnabled: (moveItemsEnabled) => set({ moveItemsEnabled }), + setTaskLoopSettledToken: (taskLoopSettledToken) => set({ taskLoopSettledToken }), + setWalkableOverlayVisible: (walkableOverlayVisible) => set({ walkableOverlayVisible }), + suppressNavigationClick: (durationMs = 250) => + set({ navigationClickSuppressedUntil: performance.now() + durationMs }), + taskQueue: [], + taskLoopSettledToken: 0, + taskLoopToken: 0, + walkableOverlayVisible: false, +})) + +function canUseRobotItemTask(node: ItemNode) { + const { enabled, moveItemsEnabled } = useNavigation.getState() + if (!enabled || !moveItemsEnabled || node.asset.attachTo) { + return false + } + + const parentNode = node.parentId ? useScene.getState().nodes[node.parentId as AnyNodeId] : null + return parentNode?.type !== 'item' +} + +export function requestNavigationItemDelete(node: ItemNode) { + if (!canUseRobotItemTask(node)) { + return false + } + + const navigationState = useNavigation.getState() + const viewerState = useViewer.getState() + const editorState = useEditor.getState() + const taskAlreadyAssigned = + Boolean(navigationVisualsStore.getState().itemDeleteActivations[node.id]) || + navigationState.taskQueue.some((task) => task.request.itemId === node.id) + + if (taskAlreadyAssigned) { + viewerState.setHoveredId(null) + viewerState.setSelection({ selectedIds: [] }) + return true + } + + if (editorState.movingNode) { + return true + } + + navigationVisualsStore.getState().activateItemDelete(node.id) + viewerState.setHoveredId(null) + viewerState.setSelection({ selectedIds: [] }) + navigationState.requestItemDelete({ + itemDimensions: getScaledDimensions(node), + itemId: node.id, + levelId: node.parentId, + sourcePosition: [...node.position] as [number, number, number], + sourceRotation: [...node.rotation] as [number, number, number], + }) + return true +} + +export function requestNavigationItemRepair(node: ItemNode) { + if (!canUseRobotItemTask(node)) { + return false + } + + const navigationState = useNavigation.getState() + const viewerState = useViewer.getState() + const editorState = useEditor.getState() + const taskAlreadyAssigned = + Boolean(navigationVisualsStore.getState().itemDeleteActivations[node.id]) || + navigationState.taskQueue.some((task) => task.request.itemId === node.id) + + if (taskAlreadyAssigned) { + viewerState.setHoveredId(null) + viewerState.setSelection({ selectedIds: [] }) + return true + } + + if (editorState.movingNode) { + return true + } + + viewerState.setHoveredId(null) + viewerState.setSelection({ selectedIds: [] }) + navigationState.requestItemRepair({ + itemDimensions: getScaledDimensions(node), + itemId: node.id, + levelId: node.parentId, + sourcePosition: [...node.position] as [number, number, number], + sourceRotation: [...node.rotation] as [number, number, number], + }) + return true +} + +export default useNavigation diff --git a/packages/editor/src/workers/navigation-graph-worker.ts b/packages/editor/src/workers/navigation-graph-worker.ts new file mode 100644 index 000000000..15d1163bc --- /dev/null +++ b/packages/editor/src/workers/navigation-graph-worker.ts @@ -0,0 +1,38 @@ +/// + +import type { AnyNode, BuildingNode } from '@pascal-app/core' +import { buildNavigationGraph } from '../lib/navigation' + +type NavigationGraphWorkerRequest = { + buildingId: BuildingNode['id'] | null + nodes: Record + requestId: number + rootNodeIds: string[] +} + +type NavigationGraphWorkerResponse = + | { + graph: ReturnType + requestId: number + } + | { + error: string + requestId: number + } + +self.onmessage = (event: MessageEvent) => { + const { buildingId, nodes, requestId, rootNodeIds } = event.data + + try { + const graph = buildNavigationGraph(nodes, rootNodeIds, buildingId) + ;(self as DedicatedWorkerGlobalScope).postMessage({ + graph, + requestId, + } satisfies NavigationGraphWorkerResponse) + } catch (error) { + ;(self as DedicatedWorkerGlobalScope).postMessage({ + error: error instanceof Error ? error.message : String(error), + requestId, + } satisfies NavigationGraphWorkerResponse) + } +} diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 5366d3d54..a5b62a51b 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -2,33 +2,213 @@ import { type AnimationEffect, type AnyNodeId, baseMaterial, + getScaledDimensions, glassMaterial, type Interactive, type ItemNode, type LightEffect, useInteractive, + useLiveTransforms, useRegistry, useScene, } from '@pascal-app/core' import { useAnimations } from '@react-three/drei' import { Clone } from '@react-three/drei/core/Clone' import { useGLTF } from '@react-three/drei/core/Gltf' -import { useFrame } from '@react-three/fiber' -import { Suspense, useEffect, useMemo, useRef } from 'react' -import type { AnimationAction, Group, Material, Mesh } from 'three' -import { MathUtils } from 'three' +import { useFrame, useThree } from '@react-three/fiber' +import { Suspense, useCallback, useEffect, useMemo, useRef } from 'react' +import { + type AnimationAction, + type Camera, + Color, + CylinderGeometry, + DoubleSide, + EdgesGeometry, + Group, + type Material, + MathUtils, + Mesh, + Scene, + Vector3, +} from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { positionLocal, smoothstep, time } from 'three/tsl' -import { MeshStandardNodeMaterial } from 'three/webgpu' +import { MeshBasicNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' +import { + useViewerRuntimeState, + type ViewerRuntimeItemMoveVisualState, +} from '../../../contexts/viewer-runtime-state' import { useNodeEvents } from '../../../hooks/use-node-events' import { resolveCdnUrl } from '../../../lib/asset-url' +import { ITEM_DELETE_FADE_OUT_MS } from '../../../lib/item-delete-visual' import { useItemLightPool } from '../../../store/use-item-light-pool' import { ErrorBoundary } from '../../error-boundary' import { NodeRenderer } from '../node-renderer' -const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => { +const processedItemScenes = new WeakMap() +const optimizedStaticScenes = new WeakSet() +const itemCarryOverlayTemplateCache = new WeakMap>() +const itemCarryOverlayTemplateCompiledCache = new WeakMap>() +const itemCarryOverlayTemplateCompileInFlight = new WeakMap>() + +type TexturedMaterial = Material & { + alphaTest?: number + alphaMap?: unknown + aoMap?: unknown + aoMapIntensity?: number + color?: Color + depthTest?: boolean + depthWrite?: boolean + emissive?: Color + emissiveMap?: unknown + emissiveIntensity?: number + map?: unknown + metalness?: number + metalnessMap?: unknown + normalMap?: unknown + opacity?: number + roughness?: number + roughnessMap?: unknown + side?: number + transparent?: boolean +} + +type FadeMaterial = Material & { + depthWrite?: boolean + needsUpdate?: boolean + opacity?: number + transparent?: boolean + userData: Record & { + pascalDeleteBaseOpacity?: number + } +} + +const hasTextureMaps = (material: Material): boolean => { + const candidate = material as TexturedMaterial + return Boolean( + candidate.map || + candidate.normalMap || + candidate.emissiveMap || + candidate.metalnessMap || + candidate.roughnessMap || + candidate.alphaMap || + candidate.aoMap, + ) +} + +function shouldPreserveImportedMaterials(node: ItemNode) { + return ( + node.asset.id === 'pascal-truck' || + node.asset.src === '/items/pascal-truck/model.glb' || + node.asset.src.endsWith('/items/pascal-truck/model.glb') + ) +} + +function shouldOptimizeStaticScene(node: ItemNode) { + return node.asset.category !== 'door' && node.asset.category !== 'window' && !node.asset.attachTo +} + +function optimizeStaticSceneMeshes(root: Group) { + if (optimizedStaticScenes.has(root)) { + return + } + + root.updateWorldMatrix(true, true) + const rootInverseWorldMatrix = root.matrixWorld.clone().invert() + const mergedEntries = new Map< + string, + { + castShadow: boolean + geometries: ReturnType[] + material: Material + receiveShadow: boolean + } + >() + const removableMeshes: Mesh[] = [] + let hasUnsupportedMeshState = false + + root.traverse((child) => { + if (!(child as Mesh).isMesh) { + return + } + + const mesh = child as Mesh + if ( + mesh.name === 'cutout' || + Array.isArray(mesh.material) || + (mesh as Mesh & { isSkinnedMesh?: boolean }).isSkinnedMesh || + mesh.morphTargetInfluences?.length + ) { + hasUnsupportedMeshState = true + return + } + + const candidateMaterial = mesh.material as TexturedMaterial + if ( + candidateMaterial.transparent || + (candidateMaterial.opacity ?? 1) < 1 || + candidateMaterial.alphaMap || + candidateMaterial.map + ) { + return + } + + const material = mesh.material + const key = `${material.uuid}:${mesh.castShadow ? '1' : '0'}:${mesh.receiveShadow ? '1' : '0'}` + const entry = mergedEntries.get(key) ?? { + castShadow: mesh.castShadow, + geometries: [], + material, + receiveShadow: mesh.receiveShadow, + } + + const geometry = mesh.geometry.clone() + const matrixInRootSpace = rootInverseWorldMatrix.clone().multiply(mesh.matrixWorld) + geometry.applyMatrix4(matrixInRootSpace) + entry.geometries.push(geometry) + mergedEntries.set(key, entry) + removableMeshes.push(mesh) + }) + + if (hasUnsupportedMeshState || mergedEntries.size === 0 || removableMeshes.length <= 1) { + optimizedStaticScenes.add(root) + return + } + + for (const mesh of removableMeshes) { + mesh.removeFromParent() + mesh.geometry.dispose() + } + + for (const entry of mergedEntries.values()) { + const mergedGeometry = + entry.geometries.length === 1 + ? entry.geometries[0] + : (mergeGeometries(entry.geometries, false) ?? entry.geometries[0]) + const mergedMesh = new Mesh(mergedGeometry, entry.material) + mergedMesh.castShadow = entry.castShadow + mergedMesh.receiveShadow = entry.receiveShadow + root.add(mergedMesh) + } + + optimizedStaticScenes.add(root) +} + +const getMaterialForOriginal = ( + original: Material, + preserveImportedTexturedMaterials: boolean, +): Material => { if (original.name.toLowerCase() === 'glass') { return glassMaterial } + + if (preserveImportedTexturedMaterials && hasTextureMaps(original)) { + // Preserve imported GLTF materials with maps. WebGPU can consume + // standard materials directly, and replacing them discards the Pascal truck look. + original.needsUpdate = true + return original + } + return baseMaterial } @@ -45,19 +225,158 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => { export const ItemRenderer = ({ node }: { node: ItemNode }) => { const ref = useRef(null!) + const isWallDoorItem = node.asset.category === 'door' && node.asset.attachTo === 'wall' + const [width] = getScaledDimensions(node) + const hingeHintOffset = width / 2 + const itemMovePreview = useViewerRuntimeState((state) => + state.itemMovePreview?.sourceItemId === node.id ? state.itemMovePreview : null, + ) + const itemMovePreviewIsSceneBacked = useScene((state) => { + const previewId = itemMovePreview?.id + if (!previewId) { + return false + } + + return state.nodes[previewId as AnyNodeId]?.type === 'item' + }) + const liveTransform = useLiveTransforms((state) => state.transforms.get(node.id)) + const visualStateOverride = useViewerRuntimeState( + (state) => state.itemMoveVisualStates[node.id] ?? null, + ) + const visibilityOverride = useViewerRuntimeState( + (state) => state.nodeVisibilityOverrides[node.id], + ) + const itemDeleteActivation = useViewerRuntimeState( + (state) => state.itemDeleteActivations[node.id] ?? null, + ) + const deleteFadeStartedAtMs = itemDeleteActivation?.fadeStartedAtMs ?? null + const baseVisible = visibilityOverride ?? node.visible + const rotation = liveTransform + ? ([node.rotation[0] ?? 0, liveTransform.rotation, node.rotation[2] ?? 0] as [ + number, + number, + number, + ]) + : node.rotation useRegistry(node.id, node.type, ref) + useEffect(() => { + if (ref.current) { + ref.current.visible = baseVisible + } + }, [baseVisible]) + + useFrame(() => { + const group = ref.current + if (!group) { + return + } + + if (deleteFadeStartedAtMs === null) { + group.visible = baseVisible + return + } + + const fadeProgress = MathUtils.clamp( + ((typeof performance !== 'undefined' ? performance.now() : Date.now()) - + deleteFadeStartedAtMs) / + ITEM_DELETE_FADE_OUT_MS, + 0, + 1, + ) + const fadeAlpha = 1 - MathUtils.smootherstep(fadeProgress, 0, 1) + group.visible = baseVisible && fadeAlpha > ITEM_DELETE_VISIBILITY_EPSILON + }) + + return ( + <> + + {isWallDoorItem ? ( + + + }> + }> + + + + + + + ) : ( + }> + }> + + + + )} + {node.children?.map((childId) => ( + + ))} + + {itemMovePreview && !itemMovePreviewIsSceneBacked && ( + + )} + + ) +} + +const ItemMovePreviewGhost = ({ + previewId, + sourceNode, +}: { + previewId: ItemNode['id'] + sourceNode: ItemNode +}) => { + const ref = useRef(null!) + const liveTransform = useLiveTransforms((state) => state.transforms.get(previewId)) + const visualStateOverride = useViewerRuntimeState( + (state) => state.itemMoveVisualStates[previewId] ?? null, + ) + const visibilityOverride = useViewerRuntimeState( + (state) => state.nodeVisibilityOverrides[previewId], + ) + const previewNode = useMemo( + () => + ({ + ...sourceNode, + children: [], + id: previewId, + visible: true, + }) as ItemNode, + [previewId, sourceNode], + ) + const rotation = liveTransform + ? ([sourceNode.rotation[0] ?? 0, liveTransform.rotation, sourceNode.rotation[2] ?? 0] as [ + number, + number, + number, + ]) + : sourceNode.rotation + + useRegistry(previewId, 'item', ref) + return ( - - }> - }> - + + }> + }> + - {node.children?.map((childId) => ( - - ))} ) } @@ -74,6 +393,216 @@ const previewOpacity = smoothstep(0.42, 0.55, positionLocal.y.add(time.mul(-0.2) previewMaterial.opacityNode = previewOpacity previewMaterial.transparent = true +type ItemMoveVisualKind = + | 'copy-source-pending' + | 'destination-ghost' + | 'destination-preview' + | 'source-pending' +type MaterializedItemMoveVisualKind = Exclude< + ItemMoveVisualKind, + 'copy-source-pending' | 'source-pending' +> + +type ItemMoveVisualMaterial = Material & { + color?: Color + depthWrite?: boolean + emissive?: Color + emissiveIntensity?: number + needsUpdate?: boolean + opacity?: number + opacityNode?: unknown + transparent?: boolean +} + +type ItemMoveVisualMaterialEntry = { + kind: ItemMoveVisualKind + originalCastShadow: boolean + originalMaterial: Material | Material[] + originalReceiveShadow: boolean + visualMaterial: Material | Material[] +} + +type ItemDeleteFadeMaterialEntry = { + fadeMaterial: Material | Material[] + originalMaterial: Material | Material[] +} + +const destinationGhostOpacity = smoothstep( + 0.02, + 0.34, + positionLocal.x.mul(6.4).add(positionLocal.z.mul(9.2)).add(positionLocal.y.mul(3.2)).fract(), +) + .mul(0.34) + .add(0.24) + +const destinationPreviewOpacity = smoothstep( + 0.02, + 0.42, + positionLocal.x + .mul(6.1) + .add(positionLocal.z.mul(8.8)) + .add(positionLocal.y.mul(3)) + .add(time.mul(-0.18)) + .fract(), +) + .mul(0.4) + .add(0.3) + +const previewGhostDestinationGhostMaterial = new MeshBasicNodeMaterial({ + color: '#ffffff', + depthTest: false, + depthWrite: false, + opacity: 0.52, + transparent: true, +}) +previewGhostDestinationGhostMaterial.opacityNode = destinationGhostOpacity +previewGhostDestinationGhostMaterial.toneMapped = false + +const previewGhostDestinationPreviewMaterial = new MeshBasicNodeMaterial({ + color: '#ffffff', + depthTest: false, + depthWrite: false, + opacity: 0.68, + transparent: true, +}) +previewGhostDestinationPreviewMaterial.opacityNode = destinationPreviewOpacity +previewGhostDestinationPreviewMaterial.toneMapped = false + +function applyPreviewGhostMaterial(root: Group | null, material: Material) { + if (!root) { + return + } + + root.traverse((child) => { + const mesh = child as Mesh + if (!mesh.isMesh) { + return + } + + if (mesh.name === 'cutout') { + mesh.visible = false + return + } + + mesh.castShadow = false + mesh.material = material + mesh.receiveShadow = false + }) +} + +function disposeVisualMaterials(material: Material | Material[]) { + if (Array.isArray(material)) { + material.forEach((entry) => { + entry.dispose() + }) + return + } + + material.dispose() +} + +function createItemMoveVisualMaterial( + material: Material, + kind: MaterializedItemMoveVisualKind, +): ItemMoveVisualMaterial { + const visualMaterial = material.clone() as ItemMoveVisualMaterial + + if (kind === 'destination-preview') { + if (visualMaterial.color instanceof Color) { + visualMaterial.color = new Color('#ffffff') + } + + if (visualMaterial.emissive instanceof Color) { + visualMaterial.emissive = new Color('#ffffff') + visualMaterial.emissiveIntensity = 0.55 + } + + visualMaterial.depthTest = false + visualMaterial.depthWrite = false + visualMaterial.opacity = 0.68 + visualMaterial.opacityNode = destinationPreviewOpacity + visualMaterial.transparent = true + visualMaterial.needsUpdate = true + return visualMaterial + } + + if (visualMaterial.color instanceof Color) { + visualMaterial.color = new Color('#ffffff') + } + + if (visualMaterial.emissive instanceof Color) { + visualMaterial.emissive = new Color('#ffffff') + visualMaterial.emissiveIntensity = 0.4 + } + + visualMaterial.depthTest = false + visualMaterial.depthWrite = false + visualMaterial.opacity = 0.52 + visualMaterial.opacityNode = destinationGhostOpacity + visualMaterial.transparent = true + visualMaterial.needsUpdate = true + return visualMaterial +} + +function createItemMoveVisualMaterials( + material: Material | Material[], + kind: MaterializedItemMoveVisualKind, +): Material | Material[] { + if (Array.isArray(material)) { + return material.map((entry) => createItemMoveVisualMaterial(entry, kind)) + } + + return createItemMoveVisualMaterial(material, kind) +} + +function isRenderableMesh(object: unknown): object is Mesh { + return Boolean((object as Mesh | undefined)?.isMesh && (object as Mesh | undefined)?.material) +} + +const PreviewGhostModelRenderer = ({ + node, + visualStateOverride, +}: { + node: ItemNode + visualStateOverride: ViewerRuntimeItemMoveVisualState | null +}) => { + const assetSrc = resolveCdnUrl(node.asset.src) || '' + const { scene, animations } = useGLTF(assetSrc) + const ref = useRef(null!) + const renderScale = useMemo( + () => multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1]), + [node.asset.scale, node.scale], + ) + const ghostMaterial = + visualStateOverride === 'destination-preview' + ? previewGhostDestinationPreviewMaterial + : previewGhostDestinationGhostMaterial + + useRegistry(node.id, 'item', ref) + + useMemo(() => { + if (!shouldOptimizeStaticScene(node) || animations.length > 0) { + return + } + + optimizeStaticSceneMeshes(scene) + }, [animations.length, node, scene]) + + useEffect(() => { + applyPreviewGhostMaterial(ref.current, ghostMaterial) + }, [ghostMaterial]) + + return ( + + ) +} + const PreviewModel = ({ node }: { node: ItemNode }) => { return ( @@ -89,18 +618,295 @@ const multiplyScales = ( b: [number, number, number], ): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]] -const ModelRenderer = ({ node }: { node: ItemNode }) => { - const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '') +const ITEM_CARRY_OVERLAY_COLOR = '#52e8ff' +const ITEM_CARRY_OVERLAY_FILL_TRANSPARENCY_PERCENT = 95 +const ITEM_CARRY_OVERLAY_OUTLINE_THICKNESS = 0.002 +const ITEM_DELETE_VISIBILITY_EPSILON = 0.001 + +function createDeleteFadeMaterial(material: Material): Material { + const nextMaterial = material.clone() as FadeMaterial + nextMaterial.userData = { + ...nextMaterial.userData, + pascalDeleteBaseOpacity: nextMaterial.opacity ?? 1, + } + nextMaterial.transparent = true + nextMaterial.depthWrite = false + nextMaterial.needsUpdate = true + return nextMaterial +} + +function createDeleteFadeMaterials(material: Material | Material[]): Material | Material[] { + if (Array.isArray(material)) { + return material.map((entry) => createDeleteFadeMaterial(entry)) + } + + return createDeleteFadeMaterial(material) +} + +function applyDeleteFadeOpacity(material: Material | Material[], fadeAlpha: number) { + const applyOpacity = (entry: Material) => { + const fadeMaterial = entry as FadeMaterial + const baseOpacity = fadeMaterial.userData.pascalDeleteBaseOpacity ?? fadeMaterial.opacity ?? 1 + fadeMaterial.opacity = baseOpacity * fadeAlpha + fadeMaterial.transparent = fadeAlpha < 0.999 || baseOpacity < 0.999 + fadeMaterial.depthWrite = fadeAlpha >= 0.999 + fadeMaterial.needsUpdate = true + } + + if (Array.isArray(material)) { + material.forEach(applyOpacity) + return + } + + applyOpacity(material) +} + +function disposeDeleteFadeMaterials(material: Material | Material[]) { + if (Array.isArray(material)) { + material.forEach((entry) => { + entry.dispose() + }) + return + } + + material.dispose() +} + +function getItemCarryOverlayTemplateKey( + assetOffset: [number, number, number], + assetRotation: [number, number, number], + renderScale: [number, number, number], +) { + return `${assetOffset.join(',')}|${assetRotation.join(',')}|${renderScale.join(',')}` +} + +function getOrCreateItemCarryOverlayTemplate({ + assetOffset, + assetRotation, + modelScene, + renderScale, +}: { + assetOffset: [number, number, number] + assetRotation: [number, number, number] + modelScene: Group + renderScale: [number, number, number] +}) { + const templateKey = getItemCarryOverlayTemplateKey(assetOffset, assetRotation, renderScale) + let sceneTemplates = itemCarryOverlayTemplateCache.get(modelScene) + if (!sceneTemplates) { + sceneTemplates = new Map() + itemCarryOverlayTemplateCache.set(modelScene, sceneTemplates) + } + + const existingTemplate = sceneTemplates.get(templateKey) + if (existingTemplate) { + return existingTemplate + } + + const template = createItemCarryOverlayTemplate({ + assetOffset, + assetRotation, + modelScene, + renderScale, + }) + sceneTemplates.set(templateKey, template) + return template +} + +function createItemCarryOverlayTemplate({ + assetOffset, + assetRotation, + modelScene, + renderScale, +}: { + assetOffset: [number, number, number] + assetRotation: [number, number, number] + modelScene: Group + renderScale: [number, number, number] +}) { + const root = new Group() + const fillOpacity = 1 - ITEM_CARRY_OVERLAY_FILL_TRANSPARENCY_PERCENT / 100 + const tintMaterials: MeshBasicNodeMaterial[] = [] + const edgeTubeGeometry = new CylinderGeometry(1, 1, 1, 6, 1, true) + const edgeTubeMaterial = new MeshBasicNodeMaterial({ + color: ITEM_CARRY_OVERLAY_COLOR, + depthWrite: false, + opacity: 0.96, + transparent: true, + }) + edgeTubeMaterial.depthTest = true + edgeTubeMaterial.toneMapped = false + + root.userData.pascalExcludeFromToolConeTarget = true + root.userData.pascalExcludeFromOutline = true + + const tintClone = modelScene.clone(true) as Group + tintClone.userData.pascalExcludeFromToolConeTarget = true + tintClone.userData.pascalExcludeFromOutline = true + tintClone.position.set(assetOffset[0], assetOffset[1], assetOffset[2]) + tintClone.rotation.set(assetRotation[0], assetRotation[1], assetRotation[2]) + tintClone.scale.set(renderScale[0], renderScale[1], renderScale[2]) + tintClone.traverse((child) => { + const mesh = child as Mesh + if (!mesh.isMesh) { + return + } + + mesh.userData.pascalExcludeFromToolConeTarget = true + mesh.userData.pascalExcludeFromOutline = true + mesh.castShadow = false + mesh.receiveShadow = false + mesh.renderOrder = 20 + const material = new MeshBasicNodeMaterial({ + color: ITEM_CARRY_OVERLAY_COLOR, + depthWrite: false, + opacity: fillOpacity, + side: DoubleSide, + transparent: true, + }) + material.depthTest = true + material.toneMapped = false + mesh.material = material + tintMaterials.push(material) + }) + root.add(tintClone) + + const edgeRoot = new Group() + edgeRoot.userData.pascalExcludeFromToolConeTarget = true + edgeRoot.userData.pascalExcludeFromOutline = true + edgeRoot.position.copy(tintClone.position) + edgeRoot.rotation.copy(tintClone.rotation) + edgeRoot.scale.copy(tintClone.scale) + const start = new Vector3() + const end = new Vector3() + const center = new Vector3() + const direction = new Vector3() + const axis = new Vector3(0, 1, 0) + + modelScene.traverse((child) => { + const mesh = child as Mesh + if (!mesh.isMesh || !mesh.geometry) { + return + } + + const edgeGeometry = new EdgesGeometry(mesh.geometry, 28) + const edgePositions = edgeGeometry.getAttribute('position') + const edgeMeshGroup = new Group() + edgeMeshGroup.userData.pascalExcludeFromToolConeTarget = true + edgeMeshGroup.userData.pascalExcludeFromOutline = true + edgeMeshGroup.position.copy(mesh.position) + edgeMeshGroup.quaternion.copy(mesh.quaternion) + edgeMeshGroup.scale.copy(mesh.scale) + + for (let index = 0; index < edgePositions.count; index += 2) { + start.fromBufferAttribute(edgePositions, index) + end.fromBufferAttribute(edgePositions, index + 1) + direction.subVectors(end, start) + const length = direction.length() + if (length <= 1e-5) { + continue + } + + const edgeTube = new Mesh(edgeTubeGeometry, edgeTubeMaterial) + edgeTube.userData.pascalExcludeFromToolConeTarget = true + edgeTube.userData.pascalExcludeFromOutline = true + center.copy(start).add(end).multiplyScalar(0.5) + edgeTube.position.copy(center) + edgeTube.quaternion.setFromUnitVectors(axis, direction.normalize()) + edgeTube.scale.set( + ITEM_CARRY_OVERLAY_OUTLINE_THICKNESS, + length, + ITEM_CARRY_OVERLAY_OUTLINE_THICKNESS, + ) + edgeTube.castShadow = false + edgeTube.receiveShadow = false + edgeTube.renderOrder = 24 + edgeMeshGroup.add(edgeTube) + } + + edgeGeometry.dispose() + edgeRoot.add(edgeMeshGroup) + }) + root.add(edgeRoot) + + root.userData.pascalCarryOverlayTemplateResources = { + edgeTubeGeometry, + edgeTubeMaterial, + tintMaterials, + } + + return root +} + +const ItemCarryOverlay = ({ + assetOffset, + assetRotation, + modelScene, + renderScale, + visible, +}: { + assetOffset: [number, number, number] + assetRotation: [number, number, number] + modelScene: Group + renderScale: [number, number, number] + visible: boolean +}) => { + const overlayRoot = useMemo( + () => + getOrCreateItemCarryOverlayTemplate({ + assetOffset, + assetRotation, + modelScene, + renderScale, + }).clone(true) as Group, + [assetOffset, assetRotation, modelScene, renderScale], + ) + + return +} + +const ModelRenderer = ({ + node, + visualStateOverride, +}: { + node: ItemNode + visualStateOverride: ViewerRuntimeItemMoveVisualState | null +}) => { + const assetSrc = resolveCdnUrl(node.asset.src) || '' + const { scene, nodes, animations } = useGLTF(assetSrc) + const { camera, gl } = useThree() const ref = useRef(null!) + const preserveImportedTexturedMaterials = shouldPreserveImportedMaterials(node) + const moveVisualState = visualStateOverride const { actions } = useAnimations(animations, ref) // Freeze the interactive definition at mount — asset schemas don't change at runtime const interactiveRef = useRef(node.asset.interactive) + const handlers = useNodeEvents(node, 'item') + const visualMaterialsRef = useRef(new Map()) + const deleteFadeMaterialsRef = useRef(new Map()) + const itemDeleteActivation = useViewerRuntimeState( + (state) => state.itemDeleteActivations[node.id] ?? null, + ) + const carryOverlayVisible = + moveVisualState === 'carried' || + moveVisualState === 'copy-source-pending' || + moveVisualState === 'destination-ghost' || + moveVisualState === 'destination-preview' || + moveVisualState === 'source-pending' + const moveVisualMaterialKind: MaterializedItemMoveVisualKind | null = + moveVisualState === 'destination-ghost' || moveVisualState === 'destination-preview' + ? moveVisualState + : null + const deleteFadeStartedAtMs = itemDeleteActivation?.fadeStartedAtMs ?? null if (nodes.cutout) { nodes.cutout.visible = false } - const handlers = useNodeEvents(node, 'item') + const renderScale = useMemo( + () => multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1]), + [node.asset.scale, node.scale], + ) useEffect(() => { if (!node.parentId) return @@ -114,7 +920,12 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { return () => useInteractive.getState().removeItem(node.id) }, [node.id]) - useMemo(() => { + useEffect(() => { + const materialProcessingKey = `${assetSrc}|${preserveImportedTexturedMaterials ? 'preserve' : 'flatten'}` + if (processedItemScenes.get(scene) === materialProcessingKey) { + return + } + scene.traverse((child) => { if ((child as Mesh).isMesh) { const mesh = child as Mesh @@ -127,7 +938,12 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { // Handle both single material and material array cases if (Array.isArray(mesh.material)) { - mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat)) + const normalizedMaterials = mesh.material + .map((mat) => + mat ? getMaterialForOriginal(mat, preserveImportedTexturedMaterials) : baseMaterial, + ) + .filter((mat): mat is Material => Boolean(mat)) + mesh.material = normalizedMaterials.length > 0 ? normalizedMaterials : [baseMaterial] hasGlass = mesh.material.some((mat) => mat.name === 'glass') // Fix geometry groups that reference materialIndex beyond the material @@ -142,14 +958,257 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { } } } else { - mesh.material = getMaterialForOriginal(mesh.material) + mesh.material = getMaterialForOriginal(mesh.material, preserveImportedTexturedMaterials) hasGlass = mesh.material.name === 'glass' } mesh.castShadow = !hasGlass mesh.receiveShadow = !hasGlass } }) - }, [scene]) + processedItemScenes.set(scene, materialProcessingKey) + }, [assetSrc, preserveImportedTexturedMaterials, scene]) + + useMemo(() => { + const hasAnimatedEffects = + interactiveRef.current?.effects?.some((effect) => effect.kind === 'animation') ?? false + if (!shouldOptimizeStaticScene(node) || animations.length > 0 || hasAnimatedEffects) { + return + } + + optimizeStaticSceneMeshes(scene) + }, [animations.length, node, scene]) + + useEffect(() => { + const templateKey = getItemCarryOverlayTemplateKey( + node.asset.offset, + node.asset.rotation, + renderScale, + ) + + if (itemCarryOverlayTemplateCache.get(scene)?.has(templateKey)) { + return + } + + const warmOverlayTemplate = () => { + getOrCreateItemCarryOverlayTemplate({ + assetOffset: node.asset.offset, + assetRotation: node.asset.rotation, + modelScene: scene, + renderScale, + }) + } + + const timeoutId = window.setTimeout(warmOverlayTemplate, 0) + return () => { + window.clearTimeout(timeoutId) + } + }, [node.asset.offset, node.asset.rotation, renderScale, scene]) + + useEffect(() => { + const templateKey = getItemCarryOverlayTemplateKey( + node.asset.offset, + node.asset.rotation, + renderScale, + ) + const compileAsync = ( + gl as typeof gl & { + compileAsync?: (scene: Scene, camera: Camera) => Promise + } + ).compileAsync + if (!compileAsync) { + return + } + + const compiledKeys = + itemCarryOverlayTemplateCompiledCache.get(scene) ?? + (() => { + const nextCompiledKeys = new Set() + itemCarryOverlayTemplateCompiledCache.set(scene, nextCompiledKeys) + return nextCompiledKeys + })() + if (compiledKeys.has(templateKey)) { + return + } + + const inFlightKeys = + itemCarryOverlayTemplateCompileInFlight.get(scene) ?? + (() => { + const nextInFlightKeys = new Set() + itemCarryOverlayTemplateCompileInFlight.set(scene, nextInFlightKeys) + return nextInFlightKeys + })() + if (inFlightKeys.has(templateKey)) { + return + } + + inFlightKeys.add(templateKey) + let cancelled = false + const timeoutId = window.setTimeout(() => { + const compileScene = new Scene() + compileScene.add( + getOrCreateItemCarryOverlayTemplate({ + assetOffset: node.asset.offset, + assetRotation: node.asset.rotation, + modelScene: scene, + renderScale, + }).clone(true), + ) + compileAsync + .call(gl, compileScene, camera) + .then(() => { + if (!cancelled) { + compiledKeys.add(templateKey) + } + }) + .catch(() => {}) + .finally(() => { + inFlightKeys.delete(templateKey) + }) + }, 0) + + return () => { + cancelled = true + window.clearTimeout(timeoutId) + inFlightKeys.delete(templateKey) + } + }, [camera, gl, node.asset.offset, node.asset.rotation, renderScale, scene]) + + const syncMoveVisualMaterials = useCallback(() => { + const root = ref.current + if (!root) { + return + } + + const activeMeshes = new Set() + + root.traverse((child) => { + if (!isRenderableMesh(child)) { + return + } + + const mesh = child + activeMeshes.add(mesh) + const existingEntry = visualMaterialsRef.current.get(mesh) + + if (!moveVisualMaterialKind) { + if (existingEntry) { + if (mesh.material === existingEntry.visualMaterial) { + mesh.material = existingEntry.originalMaterial + } + mesh.castShadow = existingEntry.originalCastShadow + mesh.receiveShadow = existingEntry.originalReceiveShadow + disposeVisualMaterials(existingEntry.visualMaterial) + visualMaterialsRef.current.delete(mesh) + } + return + } + + if (existingEntry && existingEntry.kind === moveVisualMaterialKind) { + mesh.castShadow = + moveVisualMaterialKind === 'destination-ghost' ? false : existingEntry.originalCastShadow + mesh.receiveShadow = + moveVisualMaterialKind === 'destination-ghost' + ? false + : existingEntry.originalReceiveShadow + return + } + + const originalMaterial = + existingEntry && mesh.material === existingEntry.visualMaterial + ? existingEntry.originalMaterial + : mesh.material + const originalCastShadow = existingEntry?.originalCastShadow ?? mesh.castShadow + const originalReceiveShadow = existingEntry?.originalReceiveShadow ?? mesh.receiveShadow + + if (existingEntry) { + if (mesh.material === existingEntry.visualMaterial) { + mesh.material = existingEntry.originalMaterial + } + disposeVisualMaterials(existingEntry.visualMaterial) + } + + const visualMaterial = createItemMoveVisualMaterials(originalMaterial, moveVisualMaterialKind) + mesh.material = visualMaterial + mesh.castShadow = moveVisualMaterialKind === 'destination-ghost' ? false : originalCastShadow + mesh.receiveShadow = + moveVisualMaterialKind === 'destination-ghost' ? false : originalReceiveShadow + visualMaterialsRef.current.set(mesh, { + kind: moveVisualMaterialKind, + originalCastShadow, + originalMaterial, + originalReceiveShadow, + visualMaterial, + }) + }) + + for (const [mesh, entry] of visualMaterialsRef.current.entries()) { + if (activeMeshes.has(mesh)) { + continue + } + + if (mesh.material === entry.visualMaterial) { + mesh.material = entry.originalMaterial + } + mesh.castShadow = entry.originalCastShadow + mesh.receiveShadow = entry.originalReceiveShadow + disposeVisualMaterials(entry.visualMaterial) + visualMaterialsRef.current.delete(mesh) + } + }, [moveVisualMaterialKind]) + + const syncDeleteFadeMaterials = useCallback(() => { + const root = ref.current + if (!root) { + return + } + + const activeMeshes = new Set() + + root.traverse((child) => { + if (!isRenderableMesh(child)) { + return + } + + const mesh = child + activeMeshes.add(mesh) + const existingEntry = deleteFadeMaterialsRef.current.get(mesh) + + if (deleteFadeStartedAtMs === null) { + if (existingEntry) { + if (mesh.material === existingEntry.fadeMaterial) { + mesh.material = existingEntry.originalMaterial + } + disposeDeleteFadeMaterials(existingEntry.fadeMaterial) + deleteFadeMaterialsRef.current.delete(mesh) + } + return + } + + if (existingEntry) { + return + } + + const originalMaterial = mesh.material + const fadeMaterial = createDeleteFadeMaterials(originalMaterial) + mesh.material = fadeMaterial + deleteFadeMaterialsRef.current.set(mesh, { + fadeMaterial, + originalMaterial, + }) + }) + + for (const [mesh, entry] of deleteFadeMaterialsRef.current.entries()) { + if (activeMeshes.has(mesh)) { + continue + } + + if (mesh.material === entry.fadeMaterial) { + mesh.material = entry.originalMaterial + } + disposeDeleteFadeMaterials(entry.fadeMaterial) + deleteFadeMaterialsRef.current.delete(mesh) + } + }, [deleteFadeStartedAtMs]) const interactive = interactiveRef.current const animEffect = @@ -157,6 +1216,57 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { const lightEffects = interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? [] + useEffect(() => { + syncMoveVisualMaterials() + }, [syncMoveVisualMaterials]) + + useEffect(() => { + syncDeleteFadeMaterials() + }, [syncDeleteFadeMaterials]) + + useEffect(() => { + return () => { + for (const [mesh, entry] of visualMaterialsRef.current.entries()) { + if (mesh.material === entry.visualMaterial) { + mesh.material = entry.originalMaterial + } + mesh.castShadow = entry.originalCastShadow + mesh.receiveShadow = entry.originalReceiveShadow + disposeVisualMaterials(entry.visualMaterial) + } + + visualMaterialsRef.current.clear() + + for (const [mesh, entry] of deleteFadeMaterialsRef.current.entries()) { + if (mesh.material === entry.fadeMaterial) { + mesh.material = entry.originalMaterial + } + disposeDeleteFadeMaterials(entry.fadeMaterial) + } + + deleteFadeMaterialsRef.current.clear() + } + }, []) + + useFrame(() => { + if (deleteFadeStartedAtMs === null) { + return + } + + const fadeProgress = MathUtils.clamp( + ((typeof performance !== 'undefined' ? performance.now() : Date.now()) - + deleteFadeStartedAtMs) / + ITEM_DELETE_FADE_OUT_MS, + 0, + 1, + ) + const fadeAlpha = 1 - MathUtils.smootherstep(fadeProgress, 0, 1) + + for (const entry of deleteFadeMaterialsRef.current.values()) { + applyDeleteFadeOpacity(entry.fadeMaterial, fadeAlpha) + } + }) + return ( <> { position={node.asset.offset} ref={ref} rotation={node.asset.rotation} - scale={multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1])} - {...handlers} + scale={renderScale} + {...(itemDeleteActivation ? {} : handlers)} /> + {carryOverlayVisible && ( + + )} {animations.length > 0 && ( | null>(null) + const [isInitialized, setIsInitialized] = useState(false) - // Background color uniform — updated every frame via lerp, read by the TSL pipeline. - // Initialised from the current theme so there's no flash on first render. + // Background color uniform - updated every frame via lerp, read by the TSL pipeline. + // Initialized from the current theme so there is no flash on first render. const initBg = useViewer.getState().theme === 'dark' ? DARK_BG : LIGHT_BG const bgUniform = useRef(uniform(new Color(initBg))) const bgCurrent = useRef(new Color(initBg)) const bgTarget = useRef(new Color()) const zoneLayers = useMemo(() => { - const l = new Layers() - l.enable(ZONE_LAYER) - l.disable(SCENE_LAYER) - return l + const layers = new Layers() + layers.enable(ZONE_LAYER) + layers.disable(SCENE_LAYER) + return layers }, []) - const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode) + const hoverHighlightMode = useViewer((state) => state.hoverHighlightMode) + const navigationPostWarmupCompletedToken = useViewerRuntimeState( + (state) => state.navigationPostWarmupCompletedToken, + ) + const navigationPostWarmupRequestToken = useViewerRuntimeState( + (state) => state.navigationPostWarmupRequestToken, + ) + const completeNavigationPostWarmup = useViewerRuntimeState( + (state) => state.completeNavigationPostWarmup, + ) + const runtimePostProcessing = useViewer((state) => state.runtimePostProcessing) + const effectivePostProcessingMode = runtimePostProcessing ?? 'default' const hoverVisibleColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.visibleColor)), []) const hoverHiddenColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.hiddenColor)), []) const hoverStrength = useMemo(() => uniform(DEFAULT_HOVER_STYLE.strength), []) const hoverPulseMix = useMemo(() => uniform(DEFAULT_HOVER_STYLE.pulse ? 0 : 1), []) - // Subscribe to projectId so the pipeline rebuilds on project switch - const projectId = useViewer((s) => s.projectId) + // Subscribe to projectId so the pipeline rebuilds on project switch. + const projectId = useViewer((state) => state.projectId) - // Bump this to force a pipeline rebuild (used by retry logic) + // Bump this to force a pipeline rebuild (used by retry logic). const [pipelineVersion, setPipelineVersion] = useState(0) + const disposeRenderPipeline = useCallback(() => { + if (renderPipelineRef.current) { + renderPipelineRef.current.dispose() + renderPipelineRef.current = null + } + }, []) + const requestPipelineRebuild = useCallback(() => { if (rebuildTimeoutRef.current !== null) { clearTimeout(rebuildTimeoutRef.current) rebuildTimeoutRef.current = null } - setPipelineVersion((v) => v + 1) + setPipelineVersion((version) => version + 1) }, []) - // Reset retry state when project changes useEffect(() => { - // Intentionally touch projectId so the effect reruns on project switches. + let mounted = true + + const initRenderer = async () => { + try { + const rendererWithInit = renderer as unknown as { + init?: () => Promise + } + if (renderer && typeof rendererWithInit.init === 'function') { + await rendererWithInit.init() + } + + if (mounted) { + setIsInitialized(true) + } + } catch (error) { + console.error('[viewer] Failed to initialize renderer for post-processing.', error) + if (mounted) { + setIsInitialized(false) + } + } + } + + void initRenderer() + + return () => { + mounted = false + if (rebuildTimeoutRef.current !== null) { + clearTimeout(rebuildTimeoutRef.current) + rebuildTimeoutRef.current = null + } + disposeRenderPipeline() + } + }, [disposeRenderPipeline, renderer]) + + // Reset retry count when project changes. + useEffect(() => { void projectId retryCountRef.current = 0 if (rebuildTimeoutRef.current !== null) { @@ -141,13 +195,21 @@ const PostProcessingPasses = ({ }, [projectId]) useEffect(() => { - return () => { - if (rebuildTimeoutRef.current !== null) { - clearTimeout(rebuildTimeoutRef.current) - rebuildTimeoutRef.current = null - } + if (!isInitialized) { + return + } + + if (navigationPostWarmupRequestToken <= navigationPostWarmupCompletedToken) { + return } - }, []) + + completeNavigationPostWarmup(navigationPostWarmupRequestToken) + }, [ + completeNavigationPostWarmup, + isInitialized, + navigationPostWarmupCompletedToken, + navigationPostWarmupRequestToken, + ]) useEffect(() => { const style = hoverStyles[hoverHighlightMode] ?? hoverStyles.default @@ -166,34 +228,20 @@ const PostProcessingPasses = ({ invalidate, ]) - // Build / rebuild the post-processing pipeline + // Build or rebuild the post-processing pipeline. useEffect(() => { - // Intentionally touch these so React/biome treat project switches and retry bumps - // as explicit rebuild triggers instead of accidental extra dependencies. - void projectId void pipelineVersion + void projectId - if (!(renderer && scene && camera)) { + if (!(renderer && scene && camera && isInitialized)) { return } hasPipelineErrorRef.current = false - // WebGPU availability check: SSGI, denoise, and RenderPipeline are all - // WebGPU-only APIs. When the browser falls back to WebGL2 (no - // `navigator.gpu`, or the device couldn't be created), building the - // pipeline either throws silently or produces a broken output where - // the scene renders for a few frames and then goes black as the retry - // loop fights the direct-render fallback path. Short-circuit here so - // `useFrame` uses the direct `renderer.render(scene, camera)` path - // exclusively and never attempts the TSL pipeline. - const hasWebGPU = typeof navigator !== 'undefined' && 'gpu' in navigator - if (!hasWebGPU) { - console.warn( - '[viewer] WebGPU unavailable — rendering without post-processing (SSGI, outlines, denoise).', - ) - hasPipelineErrorRef.current = true - renderPipelineRef.current = null + if (effectivePostProcessingMode === 'disabled') { + disposeRenderPipeline() + retryCountRef.current = 0 return } @@ -215,13 +263,14 @@ const PostProcessingPasses = ({ // Background detection via alpha: renderer clears with alpha=0 (setClearAlpha(0) in useFrame), // so background pixels have scenePassColor.a=0 while geometry pixels have output.a=1. // WebGPU only applies clearColorValue to MRT attachment 0 (output), so scenePassColor.a - // is the reliable geometry mask — no normals, no flicker. + // is the reliable geometry mask - no normals, no flicker. const hasGeometry = scenePassColor.a const contentAlpha = hasGeometry.max(zonePass.a) let sceneColor = scenePassColor as unknown as ReturnType - if (SSGI_PARAMS.enabled) { + const ssgiEnabled = effectivePostProcessingMode === 'default' && SSGI_PARAMS.enabled + if (ssgiEnabled) { // MRT only needed for SSGI (diffuse for GI, normal for SSGI sampling) scenePass.setMRT( mrt({ @@ -259,7 +308,7 @@ const PostProcessingPasses = ({ const giTexture = (giPass as any).getTextureNode() - // DenoiseNode only denoises RGB — alpha is passed through unchanged. + // DenoiseNode only denoises RGB - alpha is passed through unchanged. // SSGI packs AO into alpha, so we remap it into RGB before denoising. const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1)) const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera) @@ -277,7 +326,6 @@ const PostProcessingPasses = ({ } // Single merged outline node: one shared depth pass for both selected + hovered groups. - const outliner = useViewer.getState().outliner const outlineNode = mergedOutline(scene, camera, { primaryObjects: outliner.selectedObjects, secondaryObjects: outliner.hoveredObjects, @@ -324,24 +372,21 @@ const PostProcessingPasses = ({ '[viewer] Failed to set up post-processing pipeline. Rendering without post FX.', error, ) - if (renderPipelineRef.current) { - renderPipelineRef.current.dispose() - } - renderPipelineRef.current = null + disposeRenderPipeline() } return () => { - if (renderPipelineRef.current) { - renderPipelineRef.current.dispose() - } - renderPipelineRef.current = null + disposeRenderPipeline() } }, [ camera, + disposeRenderPipeline, + effectivePostProcessingMode, hoverHiddenColor, hoverPulseMix, hoverStrength, hoverVisibleColor, + isInitialized, pipelineVersion, projectId, renderer, @@ -359,11 +404,17 @@ const PostProcessingPasses = ({ sanitizeOutlineObjects(outliner.selectedObjects) sanitizeOutlineObjects(outliner.hoveredObjects) - if (hasPipelineErrorRef.current || !renderPipelineRef.current) { + if ( + effectivePostProcessingMode === 'disabled' || + hasPipelineErrorRef.current || + !renderPipelineRef.current + ) { + if (!isInitialized) { + return + } + try { - if ((renderer as any).setClearAlpha) { - ;(renderer as any).setClearAlpha(1) - } + ;(renderer as any).setClearAlpha?.(1) ;(renderer as any).render(scene, camera) } catch (fallbackError) { console.error('[viewer] Fallback render failed.', fallbackError) @@ -374,19 +425,16 @@ const PostProcessingPasses = ({ try { // Clear alpha=0 so background pixels in the output MRT attachment (index 0) get a=0, // making scenePassColor.a a reliable geometry mask (geometry pixels write a=1 via output node). - ;(renderer as any).setClearAlpha(0) + ;(renderer as any).setClearAlpha?.(0) renderPipelineRef.current.render() } catch (error) { hasPipelineErrorRef.current = true console.error('[viewer] Post-processing render pass failed.', error) - if (renderPipelineRef.current) { - renderPipelineRef.current.dispose() - } - renderPipelineRef.current = null + disposeRenderPipeline() if (retryCountRef.current < MAX_PIPELINE_RETRIES) { - // Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit - retryCountRef.current++ + // Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit. + retryCountRef.current += 1 console.warn( `[viewer] Scheduling post-processing rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`, ) diff --git a/packages/viewer/src/contexts/viewer-runtime-state.tsx b/packages/viewer/src/contexts/viewer-runtime-state.tsx new file mode 100644 index 000000000..1d4c67b69 --- /dev/null +++ b/packages/viewer/src/contexts/viewer-runtime-state.tsx @@ -0,0 +1,76 @@ +import type { BaseNode, ItemNode } from '@pascal-app/core' +import { createContext, type ReactNode, useContext } from 'react' +import { type StoreApi, useStore } from 'zustand' +import { createStore } from 'zustand/vanilla' + +export type ViewerRuntimeItemMoveVisualState = + | 'carried' + | 'copy-source-pending' + | 'destination-ghost' + | 'destination-preview' + | 'source-pending' + +export type ViewerRuntimeItemMovePreview = { + id: ItemNode['id'] + sourceItemId: ItemNode['id'] +} + +export type ViewerRuntimeItemDeleteActivation = { + fadeStartedAtMs: number | null + startedAtMs: number +} + +export type ViewerRuntimePostWarmupScope = + | ((run: () => void | Promise) => boolean | Promise) + | null + +export type ViewerRuntimeState = { + completeNavigationPostWarmup: (token: number) => void + itemDeleteActivations: Partial> + itemMovePreview: ViewerRuntimeItemMovePreview | null + itemMoveVisualStates: Partial> + navigationPostWarmupCompletedToken: number + navigationPostWarmupRequestToken: number + navigationPostWarmupScope: ViewerRuntimePostWarmupScope + nodeVisibilityOverrides: Partial> + requestNavigationPostWarmup: () => number + setNavigationPostWarmupScope: (scope: ViewerRuntimePostWarmupScope) => void +} + +const EMPTY_VIEWER_RUNTIME_STATE: ViewerRuntimeState = { + completeNavigationPostWarmup: () => {}, + itemDeleteActivations: {}, + itemMovePreview: null, + itemMoveVisualStates: {}, + navigationPostWarmupCompletedToken: 0, + navigationPostWarmupRequestToken: 0, + navigationPostWarmupScope: null, + nodeVisibilityOverrides: {}, + requestNavigationPostWarmup: () => 0, + setNavigationPostWarmupScope: () => {}, +} + +const emptyViewerRuntimeStateStore = createStore( + () => EMPTY_VIEWER_RUNTIME_STATE, +) + +const ViewerRuntimeStateContext = createContext | null>(null) + +export function ViewerRuntimeStateProvider({ + children, + store, +}: { + children: ReactNode + store: StoreApi +}) { + return ( + + {children} + + ) +} + +export function useViewerRuntimeState(selector: (state: ViewerRuntimeState) => T): T { + const store = useContext(ViewerRuntimeStateContext) + return useStore(store ?? emptyViewerRuntimeStateStore, selector) +} diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 133ba34be..a01b6a273 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -56,6 +56,12 @@ type NodeConfig = { type NodeType = keyof NodeConfig export function useNodeEvents(node: NodeConfig[T]['node'], type: T) { + const nodeEventsSuppressed = useViewer((state) => state.nodeEventsSuppressed) + + if (nodeEventsSuppressed) { + return {} + } + const emit = (suffix: EventSuffix, e: ThreeEvent) => { const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}` const localPoint = e.object.worldToLocal(e.point.clone()) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index e867a444f..5aae4ba2b 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,3 +1,5 @@ +import './lib/suppress-three-clock-warning' + export { default as Viewer } from './components/viewer' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { @@ -5,8 +7,18 @@ export { SSGI_PARAMS, } from './components/viewer/post-processing' export { WalkthroughControls } from './components/viewer/walkthrough-controls' +export { + useViewerRuntimeState, + type ViewerRuntimeItemDeleteActivation, + type ViewerRuntimeItemMovePreview, + type ViewerRuntimeItemMoveVisualState, + type ViewerRuntimePostWarmupScope, + type ViewerRuntimeState, + ViewerRuntimeStateProvider, +} from './contexts/viewer-runtime-state' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' -export { SCENE_LAYER, ZONE_LAYER } from './lib/layers' +export { ITEM_DELETE_FADE_OUT_MS } from './lib/item-delete-visual' +export { SCENE_LAYER, VFX_LAYER, ZONE_LAYER } from './lib/layers' export { applyMaterialPresetToMaterials, clearMaterialCache, diff --git a/packages/viewer/src/lib/asset-url.ts b/packages/viewer/src/lib/asset-url.ts index 6ec7d3178..5b697a9b5 100644 --- a/packages/viewer/src/lib/asset-url.ts +++ b/packages/viewer/src/lib/asset-url.ts @@ -2,6 +2,34 @@ import { loadAssetUrl } from '@pascal-app/core' export const ASSETS_CDN_URL = process.env.NEXT_PUBLIC_ASSETS_CDN_URL || 'https://editor.pascal.app' +function isLoopbackHost(hostname: string): boolean { + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '[::1]' || + hostname === '::1' + ) +} + +function getPreferredAssetOrigin(): string { + if (typeof window === 'undefined') { + return ASSETS_CDN_URL + } + + try { + const currentOrigin = new URL(window.location.origin) + // App-served public assets should stay on the active loopback origin in + // local dev, even when production points at a remote CDN host. + if (isLoopbackHost(currentOrigin.hostname)) { + return currentOrigin.origin + } + } catch { + return ASSETS_CDN_URL + } + + return ASSETS_CDN_URL +} + /** * Resolves an asset URL to the appropriate format: * - If URL starts with http:// or https://, return as-is (external URL) @@ -24,7 +52,7 @@ export async function resolveAssetUrl(url: string | undefined | null): Promise void + nodeEventsSuppressed: boolean + setNodeEventsSuppressed: (suppressed: boolean) => void hoverHighlightMode: string setHoverHighlightMode: (mode: string) => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 00649a633..e415f1c79 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -18,10 +18,14 @@ type Outliner = { hoveredObjects: Object3D[] } +export type ViewerPostProcessingMode = 'default' | 'disabled' | 'no-ssgi' + type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void + nodeEventsSuppressed: boolean + setNodeEventsSuppressed: (suppressed: boolean) => void hoverHighlightMode: string setHoverHighlightMode: (mode: string) => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null @@ -76,6 +80,8 @@ type ViewerState = { cameraDragging: boolean setCameraDragging: (dragging: boolean) => void + runtimePostProcessing: ViewerPostProcessingMode | null + setRuntimePostProcessing: (mode: ViewerPostProcessingMode | null) => void } const useViewer = create()( @@ -84,6 +90,8 @@ const useViewer = create()( selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, previewSelectedIds: [], setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), + nodeEventsSuppressed: false, + setNodeEventsSuppressed: (nodeEventsSuppressed) => set({ nodeEventsSuppressed }), hoverHighlightMode: 'default', setHoverHighlightMode: (mode) => set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })), @@ -203,6 +211,8 @@ const useViewer = create()( cameraDragging: false, setCameraDragging: (dragging) => set({ cameraDragging: dragging }), + runtimePostProcessing: null, + setRuntimePostProcessing: (runtimePostProcessing) => set({ runtimePostProcessing }), }), { name: 'viewer-preferences',