diff --git a/.gitignore b/.gitignore index d01fc0bdd..e6d08febc 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ og-test # Worktrees .worktrees + +# Local agent/runtime logs +.codex/logs/ diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index 80fb1a962..5e53ea92c 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -1,6 +1,12 @@ 'use client' -import { Editor, ItemsPanel } from '@pascal-app/editor' +import { Editor, ItemsPanel, type SceneGraph } from '@pascal-app/editor' +import { + NavigationToolbarButton, + NavigationViewerFrame, + prepareNavigationSceneGraph, +} from '@pascal-app/robot/editor' +import { shouldPauseNavigationAutoSave, useNavigation } from '@pascal-app/robot' import { Layers, Package, Settings } from 'lucide-react' import Link from 'next/link' import { @@ -33,8 +39,21 @@ const SIDEBAR_TABS = [ ] const PROJECT_ID = 'local-editor' +const LOCAL_STORAGE_SCENE_KEY = 'pascal-editor-scene' + +async function loadNavigationSceneFromLocalStorage(): Promise { + try { + const raw = window.localStorage.getItem(LOCAL_STORAGE_SCENE_KEY) + const scene = raw ? (JSON.parse(raw) as SceneGraph) : null + return prepareNavigationSceneGraph(scene) ?? scene + } catch { + return null + } +} export default function Home() { + const robotMode = useNavigation((state) => state.robotMode) + return (
{PROJECT_ID === 'local-editor' && ( @@ -54,11 +73,17 @@ export default function Home() {
)} ( + {children} + )} + shouldPauseAutoSave={shouldPauseNavigationAutoSave} sidebarTabs={SIDEBAR_TABS} viewerToolbarLeft={} - viewerToolbarRight={} + viewerToolbarRight={} />} /> ) diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index b3606f761..b068210bd 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -6,6 +6,13 @@ import { type SceneGraph, type SidebarTab, } from '@pascal-app/editor' +import { + NavigationPanel, + NavigationToolbarButton, + NavigationViewerFrame, + prepareNavigationSceneGraph, +} from '@pascal-app/robot/editor' +import { shouldPauseNavigationAutoSave, useNavigation } from '@pascal-app/robot' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' @@ -30,6 +37,11 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ label: 'Scene', component: () => null, // Built-in SitePanel handles this }, + { + id: 'robot', + label: 'Robot', + component: NavigationPanel, + }, ] interface SceneLoaderProps { @@ -60,13 +72,17 @@ function sceneGraphSignature(graph: SceneGraphWithCollections): string { export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { const router = useRouter() + const robotMode = useNavigation((state) => state.robotMode) const versionRef = useRef(meta.version) const lastRemoteGraphJsonRef = useRef(null) const suppressRemoteSaveUntilRef = useRef(0) const [conflict, setConflict] = useState(false) const [saveError, setSaveError] = useState(null) - const handleLoad = useCallback(async () => initialScene, [initialScene]) + const handleLoad = useCallback( + async () => prepareNavigationSceneGraph(initialScene) ?? initialScene, + [initialScene], + ) const handleSave = useCallback( async (graph: SceneGraph) => { @@ -123,9 +139,10 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { if (payload.version <= versionRef.current) return versionRef.current = payload.version - lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph) + const graph = prepareNavigationSceneGraph(payload.graph) ?? payload.graph + lastRemoteGraphJsonRef.current = sceneGraphSignature(graph) suppressRemoteSaveUntilRef.current = Date.now() + 2500 - applySceneGraphToEditor(payload.graph) + applySceneGraphToEditor(graph) setConflict(false) setSaveError(null) }) @@ -184,7 +201,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {

{saveError}

)} -
+
( + {children} + )} + shouldPauseAutoSave={shouldPauseNavigationAutoSave} sidebarTabs={SIDEBAR_TABS} viewerToolbarLeft={} - viewerToolbarRight={} + viewerToolbarRight={} />} />
) diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index e3668ce28..0332600d1 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -344,9 +344,11 @@ export function CommunityViewerToolbarLeft() { ) } -export function CommunityViewerToolbarRight() { +export function CommunityViewerToolbarRight({ before }: { before?: ReactNode } = {}) { return (
+ {before} + {before ?
: null} diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 0120eaf4c..c2dfd6736 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from 'next' +import path from 'node:path' const nextConfig: NextConfig = { logging: { @@ -13,6 +14,7 @@ const nextConfig: NextConfig = { '@pascal-app/core', '@pascal-app/editor', '@pascal-app/mcp', + '@pascal-app/robot', ], turbopack: { resolveAlias: { @@ -22,6 +24,18 @@ const nextConfig: NextConfig = { '@react-three/drei': './node_modules/@react-three/drei', }, }, + webpack: (config) => { + config.resolve ??= {} + config.resolve.alias = { + ...(config.resolve.alias ?? {}), + 'three/tsl': path.resolve(__dirname, './node_modules/three/build/three.tsl.js'), + 'three/webgpu': path.resolve(__dirname, './node_modules/three/build/three.webgpu.js'), + 'three$': path.resolve(__dirname, './node_modules/three/build/three.module.js'), + '@react-three/fiber': path.resolve(__dirname, './node_modules/@react-three/fiber'), + '@react-three/drei': path.resolve(__dirname, './node_modules/@react-three/drei'), + } + return config + }, experimental: { serverActions: { bodySizeLimit: '100mb', diff --git a/apps/editor/package.json b/apps/editor/package.json index f2c00a2ed..69f4fc10a 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -16,6 +16,7 @@ "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", + "@pascal-app/robot": "*", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", 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/bun.lock b/bun.lock index 64ed55c62..6a106e14b 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", + "@pascal-app/robot": "*", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", @@ -182,6 +183,41 @@ "@pascal-app/core": "^0.8.0", }, }, + "packages/robot": { + "name": "@pascal-app/robot", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-tooltip": "^1.2.8", + "clsx": "^2.1.1", + "lucide-react": "^0.562.0", + "mitt": "^3.0.1", + "tailwind-merge": "^3.5.0", + "zustand": "^5.0.11", + }, + "devDependencies": { + "@pascal-app/core": "^0.8.0", + "@pascal-app/editor": "^0.8.0", + "@pascal-app/viewer": "^0.8.0", + "@pascal/typescript-config": "*", + "@types/node": "^22.19.12", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", + "@types/three": "^0.184.0", + "typescript": "5.9.3", + }, + "peerDependencies": { + "@pascal-app/core": "^0.8.0", + "@pascal-app/editor": "^0.8.0", + "@pascal-app/viewer": "^0.8.0", + "@react-three/drei": "^10", + "@react-three/fiber": "^9", + "next": ">=15", + "react": "^18 || ^19", + "react-dom": "^18 || ^19", + "three": "^0.184", + }, + }, "packages/typescript-config": { "name": "@repo/typescript-config", "version": "0.0.0", @@ -451,6 +487,8 @@ "@pascal-app/mcp": ["@pascal-app/mcp@workspace:packages/mcp"], + "@pascal-app/robot": ["@pascal-app/robot@workspace:packages/robot"], + "@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"], "@pascal/typescript-config": ["@pascal/typescript-config@workspace:tooling/typescript"], @@ -1531,6 +1569,12 @@ "@pascal-app/mcp/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@pascal-app/robot/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], + + "@pascal-app/robot/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], + + "@pascal-app/robot/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@pascal-app/viewer/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -1613,6 +1657,8 @@ "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@pascal-app/robot/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@pascal-app/viewer/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@repo/ui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/packages/editor/package.json b/packages/editor/package.json index 1e18e3640..b219a3441 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./src/index.tsx", - "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx" + "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx", + "./runtime": "./src/runtime.ts" }, "scripts": { "check-types": "tsc --noEmit" diff --git a/packages/editor/src/components/editor/editor-layout-v2.tsx b/packages/editor/src/components/editor/editor-layout-v2.tsx index 3a794308a..f8851ae67 100644 --- a/packages/editor/src/components/editor/editor-layout-v2.tsx +++ b/packages/editor/src/components/editor/editor-layout-v2.tsx @@ -162,7 +162,7 @@ function RightColumn({ > {/* Viewer toolbar */} {(toolbarLeft || toolbarRight) && ( -
+
{toolbarLeft}
{toolbarRight}
diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index fa5b563f8..63590c068 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -56,6 +56,23 @@ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible } +function clonePositionOnlyGeometry(sourceGeometry: THREE.BufferGeometry) { + const position = sourceGeometry.getAttribute('position') + if (!position || position.count < 3 || position.itemSize < 3) return null + + const positionArray = new Float32Array(position.count * 3) + for (let index = 0; index < position.count; index += 1) { + const targetIndex = index * 3 + positionArray[targetIndex] = position.getX(index) + positionArray[targetIndex + 1] = position.getY(index) + positionArray[targetIndex + 2] = position.getZ(index) + } + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(positionArray, 3)) + return geometry +} + function cloneWorldGeometry(mesh: THREE.Mesh) { const sourceGeometry = mesh.geometry const position = sourceGeometry.getAttribute('position') @@ -64,14 +81,10 @@ function cloneWorldGeometry(mesh: THREE.Mesh) { const workingGeometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry.clone() - const cleanGeometry = new THREE.BufferGeometry() - cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone()) - - const normal = workingGeometry.getAttribute('normal') - if (normal) { - cleanGeometry.setAttribute('normal', normal.clone()) - } else { - cleanGeometry.computeVertexNormals() + const cleanGeometry = clonePositionOnlyGeometry(workingGeometry) + if (!cleanGeometry) { + workingGeometry.dispose() + return null } cleanGeometry.applyMatrix4(mesh.matrixWorld) @@ -124,15 +137,11 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { const visibleHeight = leafH * (1 - openAmount) if (visibleHeight <= 0.12) return null - const sourceGeometry = new THREE.BoxGeometry( - leafW, - visibleHeight, - DOOR_LEAF_COLLIDER_DEPTH, - ).toNonIndexed() - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone()) - geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone()) + const sourceGeometry = new THREE.BoxGeometry(leafW, visibleHeight, DOOR_LEAF_COLLIDER_DEPTH) + .toNonIndexed() + const geometry = clonePositionOnlyGeometry(sourceGeometry) sourceGeometry.dispose() + if (!geometry) return null const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2 geometry.applyMatrix4( root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)), @@ -158,10 +167,9 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { leafH, DOOR_LEAF_COLLIDER_DEPTH, ).toNonIndexed() - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone()) - geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone()) + const geometry = clonePositionOnlyGeometry(sourceGeometry) sourceGeometry.dispose() + if (!geometry) return null const matrix = root.matrixWorld .clone() .multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0)) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 93f81a5df..9ec9b2ce6 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -396,7 +396,7 @@ export function FloatingActionMenu() { setSelection({ selectedIds: [] }) useScene.getState().deleteNode(selectedId as AnyNodeId) }, - [node?.type, selectedId, setSelection], + [node, selectedId, setSelection], ) if ( @@ -439,6 +439,7 @@ export function FloatingActionMenu() { ? handleMove : undefined } + onRepair={node?.type === 'item' ? handleRepair : undefined} onPointerDown={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 8515223cb..9d5a8c258 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -139,6 +139,10 @@ export interface EditorProps { previewScene?: SceneGraph isVersionPreviewMode?: boolean + // External modes can pause editor-owned interactions while preserving viewer/app composition. + editorInteractionsDisabled?: boolean + shouldPauseAutoSave?: () => boolean + // Loading indicator (e.g. project fetching in community mode) isLoading?: boolean @@ -159,6 +163,40 @@ export interface EditorProps { // Command palette fallback when no commands match commandPaletteEmptyAction?: CommandPaletteEmptyAction + + // Feature extension slot + renderViewer?: ( + children: ReactNode, + props: { hoverStyles: HoverStyles; selectionManager: 'custom' | 'default' }, + ) => ReactNode +} + +function resetEditorInteractionState() { + const viewer = useViewer.getState() + viewer.setHoveredId(null) + viewer.setPreviewSelectedIds([]) + viewer.setSelection({ + buildingId: viewer.selection.buildingId, + levelId: viewer.selection.levelId, + selectedIds: [], + zoneId: null, + }) + viewer.setHoverHighlightMode('default') + viewer.outliner.selectedObjects.length = 0 + viewer.outliner.hoveredObjects.length = 0 + + const editor = useEditor.getState() + editor.setMovingNode(null) + editor.setMovingWallEndpoint(null) + editor.setMovingFenceEndpoint(null) + editor.setCurvingWall(null) + editor.setCurvingFence(null) + editor.setSelectedMaterialTarget(null) + editor.setSelectedReferenceId(null) + editor.setEditingHole(null) + editor.setFloorplanSelectionTool('click') + editor.setMode('select') + editor.setTool(null) } function EditorSceneCrashFallback() { @@ -577,20 +615,30 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ isVersionPreviewMode, isLoading, isFirstPersonMode, + editorInteractionsDisabled, onThumbnailCapture, }: { isVersionPreviewMode: boolean isLoading: boolean isFirstPersonMode: boolean + editorInteractionsDisabled: boolean onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void }) { return ( <> - {!isFirstPersonMode && } - {!(isVersionPreviewMode || isFirstPersonMode) && } - {!(isVersionPreviewMode || isFirstPersonMode) && } - {!(isVersionPreviewMode || isFirstPersonMode) && } - {!(isVersionPreviewMode || isFirstPersonMode) && } + {!(isFirstPersonMode || editorInteractionsDisabled) && } + {!(isVersionPreviewMode || isFirstPersonMode || editorInteractionsDisabled) && ( + + )} + {!(isVersionPreviewMode || isFirstPersonMode || editorInteractionsDisabled) && ( + + )} + {!(isVersionPreviewMode || isFirstPersonMode || editorInteractionsDisabled) && ( + + )} + {!(isVersionPreviewMode || isFirstPersonMode || editorInteractionsDisabled) && ( + + )} {!isFirstPersonMode && } {isFirstPersonMode ? : } @@ -601,7 +649,9 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!(isLoading || isFirstPersonMode) && ( )} - {!(isLoading || isVersionPreviewMode || isFirstPersonMode) && } + {!(isLoading || isVersionPreviewMode || isFirstPersonMode || editorInteractionsDisabled) && ( + + )} {isFirstPersonMode && } @@ -618,13 +668,15 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ function DeleteCursorLayer({ containerRef, isVersionPreviewMode, + editorInteractionsDisabled, }: { containerRef: React.RefObject isVersionPreviewMode: boolean + editorInteractionsDisabled: boolean }) { const mode = useEditor((s) => s.mode) const badgeRef = useRef(null) - const active = mode === 'delete' && !isVersionPreviewMode + const active = mode === 'delete' && !(isVersionPreviewMode || editorInteractionsDisabled) useEffect(() => { if (!active) { @@ -692,15 +744,17 @@ function DeleteCursorLayer({ function PaintCursorLayer({ containerRef, isVersionPreviewMode, + editorInteractionsDisabled, }: { containerRef: React.RefObject isVersionPreviewMode: boolean + editorInteractionsDisabled: boolean }) { const mode = useEditor((s) => s.mode) const activePaintMaterial = useEditor((s) => s.activePaintMaterial) const activePaintTarget = useEditor((s) => s.activePaintTarget) const badgeRef = useRef(null) - const active = mode === 'material-paint' && !isVersionPreviewMode + const active = mode === 'material-paint' && !(isVersionPreviewMode || editorInteractionsDisabled) useEffect(() => { if (!active) { @@ -791,16 +845,23 @@ const ViewerCanvas = memo(function ViewerCanvas({ isVersionPreviewMode, isLoading, isFirstPersonMode, + editorInteractionsDisabled, hasLoadedInitialScene, showLoader, onThumbnailCapture, + renderViewer, }: { isVersionPreviewMode: boolean isLoading: boolean isFirstPersonMode: boolean + editorInteractionsDisabled: boolean hasLoadedInitialScene: boolean showLoader: boolean onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void + renderViewer?: ( + children: ReactNode, + props: { hoverStyles: HoverStyles; selectionManager: 'custom' | 'default' }, + ) => ReactNode }) { const viewMode = useEditor((s) => s.viewMode) const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio) @@ -854,6 +915,16 @@ const ViewerCanvas = memo(function ViewerCanvas({ const show2d = viewMode === '2d' || viewMode === 'split' const show3d = viewMode === '3d' || viewMode === 'split' + const selectionManager = isFirstPersonMode ? 'default' : 'custom' + const viewerChildren = ( + + ) return ( }> @@ -887,10 +958,12 @@ const ViewerCanvas = memo(function ViewerCanvas({ > {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? ( @@ -900,20 +973,18 @@ const ViewerCanvas = memo(function ViewerCanvas({ /> ) : null} - - - + {renderViewer ? ( + renderViewer(viewerChildren, { hoverStyles: EDITOR_HOVER_STYLES, selectionManager }) + ) : ( + + {viewerChildren} + + )}
- {!(isLoading || isVersionPreviewMode) && } + {!(isLoading || isVersionPreviewMode || editorInteractionsDisabled) && ( + + )} ) }) @@ -933,6 +1004,8 @@ export default function Editor({ onSaveStatusChange, previewScene, isVersionPreviewMode = false, + editorInteractionsDisabled = false, + shouldPauseAutoSave, isLoading = false, onThumbnailCapture, sidebarOverlay, @@ -942,16 +1015,18 @@ export default function Editor({ extraSidebarPanels, presetsAdapter, commandPaletteEmptyAction, + renderViewer, }: EditorProps) { const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) - useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode }) + useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || editorInteractionsDisabled }) const { isLoadingSceneRef } = useAutoSave({ onSave, onDirty, onSaveStatusChange, isVersionPreviewMode, + shouldPauseAutoSave, }) const [isSceneLoading, setIsSceneLoading] = useState(false) @@ -975,6 +1050,11 @@ export default function Editor({ } }, [projectId]) + useEffect(() => { + if (!editorInteractionsDisabled) return + resetEditorInteractionState() + }, [editorInteractionsDisabled]) + // Load scene on mount (or when onLoad identity changes, e.g. project switch) useEffect(() => { let cancelled = false @@ -1088,11 +1168,13 @@ export default function Editor({ const viewerCanvas = ( ) @@ -1144,17 +1226,17 @@ export default function Editor({ overlays={ <> {!isCaptureMode && } - {!(isVersionPreviewMode || isCaptureMode) && ( + {!(isVersionPreviewMode || isCaptureMode || editorInteractionsDisabled) && (
)} - {!(isVersionPreviewMode || isCaptureMode) && ( + {!(isVersionPreviewMode || isCaptureMode || editorInteractionsDisabled) && (
)} - {!isCaptureMode && ( + {!isCaptureMode && !editorInteractionsDisabled && (
@@ -1223,15 +1305,19 @@ export default function Editor({ {/* Fixed UI overlays scoped to the viewer area */} -
- -
-
- -
-
- -
+ {!editorInteractionsDisabled && ( + <> +
+ +
+
+ +
+
+ +
+ + )}
{/* First-person overlay — rendered on top of normal layout */} {isFirstPersonMode && ( diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 3e8724081..abc5a7404 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -129,7 +129,7 @@ export const floorStrategy = { pos, getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo), ctx.draftItem.rotation, - [ctx.draftItem.id], + ctx.ignoredItemIds ?? [ctx.draftItem.id], ).valid if (!valid) return null @@ -246,7 +246,7 @@ export const wallStrategy = { getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo), ctx.draftItem.asset.attachTo as 'wall' | 'wall-side', side, - [ctx.draftItem.id], + ctx.ignoredItemIds ?? [ctx.draftItem.id], ) const adjustedY = validation.adjustedY ?? snappedY @@ -290,7 +290,7 @@ export const wallStrategy = { getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo), ctx.draftItem.asset.attachTo as 'wall' | 'wall-side', ctx.draftItem.side, - [ctx.draftItem.id], + ctx.ignoredItemIds ?? [ctx.draftItem.id], ).valid if (!valid) return null @@ -427,7 +427,7 @@ export const ceilingStrategy = { pos, getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo), ctx.draftItem.rotation, - [ctx.draftItem.id], + ctx.ignoredItemIds ?? [ctx.draftItem.id], ).valid if (!valid) return null @@ -614,7 +614,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], alignedDims, ctx.draftItem.rotation, - [ctx.draftItem.id], + ctx.ignoredItemIds ?? [ctx.draftItem.id], ).valid } @@ -628,7 +628,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato alignedDims, attachTo, ctx.draftItem.side, - [ctx.draftItem.id], + ctx.ignoredItemIds ?? [ctx.draftItem.id], ).valid } @@ -638,6 +638,6 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato [ctx.gridPosition.x, 0, ctx.gridPosition.z], alignedDims, ctx.draftItem.rotation, - [ctx.draftItem.id], + ctx.ignoredItemIds ?? [ctx.draftItem.id], ).valid } diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 538286580..023fd8c74 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -37,6 +37,7 @@ export interface PlacementContext { levelId: LevelNode['id'] | null draftItem: ItemNode | null gridPosition: Vector3 + ignoredItemIds?: string[] state: PlacementState /** * Current world Y rotation of the placement cursor — the user's intended diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index 348422c6f..76977e5eb 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -11,11 +11,24 @@ import type { Vector3 } from 'three' import { stripTransient } from './placement-math' interface OriginalState { + id: AnyNodeId + name?: string position: [number, number, number] rotation: [number, number, number] side: ItemNode['side'] parentId: string | null metadata: ItemNode['metadata'] + scale: [number, number, number] + visible: boolean +} + +type DraftMode = 'adopt' | 'clone' | 'create' + +type DraftNodeOptions = { + id?: ItemNode['id'] + metadata?: ItemNode['metadata'] + name?: string + parentId?: string | null } export interface DraftNodeHandle { @@ -23,15 +36,20 @@ export interface DraftNodeHandle { readonly current: ItemNode | null /** Whether the current draft was adopted (move mode) vs created (create mode) */ readonly isAdopted: boolean + /** Current draft lifecycle mode. */ + readonly mode: DraftMode | null /** Create a new draft item at the given position. Returns the created node or null. */ create: ( gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number], + options?: DraftNodeOptions, ) => ItemNode | null /** Take ownership of an existing scene node as the draft (for move mode). */ adopt: (node: ItemNode) => void + /** Create a transient preview clone while keeping the source item in place. */ + preview: (node: ItemNode) => ItemNode | null /** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */ commit: (finalUpdate: Partial) => string | null /** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */ @@ -48,7 +66,7 @@ export interface DraftNodeHandle { */ export function useDraftNode(): DraftNodeHandle { const draftRef = useRef(null) - const adoptedRef = useRef(false) + const modeRef = useRef(null) const originalStateRef = useRef(null) const create = useCallback( @@ -57,23 +75,25 @@ export function useDraftNode(): DraftNodeHandle { asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number], + options?: DraftNodeOptions, ): ItemNode | null => { - const currentLevelId = useViewer.getState().selection.levelId + const currentLevelId = options?.parentId ?? useViewer.getState().selection.levelId if (!currentLevelId) return null const node = ItemNode.parse({ + id: options?.id, position: [gridPosition.x, gridPosition.y, gridPosition.z], rotation: rotation ?? [0, 0, 0], scale: scale ?? [1, 1, 1], - name: asset.name, + name: options?.name ?? asset.name, asset, parentId: currentLevelId, - metadata: { isTransient: true }, + metadata: { ...(options?.metadata as Record | undefined), isTransient: true }, }) - useScene.getState().createNode(node, currentLevelId) + useScene.getState().createNode(node, currentLevelId as AnyNodeId) draftRef.current = node - adoptedRef.current = false + modeRef.current = 'create' originalStateRef.current = null return node }, @@ -88,15 +108,19 @@ export function useDraftNode(): DraftNodeHandle { : {} originalStateRef.current = { + id: node.id as AnyNodeId, + name: node.name, position: [...node.position] as [number, number, number], rotation: [...node.rotation] as [number, number, number], side: node.side, parentId: node.parentId, metadata: node.metadata, + scale: [...node.scale] as [number, number, number], + visible: node.visible ?? true, } draftRef.current = node - adoptedRef.current = true + modeRef.current = 'adopt' // Mark as transient so it renders as a draft useScene.getState().updateNode(node.id, { @@ -104,11 +128,50 @@ export function useDraftNode(): DraftNodeHandle { }) }, []) + const preview = useCallback((node: ItemNode): ItemNode | null => { + const meta = + typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) + ? (node.metadata as Record) + : {} + + originalStateRef.current = { + id: node.id as AnyNodeId, + name: node.name, + position: [...node.position] as [number, number, number], + rotation: [...node.rotation] as [number, number, number], + side: node.side, + parentId: node.parentId, + metadata: node.metadata, + scale: [...node.scale] as [number, number, number], + visible: node.visible ?? true, + } + + const previewNode = ItemNode.parse({ + name: node.name, + asset: node.asset, + metadata: { ...meta, isTransient: true }, + parentId: node.parentId, + position: [...node.position] as [number, number, number], + rotation: [...node.rotation] as [number, number, number], + scale: [...node.scale] as [number, number, number], + side: node.side, + visible: true, + }) + + if (previewNode.parentId) { + useScene.getState().createNode(previewNode, previewNode.parentId as AnyNodeId) + } + + draftRef.current = previewNode + modeRef.current = 'clone' + return previewNode + }, []) + const commit = useCallback((finalUpdate: Partial): string | null => { const draft = draftRef.current if (!draft) return null - if (adoptedRef.current) { + if (modeRef.current === 'adopt') { // Move mode: update in place (single undoable action) const { parentId: newParentId, ...updateProps } = finalUpdate const parentId = @@ -119,9 +182,11 @@ export function useDraftNode(): DraftNodeHandle { useScene.getState().updateNode(draft.id, { position: original.position, rotation: original.rotation, + scale: original.scale, side: original.side, parentId: original.parentId, metadata: original.metadata, + visible: original.visible, }) // Resume → tracked update (undo reverts to original) @@ -139,11 +204,45 @@ export function useDraftNode(): DraftNodeHandle { const id = draft.id draftRef.current = null - adoptedRef.current = false + modeRef.current = null originalStateRef.current = null return id } + if (modeRef.current === 'clone') { + const { parentId: newParentId, ...updateProps } = finalUpdate + const original = originalStateRef.current + if (!original) { + return null + } + + const parentId = newParentId ?? original.parentId ?? useViewer.getState().selection.levelId + + draftRef.current = null + + useScene.temporal.getState().resume() + + if (draft.id in useScene.getState().nodes) { + useScene.getState().deleteNode(draft.id) + } + + useScene.getState().updateNode(original.id, { + metadata: (updateProps.metadata ?? original.metadata) as ItemNode['metadata'], + parentId: parentId as string, + position: updateProps.position ?? original.position, + rotation: updateProps.rotation ?? original.rotation, + scale: updateProps.scale ?? original.scale, + side: updateProps.side ?? original.side, + visible: true, + }) + + useScene.temporal.getState().pause() + + modeRef.current = null + originalStateRef.current = null + return original.id + } + // Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause const { parentId: newParentId, ...updateProps } = finalUpdate const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId @@ -170,7 +269,7 @@ export function useDraftNode(): DraftNodeHandle { // Re-pause for next draft cycle useScene.temporal.getState().pause() - adoptedRef.current = false + modeRef.current = null originalStateRef.current = null return finalNode.id }, []) @@ -178,7 +277,7 @@ export function useDraftNode(): DraftNodeHandle { const destroy = useCallback(() => { if (!draftRef.current) return - if (adoptedRef.current && originalStateRef.current) { + if (modeRef.current === 'adopt' && originalStateRef.current) { // Move mode: restore original state instead of deleting const original = originalStateRef.current const id = draftRef.current.id @@ -186,9 +285,11 @@ export function useDraftNode(): DraftNodeHandle { useScene.getState().updateNode(id, { position: original.position, rotation: original.rotation, + scale: original.scale, side: original.side, parentId: original.parentId, metadata: original.metadata, + visible: original.visible, }) // Also reset the Three.js mesh directly — the store update triggers a React @@ -198,7 +299,16 @@ export function useDraftNode(): DraftNodeHandle { if (mesh) { mesh.position.set(original.position[0], original.position[1], original.position[2]) mesh.rotation.y = original.rotation[1] ?? 0 - mesh.visible = true + mesh.visible = original.visible + } + } else if (modeRef.current === 'clone' && originalStateRef.current) { + if (draftRef.current.id in useScene.getState().nodes) { + useScene.getState().deleteNode(draftRef.current.id) + } + + const originalMesh = sceneRegistry.nodes.get(originalStateRef.current.id) + if (originalMesh) { + originalMesh.visible = true } } else { // Create mode: delete the transient node @@ -206,7 +316,7 @@ export function useDraftNode(): DraftNodeHandle { } draftRef.current = null - adoptedRef.current = false + modeRef.current = null originalStateRef.current = null }, []) @@ -216,13 +326,17 @@ export function useDraftNode(): DraftNodeHandle { return draftRef.current }, get isAdopted() { - return adoptedRef.current + return modeRef.current === 'adopt' + }, + get mode() { + return modeRef.current }, create, adopt, + preview, commit, destroy, }), - [create, adopt, commit, destroy], + [create, adopt, preview, commit, destroy], ) } diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fcc477672..7fcd2304c 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -1,4 +1,4 @@ -import type { AssetInput } from '@pascal-app/core' +import type { AssetInput, ItemNode } from '@pascal-app/core' import { type AnyNodeId, type CeilingEvent, @@ -267,13 +267,22 @@ basePlaneMaterial.opacityNode = radialOpacity export interface PlacementCoordinatorConfig { asset: AssetInput | null + disabled?: boolean draftNode: DraftNodeHandle + ignoreItemIds?: string[] initDraft: (gridPosition: Vector3) => void + isDisabled?: () => boolean + onCommitRequested?: (request: { + nodeUpdate: Partial + surface: PlacementState['surface'] + }) => boolean onCommitted: () => boolean onCancel?: () => void initialState?: PlacementState /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */ defaultScale?: [number, number, number] + preserveDraftOnUnmount?: boolean | (() => boolean) + surfaceMode?: 'all' | 'floor-only' } export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode { @@ -291,6 +300,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) const [dimensionBounds, setDimensionBounds] = useState(null) + const allowNonFloorSurfaces = config.surfaceMode !== 'floor-only' // Store config callbacks in refs to avoid re-running effect when they change const configRef = useRef(config) @@ -445,12 +455,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const getContext = () => ({ asset, + ignoredItemIds: Array.from(new Set([...(configRef.current.ignoreItemIds ?? []), ...(draftNode.current ? [draftNode.current.id] : [])])), levelId: useViewer.getState().selection.levelId, draftItem: draftNode.current, gridPosition: gridPosition.current, state: { ...placementState.current }, currentCursorRotationY: cursorGroupRef.current.rotation.y, }) + const isDisabled = () => Boolean(configRef.current.disabled || configRef.current.isDisabled?.()) + const shouldCommit = (nodeUpdate: Partial) => + configRef.current.onCommitRequested?.({ nodeUpdate, surface: placementState.current.surface }) ?? true const getActiveValidators = () => shiftFreeRef.current @@ -547,6 +561,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea let previousGridPos: [number, number, number] | null = null const onGridMove = (event: GridEvent) => { + if (isDisabled()) return // Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now if (draftNode.current === null && asset.attachTo === undefined) { configRef.current.initDraft(gridPosition.current) @@ -588,8 +603,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onGridClick = (event: GridEvent) => { + if (isDisabled()) return const result = floorStrategy.click(getContext(), event, getActiveValidators()) if (!result) return + if (!shouldCommit(result.nodeUpdate)) return // Preserve cursor rotation for the next draft const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0] @@ -609,6 +626,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Wall Handlers ---- const onWallEnter = (event: WallEvent) => { + if (isDisabled() || !allowNonFloorSurfaces) return const nodes = useScene.getState().nodes const result = wallStrategy.enter( getContext(), @@ -634,6 +652,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onWallMove = (event: WallEvent) => { + if (isDisabled() || !allowNonFloorSurfaces) return const ctx = getContext() if (ctx.state.surface !== 'wall') { @@ -734,10 +753,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onWallClick = (event: WallEvent) => { + if (isDisabled() || !allowNonFloorSurfaces) return const result = wallStrategy.click(getContext(), event, getActiveValidators()) if (!result) return event.stopPropagation() + if (!shouldCommit(result.nodeUpdate)) return // Clear live transform before commit if (draftNode.current) { useLiveTransforms.getState().clear(draftNode.current.id) @@ -765,6 +786,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onWallLeave = (event: WallEvent) => { + if (isDisabled() || !allowNonFloorSurfaces) return const result = wallStrategy.leave(getContext()) if (!result) return @@ -823,6 +845,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onItemEnter = (event: ItemEvent) => { + if (isDisabled() || !allowNonFloorSurfaces) return if (event.node.id === draftNode.current?.id) return const result = itemSurfaceStrategy.enter(getContext(), event) if (!result) return @@ -839,6 +862,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onItemMove = (event: ItemEvent) => { + if (isDisabled() || !allowNonFloorSurfaces) return if (event.node.id === draftNode.current?.id) return const ctx = getContext() @@ -909,6 +933,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onItemLeave = (event: ItemEvent) => { + if (isDisabled() || !allowNonFloorSurfaces) return if (event.node.id === draftNode.current?.id) return if (placementState.current.surface !== 'item-surface') return @@ -923,11 +948,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onItemClick = (event: ItemEvent) => { + if (isDisabled() || !allowNonFloorSurfaces) return if (event.node.id === draftNode.current?.id) return const result = itemSurfaceStrategy.click(getContext(), event) if (!result) return event.stopPropagation() + if (!shouldCommit(result.nodeUpdate)) return // Clear live transform before commit if (draftNode.current) { useLiveTransforms.getState().clear(draftNode.current.id) @@ -948,6 +975,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Ceiling Handlers ---- const onCeilingEnter = (event: CeilingEvent) => { + if (isDisabled() || !allowNonFloorSurfaces) return const nodes = useScene.getState().nodes const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes) if (!result) return @@ -967,6 +995,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onCeilingMove = (event: CeilingEvent) => { + if (isDisabled() || !allowNonFloorSurfaces) return if (!draftNode.current && placementState.current.surface === 'ceiling') { const nodes = useScene.getState().nodes const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes) @@ -1014,10 +1043,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onCeilingClick = (event: CeilingEvent) => { + if (isDisabled() || !allowNonFloorSurfaces) return const result = ceilingStrategy.click(getContext(), event, getActiveValidators()) if (!result) return event.stopPropagation() + if (!shouldCommit(result.nodeUpdate)) return // Clear live transform before commit if (draftNode.current) { useLiveTransforms.getState().clear(draftNode.current.id) @@ -1036,6 +1067,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onCeilingLeave = (event: CeilingEvent) => { + if (isDisabled() || !allowNonFloorSurfaces) return const result = ceilingStrategy.leave(getContext()) if (!result) return @@ -1211,12 +1243,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (tearingDown) return const draft = draftNode.current if (draft === null) return + if (draftNode.mode === 'clone') return if (draft.id in state.nodes) return queueMicrotask(() => { if (tearingDown) return const draft = draftNode.current if (draft === null) return + if (draftNode.mode === 'clone') return if (draft.id in useScene.getState().nodes) return // Temporal is paused during placement, createNode won't be tracked useScene.getState().createNode(draft, draft.parentId as AnyNodeId) @@ -1243,11 +1277,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return () => { tearingDown = true unsubDraftWatch() - // Clear live transform for any remaining draft - if (draftNode.current) { - useLiveTransforms.getState().clear(draftNode.current.id) + const preserveDraftOnUnmount = typeof configRef.current.preserveDraftOnUnmount === 'function' ? configRef.current.preserveDraftOnUnmount() : configRef.current.preserveDraftOnUnmount === true + if (!preserveDraftOnUnmount) { + // Clear live transform for any remaining draft + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.destroy() } - draftNode.destroy() useScene.temporal.getState().resume() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) @@ -1268,7 +1305,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea window.removeEventListener('keyup', onKeyUp) window.removeEventListener('contextmenu', onContextMenu) } - }, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode]) + }, [allowNonFloorSurfaces, asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode]) // Refresh wireframe when the grid step changes mid-placement so the green/red // box snaps to the new cell size right away. diff --git a/packages/editor/src/components/ui/action-menu/camera-actions.tsx b/packages/editor/src/components/ui/action-menu/camera-actions.tsx index 4a86fb7d8..1d11a9b4f 100644 --- a/packages/editor/src/components/ui/action-menu/camera-actions.tsx +++ b/packages/editor/src/components/ui/action-menu/camera-actions.tsx @@ -2,9 +2,16 @@ import { emitter } from '@pascal-app/core' import Image from 'next/image' +import type { ReactNode } from 'react' import { ActionButton } from './action-button' -export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) { +export function CameraActions({ + afterCameraMode, + hideOrbit = false, +}: { + afterCameraMode?: ReactNode + hideOrbit?: boolean +}) { const goToTopView = () => { emitter.emit('camera-controls:top-view') } @@ -73,6 +80,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) { width={28} /> + {afterCameraMode}
) } diff --git a/packages/editor/src/components/ui/panels/item-panel.tsx b/packages/editor/src/components/ui/panels/item-panel.tsx index b4ee0154c..c00c1a5e1 100644 --- a/packages/editor/src/components/ui/panels/item-panel.tsx +++ b/packages/editor/src/components/ui/panels/item-panel.tsx @@ -55,7 +55,7 @@ export function ItemPanel() { const handleDuplicate = useCallback(() => { if (!node) return sfxEmitter.emit('sfx:item-pick') - const proto = ItemNode.parse({ + const duplicateInfo = { position: [...node.position] as [number, number, number], rotation: [...node.rotation] as [number, number, number], name: node.name, @@ -63,17 +63,18 @@ export function ItemPanel() { parentId: node.parentId, side: node.side, metadata: { isNew: true }, - }) + } + const proto = ItemNode.parse(duplicateInfo) setMovingNode(proto) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) const handleDelete = useCallback(() => { - if (!selectedId) return + if (!(selectedId && node)) return sfxEmitter.emit('sfx:item-delete') deleteNode(selectedId as AnyNode['id']) setSelection({ selectedIds: [] }) - }, [selectedId, deleteNode, setSelection]) + }, [node, selectedId, deleteNode, setSelection]) if (!(node && node.type === 'item' && selectedId)) return null diff --git a/packages/editor/src/hooks/use-auto-save.ts b/packages/editor/src/hooks/use-auto-save.ts index 43af28f7b..0936159d1 100644 --- a/packages/editor/src/hooks/use-auto-save.ts +++ b/packages/editor/src/hooks/use-auto-save.ts @@ -13,6 +13,7 @@ interface UseAutoSaveOptions { onDirty?: () => void onSaveStatusChange?: (status: SaveStatus) => void isVersionPreviewMode?: boolean + shouldPauseAutoSave?: () => boolean } /** @@ -26,6 +27,7 @@ export function useAutoSave({ onDirty, onSaveStatusChange, isVersionPreviewMode = false, + shouldPauseAutoSave, }: UseAutoSaveOptions): { isLoadingSceneRef: MutableRefObject } { const saveTimeoutRef = useRef(undefined) const isSavingRef = useRef(false) @@ -39,6 +41,7 @@ export function useAutoSave({ const onDirtyRef = useRef(onDirty) const onSaveStatusChangeRef = useRef(onSaveStatusChange) const isVersionPreviewModeRef = useRef(isVersionPreviewMode) + const shouldPauseAutoSaveRef = useRef(shouldPauseAutoSave) useEffect(() => { onSaveRef.current = onSave @@ -52,6 +55,9 @@ export function useAutoSave({ useEffect(() => { isVersionPreviewModeRef.current = isVersionPreviewMode }, [isVersionPreviewMode]) + useEffect(() => { + shouldPauseAutoSaveRef.current = shouldPauseAutoSave + }, [shouldPauseAutoSave]) const setSaveStatus = useCallback((status: SaveStatus) => { onSaveStatusChangeRef.current?.(status) @@ -63,7 +69,11 @@ export function useAutoSave({ let lastNodeCount = Object.keys(useScene.getState().nodes).length async function executeSave() { - if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) { + if ( + isLoadingSceneRef.current || + isVersionPreviewModeRef.current || + shouldPauseAutoSaveRef.current?.() + ) { pendingSaveRef.current = true setSaveStatus('paused') return @@ -121,7 +131,7 @@ export function useAutoSave({ return } - if (isVersionPreviewModeRef.current) { + if (isVersionPreviewModeRef.current || shouldPauseAutoSaveRef.current?.()) { setSaveStatus('paused') lastNodesSnapshot = JSON.stringify(state.nodes) return @@ -149,6 +159,7 @@ export function useAutoSave({ }) function flushOnExit() { + if (shouldPauseAutoSaveRef.current?.()) return if (!hasDirtyChangesRef.current) return const { nodes, rootNodeIds } = useScene.getState() const sceneGraph = { nodes, rootNodeIds } as SceneGraph @@ -173,7 +184,7 @@ export function useAutoSave({ // Handle version preview mode transitions useEffect(() => { - if (isVersionPreviewMode) { + if (isVersionPreviewMode || shouldPauseAutoSave?.()) { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current) saveTimeoutRef.current = undefined @@ -199,7 +210,7 @@ export function useAutoSave({ } setSaveStatus('saved') - }, [isVersionPreviewMode, setSaveStatus]) + }, [isVersionPreviewMode, setSaveStatus, shouldPauseAutoSave]) return { isLoadingSceneRef } } diff --git a/packages/editor/src/runtime.ts b/packages/editor/src/runtime.ts new file mode 100644 index 000000000..87910aa68 --- /dev/null +++ b/packages/editor/src/runtime.ts @@ -0,0 +1,2 @@ +export { triggerSFX } from './lib/sfx-bus' +export { default as useEditor } from './store/use-editor' diff --git a/packages/robot/package.json b/packages/robot/package.json new file mode 100644 index 000000000..fc1f42a79 --- /dev/null +++ b/packages/robot/package.json @@ -0,0 +1,46 @@ +{ + "name": "@pascal-app/robot", + "version": "0.1.0", + "description": "Robot navigation feature package for Pascal editor", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts", + "./editor": "./src/editor.tsx", + "./store": "./src/store/use-navigation.ts" + }, + "scripts": { + "check-types": "tsc --noEmit" + }, + "peerDependencies": { + "@pascal-app/core": "^0.8.0", + "@pascal-app/editor": "^0.8.0", + "@pascal-app/viewer": "^0.8.0", + "@react-three/drei": "^10", + "@react-three/fiber": "^9", + "next": ">=15", + "react": "^18 || ^19", + "react-dom": "^18 || ^19", + "three": "^0.184" + }, + "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-tooltip": "^1.2.8", + "clsx": "^2.1.1", + "lucide-react": "^0.562.0", + "mitt": "^3.0.1", + "tailwind-merge": "^3.5.0", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@pascal-app/core": "^0.8.0", + "@pascal-app/editor": "^0.8.0", + "@pascal-app/viewer": "^0.8.0", + "@pascal/typescript-config": "*", + "@types/node": "^22.19.12", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", + "@types/three": "^0.184.0", + "typescript": "5.9.3" + } +} diff --git a/packages/robot/src/components/navigation-camera-follow.ts b/packages/robot/src/components/navigation-camera-follow.ts new file mode 100644 index 000000000..d2ce5f9d9 --- /dev/null +++ b/packages/robot/src/components/navigation-camera-follow.ts @@ -0,0 +1,384 @@ +import type { CameraControlsImpl } from '@react-three/drei' +import { useFrame } from '@react-three/fiber' +import { type RefObject, useEffect, useRef } from 'react' +import { Vector3 } from 'three' +import useNavigation, { navigationEmitter } from '../store/use-navigation' + +const liveActorPosition = new Vector3() +const bufferedActorPosition = new Vector3() +const followFocusPoint = new Vector3() +const followDesiredPosition = new Vector3() +const followDesiredTarget = new Vector3() +const followDefaultViewDirection = new Vector3(0.62, -0.48, 0.62).normalize() +const tempPosition = new Vector3() +const FOLLOW_CAMERA_CLOSE_DISTANCE = 11.5 +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 setDefaultFollowPositionOffset(offset: Vector3) { + offset.copy(followDefaultViewDirection).multiplyScalar(-FOLLOW_CAMERA_CLOSE_DISTANCE) +} + +function isFiniteVector3(vector: Vector3) { + return Number.isFinite(vector.x) && Number.isFinite(vector.y) && Number.isFinite(vector.z) +} + +function applyFollowCameraPose( + controls: CameraControlsImpl, + position: Vector3, + target: Vector3, +) { + if (!(isFiniteVector3(position) && isFiniteVector3(target))) { + return + } + + controls.setLookAt(position.x, position.y, position.z, target.x, target.y, target.z, false) +} + +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 function useNavigationCameraFollow({ + controls, + isPreviewMode, + walkthroughMode, +}: { + controls: RefObject + isPreviewMode: boolean + walkthroughMode: boolean +}) { + const actorWorldPosition = useNavigation((state) => state.actorWorldPosition) + const followRobotEnabled = useNavigation((state) => state.followRobotEnabled) + const setFollowRobotEnabled = useNavigation((state) => state.setFollowRobotEnabled) + 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), + }) + + useEffect(() => { + if (!(followRobotEnabled && (isPreviewMode || walkthroughMode))) { + return + } + + setFollowRobotEnabled(false) + }, [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]) + + useEffect(() => { + 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, + ) + } + + navigationEmitter.on('navigation:look-at', handleLookAt) + + return () => { + navigationEmitter.off('navigation:look-at', handleLookAt) + } + }, [controls]) + + useEffect(() => { + const followRig = followRigRef.current + if (!followRobotEnabled) { + followRig.initialized = false + return + } + + if (!(controls.current && followRig.hasActorTransform)) { + return + } + + const delayedActorPosition = sampleBufferedActorPosition( + followHistoryRef.current, + performance.now() - FOLLOW_CAMERA_BUFFER_DELAY_MS, + bufferedActorPosition, + ) + ? bufferedActorPosition + : liveActorPosition + + followRig.actorAnchor.copy(delayedActorPosition) + followFocusPoint.copy(delayedActorPosition).add(FOLLOW_CAMERA_FOCUS_OFFSET) + setDefaultFollowPositionOffset(followRig.positionOffset) + followRig.cameraPosition.copy(followFocusPoint).add(followRig.positionOffset) + followRig.cameraTarget.copy(followFocusPoint) + followRig.initialized = true + }, [controls, 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) + } + }, [controls, followRobotEnabled]) + + useFrame((_, delta) => { + if (!controls.current || !followRobotEnabled || isPreviewMode || walkthroughMode) { + return + } + + const followRig = followRigRef.current + if (!followRig.hasActorTransform) { + return + } + + if (!followRig.initialized) { + followRig.actorAnchor.copy(liveActorPosition) + followFocusPoint.copy(liveActorPosition).add(FOLLOW_CAMERA_FOCUS_OFFSET) + setDefaultFollowPositionOffset(followRig.positionOffset) + followRig.cameraPosition.copy(followFocusPoint).add(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) + applyFollowCameraPose(controls.current, followRig.cameraPosition, followRig.cameraTarget) + 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) + + applyFollowCameraPose(controls.current, followRig.cameraPosition, followRig.cameraTarget) + }, 100) +} diff --git a/packages/robot/src/components/navigation-door-system.tsx b/packages/robot/src/components/navigation-door-system.tsx new file mode 100644 index 000000000..d578ec1d2 --- /dev/null +++ b/packages/robot/src/components/navigation-door-system.tsx @@ -0,0 +1,867 @@ +'use client' + +import { + type AnyNodeId, + type DoorInteractiveState, + isOperationDoorType, + sceneRegistry, + useInteractive, + useScene, +} from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { type MutableRefObject, useEffect, useMemo, useRef } from 'react' +import { + Box3, + type Curve, + Euler, + Group, + 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 SCENE_DOOR_OPEN_RESPONSE = 8 +const SCENE_DOOR_CLOSE_RESPONSE = 6 +const SCENE_DOOR_OPEN_SETTLE_EPSILON = 0.01 +const SCENE_DOOR_SWING_OPEN_ANGLE = MathUtils.degToRad(90) +const activeNavigationDoorIds = new Set() +const activeNavigationDoorOpenAmounts = new Map() + +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 +} + +export function getActiveNavigationDoorOpenAmounts() { + return activeNavigationDoorOpenAmounts +} + +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 ensureItemDoorLeafPivot(doorRoot: Object3D) { + const existingLeafPivot = doorRoot.getObjectByName('door-leaf-pivot') + if (existingLeafPivot) { + return existingLeafPivot + } + + const movableChildren = doorRoot.children.filter((child) => child.name !== 'door-leaf-pivot') + if (movableChildren.length === 0) { + return null + } + + const leafPivot = new Group() + leafPivot.name = 'door-leaf-pivot' + leafPivot.userData.pascalNavigationRuntime = true + + const leafGroup = new Group() + leafGroup.name = 'door-leaf-group' + leafGroup.userData.pascalNavigationRuntime = true + + for (const child of movableChildren) { + leafGroup.add(child) + } + + leafPivot.add(leafGroup) + doorRoot.add(leafPivot) + return leafPivot +} + +function ensureItemDoorAnimationState(doorId: string) { + const doorRoot = sceneRegistry.nodes.get(doorId) + const leafPivot = doorRoot ? ensureItemDoorLeafPivot(doorRoot) : undefined + 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 sceneDoorOpenAmountsRef = 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() + activeNavigationDoorOpenAmounts.clear() + for (const doorId of activeDoorIds) { + activeNavigationDoorIds.add(doorId) + } + let openDoorCount = 0 + + for (const doorId of activeDoorIds) { + const sceneDoorNode = useScene.getState().nodes[doorId as AnyNodeId] + if (sceneDoorNode?.type === 'door' && !sceneRegistry.byType.item.has(doorId)) { + const open = openDoorIds.has(doorId) + const isOperationDoor = isOperationDoorType(sceneDoorNode.doorType) + const interactive = useInteractive.getState() + const runtimeDoor = interactive.doors[doorId as AnyNodeId] + const persistedOpenValue = isOperationDoor + ? (sceneDoorNode.operationState ?? 0) + : (sceneDoorNode.swingAngle ?? 0) / SCENE_DOOR_SWING_OPEN_ANGLE + const runtimeOpenValue = isOperationDoor + ? runtimeDoor?.operationState + : runtimeDoor?.swingAngle !== undefined + ? runtimeDoor.swingAngle / SCENE_DOOR_SWING_OPEN_ANGLE + : undefined + const currentOpenAmount = + sceneDoorOpenAmountsRef.current.get(doorId) ?? + MathUtils.clamp(runtimeOpenValue ?? persistedOpenValue, 0, 1) + const targetOpenAmount = open ? 1 : 0 + const nextOpenAmount = MathUtils.damp( + currentOpenAmount, + targetOpenAmount, + targetOpenAmount > currentOpenAmount + ? SCENE_DOOR_OPEN_RESPONSE + : SCENE_DOOR_CLOSE_RESPONSE, + delta, + ) + const settled = + Math.abs(nextOpenAmount - targetOpenAmount) <= SCENE_DOOR_OPEN_SETTLE_EPSILON + const appliedOpenAmount = settled ? targetOpenAmount : nextOpenAmount + + if (targetOpenAmount > 0 || appliedOpenAmount > SCENE_DOOR_OPEN_SETTLE_EPSILON) { + const doorState: DoorInteractiveState = isOperationDoor + ? { operationState: appliedOpenAmount } + : { swingAngle: appliedOpenAmount * SCENE_DOOR_SWING_OPEN_ANGLE } + interactive.cancelDoorAnimation(doorId as AnyNodeId) + interactive.setDoorOpenState(doorId as AnyNodeId, doorState) + useScene.getState().markDirty(doorId as AnyNodeId) + sceneDoorOpenAmountsRef.current.set(doorId, appliedOpenAmount) + activeNavigationDoorOpenAmounts.set(doorId, appliedOpenAmount) + trackedDoorIdsRef.current.add(doorId) + if (appliedOpenAmount > 0.08) { + openDoorCount += 1 + } + } else { + interactive.cancelDoorAnimation(doorId as AnyNodeId) + interactive.removeDoorOpenState(doorId as AnyNodeId) + useScene.getState().markDirty(doorId as AnyNodeId) + sceneDoorOpenAmountsRef.current.delete(doorId) + trackedDoorIdsRef.current.delete(doorId) + activeNavigationDoorOpenAmounts.set(doorId, 0) + } + continue + } + + const { animationState, leafPivot } = getDoorAnimationState(doorId) + if (!leafPivot) { + trackedDoorIdsRef.current.delete(doorId) + overheadDoorOpenAmountsRef.current.delete(doorId) + sceneDoorOpenAmountsRef.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 () => { + for (const doorId of sceneDoorOpenAmountsRef.current.keys()) { + useInteractive.getState().cancelDoorAnimation(doorId as AnyNodeId) + useInteractive.getState().removeDoorOpenState(doorId as AnyNodeId) + useScene.getState().markDirty(doorId as AnyNodeId) + } + sceneDoorOpenAmountsRef.current.clear() + activeNavigationDoorIds.clear() + activeNavigationDoorOpenAmounts.clear() + } + }, []) + + return null +} diff --git a/packages/robot/src/components/navigation-item-action-menu.tsx b/packages/robot/src/components/navigation-item-action-menu.tsx new file mode 100644 index 000000000..77343a00c --- /dev/null +++ b/packages/robot/src/components/navigation-item-action-menu.tsx @@ -0,0 +1,673 @@ +'use client' + +import { + type AnyNodeId, + emitter, + generateId, + getScaledDimensions, + type GridEvent, + ItemNode, + type ItemEvent, + sceneRegistry, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useFrame, useThree } from '@react-three/fiber' +import { Copy, Move, Trash2, Wrench } from 'lucide-react' +import { type MouseEvent, type PointerEvent, useCallback, useEffect, useRef, useState } from 'react' +import { Box3, type Camera, type Object3D, Vector3 } from 'three' +import { + canUseRobotItemTask, + requestNavigationItemDelete, + requestNavigationItemRepair, + default as useNavigation, + type NavigationItemMoveRequest, +} from '../store/use-navigation' +import navigationVisualsStore from '../store/use-navigation-visuals' +import { stripTransientMetadata } from '../lib/transient' + +const BUTTON_CLASS = + 'flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-muted-foreground shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-foreground' +const ACTION_MENU_HALF_WIDTH_PX = 76 +const ACTION_MENU_HALF_HEIGHT_PX = 24 +const actionMenuProjectedPosition = new Vector3() + +type PendingPlacement = { + finalPosition: [number, number, number] + finalRotation: [number, number, number] + operation: 'copy' | 'move' + previewItemId: ItemNode['id'] + sourceItemId: ItemNode['id'] +} + +function getRobotSelectableObject(itemId: ItemNode['id'] | null) { + if (!itemId) { + return null + } + + const node = useScene.getState().nodes[itemId as AnyNodeId] + if (node?.type !== 'item' || !canUseRobotItemTask(node)) { + return null + } + + const object = sceneRegistry.nodes.get(itemId) + return object?.parent ? object : null +} + +function syncRobotViewerObjectList(target: Object3D[], itemId: ItemNode['id'] | null) { + const object = getRobotSelectableObject(itemId) + if (target.length === (object ? 1 : 0) && target[0] === object) { + return + } + + target.length = 0 + if (object) { + target.push(object) + } +} + +function syncRobotViewerItemState( + selectedItemId: ItemNode['id'] | null, + hoveredItemId: ItemNode['id'] | null, +) { + const viewerState = useViewer.getState() + const selectedObject = getRobotSelectableObject(selectedItemId) + const hoveredObject = getRobotSelectableObject(hoveredItemId) + const resolvedSelectedItemId = selectedObject ? selectedItemId : null + const resolvedHoveredItemId = hoveredObject ? hoveredItemId : null + + if (viewerState.hoveredId !== resolvedHoveredItemId) { + viewerState.setHoveredId(resolvedHoveredItemId) + } + if (viewerState.previewSelectedIds.length > 0) { + viewerState.setPreviewSelectedIds([]) + } + if ( + viewerState.selection.zoneId !== null || + viewerState.selection.selectedIds.length !== (resolvedSelectedItemId ? 1 : 0) || + viewerState.selection.selectedIds[0] !== resolvedSelectedItemId + ) { + viewerState.setSelection({ + buildingId: viewerState.selection.buildingId, + levelId: viewerState.selection.levelId, + selectedIds: resolvedSelectedItemId ? [resolvedSelectedItemId] : [], + zoneId: null, + }) + } + syncRobotViewerObjectList(viewerState.outliner.selectedObjects, resolvedSelectedItemId) + syncRobotViewerObjectList(viewerState.outliner.hoveredObjects, resolvedHoveredItemId) +} + +function clearRobotViewerItemState() { + syncRobotViewerItemState(null, null) +} + +function getPreviewVisualStateForOperation(operation: PendingPlacement['operation']) { + return operation === 'copy' ? 'copy-source-pending' : 'source-pending' +} + +function withPausedHistory(run: () => void) { + const temporal = useScene.temporal.getState() + temporal.pause() + try { + run() + } finally { + temporal.resume() + } +} + +function createPlacementPreviewNode( + source: ItemNode, + operation: PendingPlacement['operation'], +): PendingPlacement | null { + if (!source.parentId) { + return null + } + + const previewItemId = generateId( + operation === 'copy' ? 'item_debug_copy_preview' : 'item_debug_move_preview', + ) as ItemNode['id'] + const metadata = { + ...(stripTransientMetadata(source.metadata) as Record), + isTransient: true, + } + const previewNode = ItemNode.parse({ + asset: source.asset, + id: previewItemId, + metadata, + name: source.name, + parentId: source.parentId, + position: [...source.position] as [number, number, number], + rotation: [...source.rotation] as [number, number, number], + scale: [...source.scale] as [number, number, number], + side: source.side, + visible: true, + }) + + withPausedHistory(() => { + useScene.getState().createNode(previewNode, source.parentId as AnyNodeId) + }) + + const navigationVisuals = navigationVisualsStore.getState() + navigationVisuals.registerTaskPreviewNode(previewItemId) + navigationVisuals.setItemMovePreview({ id: previewItemId, sourceItemId: source.id }) + navigationVisuals.setItemMoveVisualState(previewItemId, 'destination-preview') + navigationVisuals.setItemMoveVisualState(source.id, getPreviewVisualStateForOperation(operation)) + + return { + finalPosition: [...source.position] as [number, number, number], + finalRotation: [...source.rotation] as [number, number, number], + operation, + previewItemId, + sourceItemId: source.id, + } +} + +function cleanupPlacementPreview(pending: PendingPlacement | null) { + if (!pending) { + return + } + + const navigationVisuals = navigationVisualsStore.getState() + navigationVisuals.setItemMoveVisualState(pending.sourceItemId, null) + navigationVisuals.setItemMoveVisualState(pending.previewItemId, null) + navigationVisuals.setItemMovePreview(null) + navigationVisuals.unregisterTaskPreviewNode(pending.previewItemId) + useLiveTransforms.getState().clear(pending.previewItemId) + withPausedHistory(() => { + const previewNode = useScene.getState().nodes[pending.previewItemId as AnyNodeId] + if (previewNode?.type === 'item') { + useScene.getState().deleteNode(pending.previewItemId as AnyNodeId) + } + }) +} + +function updatePlacementPreview(pending: PendingPlacement, position: [number, number, number]) { + useLiveTransforms.getState().set(pending.previewItemId, { + position, + rotation: pending.finalRotation[1] ?? 0, + }) + navigationVisualsStore.getState().setItemMoveVisualState(pending.previewItemId, 'destination-preview') +} + +function buildPlacementRequest( + pending: PendingPlacement, + source: ItemNode, +): NavigationItemMoveRequest { + const operation = pending.operation + return { + finalUpdate: { + metadata: stripTransientMetadata(source.metadata) as ItemNode['metadata'], + parentId: source.parentId, + position: pending.finalPosition, + rotation: pending.finalRotation, + side: source.side, + visible: true, + }, + itemDimensions: getScaledDimensions(source), + itemId: source.id, + levelId: source.parentId, + operation, + sourcePosition: [...source.position] as [number, number, number], + sourceRotation: [...source.rotation] as [number, number, number], + targetPreviewItemId: pending.previewItemId, + visualItemId: + operation === 'copy' + ? (`${pending.previewItemId}__copy_carry` as ItemNode['id']) + : source.id, + } +} + +function isPrimaryPointerEvent(event: ItemEvent) { + const nativeEvent = event.nativeEvent as unknown as MouseEvent | PointerEvent | undefined + return typeof nativeEvent?.button !== 'number' || nativeEvent.button === 0 +} + +function resolveMenuPosition( + itemId: ItemNode['id'], + fallbackPosition?: [number, number, number], +): [number, number, number] | null { + const object = sceneRegistry.nodes.get(itemId) + if (object) { + const bounds = new Box3().setFromObject(object) + if (!bounds.isEmpty()) { + const center = bounds.getCenter(new Vector3()) + return [center.x, bounds.max.y + 0.3, center.z] + } + } + + if (fallbackPosition) { + return [fallbackPosition[0], fallbackPosition[1] + 0.5, fallbackPosition[2]] + } + + return null +} + +function calculateActionMenuScreenPosition( + object: Object3D, + camera: Camera, + size: { width: number; height: number }, +) { + actionMenuProjectedPosition.setFromMatrixPosition(object.matrixWorld).project(camera) + const x = (actionMenuProjectedPosition.x * size.width) / 2 + size.width / 2 + const y = (-actionMenuProjectedPosition.y * size.height) / 2 + size.height / 2 + + return [ + Math.min(Math.max(x, ACTION_MENU_HALF_WIDTH_PX), size.width - ACTION_MENU_HALF_WIDTH_PX), + Math.min(Math.max(y, ACTION_MENU_HALF_HEIGHT_PX), size.height - ACTION_MENU_HALF_HEIGHT_PX), + ] +} + +export function NavigationItemActionMenu() { + const robotMode = useNavigation((state) => state.robotMode) + const suppressNavigationClick = useNavigation((state) => state.suppressNavigationClick) + const invalidate = useThree((state) => state.invalidate) + const [activeItemId, setActiveItemId] = useState(null) + const [menuPosition, setMenuPosition] = useState<[number, number, number] | null>(null) + const [pendingPlacement, setPendingPlacement] = useState(null) + const hoveredRobotItemIdRef = useRef(null) + const ignoreGridClearUntilRef = useRef(0) + const pendingPlacementRef = useRef(null) + const pointerActionHandledRef = useRef(false) + const selectedRobotItemIdRef = useRef(null) + const activeItem = useScene((state) => + activeItemId ? (state.nodes[activeItemId as AnyNodeId] as ItemNode | undefined) : undefined, + ) + + const clearRobotSelection = useCallback(() => { + selectedRobotItemIdRef.current = null + hoveredRobotItemIdRef.current = null + clearRobotViewerItemState() + invalidate() + }, [invalidate]) + + const selectRobotItem = useCallback( + (item: ItemNode) => { + selectedRobotItemIdRef.current = item.id + hoveredRobotItemIdRef.current = null + syncRobotViewerItemState(item.id, null) + invalidate() + }, + [invalidate], + ) + + const setRobotHoverItem = useCallback( + (itemId: ItemNode['id'] | null) => { + hoveredRobotItemIdRef.current = itemId + syncRobotViewerItemState(selectedRobotItemIdRef.current, itemId) + invalidate() + }, + [invalidate], + ) + + useEffect(() => { + pendingPlacementRef.current = pendingPlacement + }, [pendingPlacement]) + + useFrame(() => { + if (robotMode === null) { + return + } + if (!selectedRobotItemIdRef.current && !hoveredRobotItemIdRef.current) { + return + } + + syncRobotViewerItemState(selectedRobotItemIdRef.current, hoveredRobotItemIdRef.current) + }) + + useEffect(() => { + if (robotMode === null) { + cleanupPlacementPreview(pendingPlacementRef.current) + pendingPlacementRef.current = null + setPendingPlacement(null) + setActiveItemId(null) + setMenuPosition(null) + clearRobotSelection() + return + } + clearRobotSelection() + }, [clearRobotSelection, robotMode]) + + useEffect(() => { + if (robotMode === null) { + return + } + + const handleItemPointerDown = (event: ItemEvent) => { + if (pendingPlacementRef.current) { + return + } + if (!isPrimaryPointerEvent(event)) { + return + } + if (!canUseRobotItemTask(event.node as ItemNode)) { + return + } + suppressNavigationClick(500) + } + + const handleItemEnter = (event: ItemEvent) => { + if (pendingPlacementRef.current) { + return + } + const item = event.node as ItemNode + if (!canUseRobotItemTask(item)) { + return + } + + setRobotHoverItem(item.id) + } + + const handleItemLeave = (event: ItemEvent) => { + const item = event.node as ItemNode + if (!canUseRobotItemTask(item)) { + return + } + + if (hoveredRobotItemIdRef.current === item.id) { + setRobotHoverItem(null) + } + } + + const handleItemClick = (event: ItemEvent) => { + if (pendingPlacementRef.current) { + return + } + if (!isPrimaryPointerEvent(event)) { + return + } + const item = event.node as ItemNode + if (!canUseRobotItemTask(item)) { + return + } + + event.stopPropagation() + suppressNavigationClick(500) + selectRobotItem(item) + if ( + typeof window !== 'undefined' && + window.localStorage.getItem('pascal-navigation-debug') === '1' + ) { + document.documentElement.dataset.pascalRobotActionMenuNode = item.id + } + setActiveItemId(item.id) + const nextMenuPosition = resolveMenuPosition(item.id, event.position ?? item.position) + if ( + typeof window !== 'undefined' && + window.localStorage.getItem('pascal-navigation-debug') === '1' + ) { + document.documentElement.dataset.pascalRobotActionMenuPosition = JSON.stringify( + nextMenuPosition, + ) + } + setMenuPosition(nextMenuPosition) + ignoreGridClearUntilRef.current = performance.now() + 200 + } + + const clearMenu = () => { + if (pendingPlacementRef.current) { + return + } + if (performance.now() < ignoreGridClearUntilRef.current) { + return + } + setActiveItemId(null) + setMenuPosition(null) + clearRobotSelection() + } + + emitter.on('item:enter', handleItemEnter as never) + emitter.on('item:leave', handleItemLeave as never) + emitter.on('item:pointerdown', handleItemPointerDown as never) + emitter.on('item:click', handleItemClick as never) + emitter.on('grid:click', clearMenu as never) + + return () => { + emitter.off('item:enter', handleItemEnter as never) + emitter.off('item:leave', handleItemLeave as never) + emitter.off('item:pointerdown', handleItemPointerDown as never) + emitter.off('item:click', handleItemClick as never) + emitter.off('grid:click', clearMenu as never) + } + }, [clearRobotSelection, robotMode, selectRobotItem, setRobotHoverItem, suppressNavigationClick]) + + const closeMenu = useCallback(() => { + setActiveItemId(null) + setMenuPosition(null) + clearRobotSelection() + }, [clearRobotSelection]) + + useEffect(() => { + if (!pendingPlacement) { + return + } + + const cancelPlacement = () => { + const pending = pendingPlacementRef.current + cleanupPlacementPreview(pending) + pendingPlacementRef.current = null + setPendingPlacement(null) + } + + const handleGridMove = (event: GridEvent) => { + const pending = pendingPlacementRef.current + if (!pending) { + return + } + + const source = useScene.getState().nodes[pending.sourceItemId as AnyNodeId] + if (source?.type !== 'item') { + cancelPlacement() + return + } + + const finalPosition: [number, number, number] = [ + event.localPosition[0], + source.position[1], + event.localPosition[2], + ] + const nextPending = { + ...pending, + finalPosition, + } + pendingPlacementRef.current = nextPending + updatePlacementPreview(nextPending, finalPosition) + } + + const handleGridClick = () => { + const pending = pendingPlacementRef.current + if (!pending) { + return + } + + const source = useScene.getState().nodes[pending.sourceItemId as AnyNodeId] + if (source?.type !== 'item') { + cancelPlacement() + return + } + + const navigationVisuals = navigationVisualsStore.getState() + navigationVisuals.setItemMoveVisualState( + pending.sourceItemId, + getPreviewVisualStateForOperation(pending.operation), + ) + navigationVisuals.setItemMoveVisualState(pending.previewItemId, 'destination-ghost') + navigationVisuals.setItemMovePreview({ id: pending.previewItemId, sourceItemId: source.id }) + useLiveTransforms.getState().clear(pending.previewItemId) + withPausedHistory(() => { + useScene.getState().updateNode(pending.previewItemId as AnyNodeId, { + metadata: { + ...(stripTransientMetadata(source.metadata) as Record), + isTransient: true, + }, + parentId: source.parentId, + position: pending.finalPosition, + rotation: pending.finalRotation, + visible: true, + }) + }) + useNavigation.getState().requestItemMove(buildPlacementRequest(pending, source)) + useNavigation.getState().setItemMoveLocked(false) + pendingPlacementRef.current = null + setPendingPlacement(null) + suppressNavigationClick(500) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + cancelPlacement() + } + } + + emitter.on('grid:move', handleGridMove as never) + emitter.on('grid:click', handleGridClick as never) + emitter.on('grid:context-menu', cancelPlacement as never) + window.addEventListener('keydown', handleKeyDown) + + return () => { + emitter.off('grid:move', handleGridMove as never) + emitter.off('grid:click', handleGridClick as never) + emitter.off('grid:context-menu', cancelPlacement as never) + window.removeEventListener('keydown', handleKeyDown) + } + }, [pendingPlacement, suppressNavigationClick]) + + useEffect( + () => () => { + cleanupPlacementPreview(pendingPlacementRef.current) + pendingPlacementRef.current = null + }, + [], + ) + + const beginPlacement = useCallback( + (operation: PendingPlacement['operation']) => { + if (!activeItem) { + return + } + + cleanupPlacementPreview(pendingPlacementRef.current) + const pending = createPlacementPreviewNode(activeItem, operation) + if (!pending) { + return + } + + pendingPlacementRef.current = pending + setPendingPlacement(pending) + setActiveItemId(null) + setMenuPosition(null) + selectRobotItem(activeItem) + }, + [activeItem, selectRobotItem], + ) + + const handleMove = useCallback(() => { + if (!activeItem) { + return + } + beginPlacement('move') + }, [activeItem, beginPlacement]) + + const handleCopy = useCallback(() => { + if (!activeItem) { + return + } + beginPlacement('copy') + }, [activeItem, beginPlacement]) + + const handleRepair = useCallback(() => { + if (activeItem) { + requestNavigationItemRepair(activeItem) + } + closeMenu() + }, [activeItem, closeMenu]) + + const handleDelete = useCallback(() => { + if (activeItem) { + requestNavigationItemDelete(activeItem) + } + closeMenu() + }, [activeItem, closeMenu]) + + const runMenuActionFromPointer = useCallback( + (event: PointerEvent, action: () => void) => { + event.preventDefault() + event.stopPropagation() + pointerActionHandledRef.current = true + action() + window.setTimeout(() => { + pointerActionHandledRef.current = false + }, 0) + }, + [], + ) + + const runMenuActionFromClick = useCallback( + (event: MouseEvent, action: () => void) => { + event.stopPropagation() + if (pointerActionHandledRef.current) { + event.preventDefault() + return + } + action() + }, + [], + ) + + if (!(robotMode && activeItem && canUseRobotItemTask(activeItem) && menuPosition)) { + return null + } + + return ( + + +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + > + + + + +
+ +
+ ) +} diff --git a/packages/robot/src/components/navigation-item-visual-system.tsx b/packages/robot/src/components/navigation-item-visual-system.tsx new file mode 100644 index 000000000..86ba8c8aa --- /dev/null +++ b/packages/robot/src/components/navigation-item-visual-system.tsx @@ -0,0 +1,553 @@ +'use client' + +import { + type AnyNodeId, + type BaseNode, + sceneRegistry, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { + Color, + MathUtils, + type Material, + type Mesh, + type Object3D, +} from 'three' +import type { ItemMoveVisualState } from '../lib/item-move-visuals' +import navigationVisualsStore from '../store/use-navigation-visuals' + +const ITEM_DELETE_FADE_OUT_MS = 900 +const ITEM_DELETE_VISIBILITY_EPSILON = 0.001 + +type RuntimeTransformRestore = { + position: [number, number, number] + rotationY: number + visible: boolean +} + +function getSceneTransformRestore(id: string, fallback: RuntimeTransformRestore) { + const node = useScene.getState().nodes[id as AnyNodeId] + if (!node || !('position' in node) || !Array.isArray(node.position)) { + return fallback + } + + const rotation = 'rotation' in node && Array.isArray(node.rotation) ? node.rotation : null + return { + position: [...node.position] as [number, number, number], + rotationY: rotation?.[1] ?? fallback.rotationY, + visible: 'visible' in node && typeof node.visible === 'boolean' ? node.visible : fallback.visible, + } +} + +type FadeMaterial = Material & { + depthWrite?: boolean + needsUpdate?: boolean + opacity?: number + transparent?: boolean + userData: Record & { + navigationDeleteBaseOpacity?: number + } +} + +type DeleteFadeEntry = { + fadeMaterial: Material | Material[] + originalMaterial: Material | Material[] +} + +type MoveVisualMaterial = Material & { + color?: Color + depthWrite?: boolean + needsUpdate?: boolean + opacity?: number + transparent?: boolean +} + +type MoveVisualMaterialEntry = { + originalMaterial: Material | Material[] + state: ItemMoveVisualState + visualMaterial: Material | Material[] +} + +type RepairMaterialEntry = { + originalMaterial: Material | Material[] + visualMaterial: Material | Material[] +} + +function isRenderableMesh(object: Object3D): object is Mesh { + return Boolean((object as Mesh).isMesh && (object as Mesh).material) +} + +function createFadeMaterial(material: Material): Material { + const nextMaterial = material.clone() as FadeMaterial + nextMaterial.userData = { + ...nextMaterial.userData, + navigationDeleteBaseOpacity: nextMaterial.opacity ?? 1, + } + nextMaterial.transparent = true + nextMaterial.depthWrite = false + nextMaterial.needsUpdate = true + return nextMaterial +} + +function createFadeMaterials(material: Material | Material[]): Material | Material[] { + return Array.isArray(material) + ? material.map((entry) => createFadeMaterial(entry)) + : createFadeMaterial(material) +} + +function disposeMaterials(material: Material | Material[]) { + if (Array.isArray(material)) { + material.forEach((entry) => entry.dispose()) + return + } + + material.dispose() +} + +function applyFadeOpacity(material: Material | Material[], fadeAlpha: number) { + const apply = (entry: Material) => { + const fadeMaterial = entry as FadeMaterial + const baseOpacity = + fadeMaterial.userData.navigationDeleteBaseOpacity ?? 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(apply) + return + } + + apply(material) +} + +function restoreFadeMaterials(entries: Map) { + for (const [mesh, entry] of entries.entries()) { + if (mesh.material === entry.fadeMaterial) { + mesh.material = entry.originalMaterial + } + disposeMaterials(entry.fadeMaterial) + } + entries.clear() +} + +function syncDeleteFadeMaterials( + object: Object3D, + entries: Map, + fadeAlpha: number, +) { + const activeMeshes = new Set() + + object.traverse((child) => { + if (!isRenderableMesh(child)) { + return + } + + activeMeshes.add(child) + let entry = entries.get(child) + if (!entry) { + entry = { + fadeMaterial: createFadeMaterials(child.material), + originalMaterial: child.material, + } + child.material = entry.fadeMaterial + entries.set(child, entry) + } + + applyFadeOpacity(entry.fadeMaterial, fadeAlpha) + }) + + for (const [mesh, entry] of entries.entries()) { + if (activeMeshes.has(mesh)) { + continue + } + + if (mesh.material === entry.fadeMaterial) { + mesh.material = entry.originalMaterial + } + disposeMaterials(entry.fadeMaterial) + entries.delete(mesh) + } +} + +function getMoveVisualStyle(state: ItemMoveVisualState) { + switch (state) { + case 'destination-ghost': + return { color: '#22c55e', opacity: 0.42, tint: 0.38 } + case 'destination-preview': + return { color: '#10b981', opacity: 0.48, tint: 0.34 } + case 'carried': + return { color: '#38bdf8', opacity: 0.72, tint: 0.22 } + default: + return null + } +} + +function createMoveVisualMaterial(material: Material, state: ItemMoveVisualState): Material { + const style = getMoveVisualStyle(state) + const nextMaterial = material.clone() as MoveVisualMaterial + + if (!style) { + return nextMaterial + } + + if (nextMaterial.color) { + nextMaterial.color.lerp(new Color(style.color), style.tint) + } + nextMaterial.opacity = (nextMaterial.opacity ?? 1) * style.opacity + nextMaterial.transparent = true + nextMaterial.depthWrite = false + nextMaterial.needsUpdate = true + return nextMaterial +} + +function createMoveVisualMaterials( + material: Material | Material[], + state: ItemMoveVisualState, +): Material | Material[] { + return Array.isArray(material) + ? material.map((entry) => createMoveVisualMaterial(entry, state)) + : createMoveVisualMaterial(material, state) +} + +function restoreMoveVisualMaterials(entries: Map) { + for (const [mesh, entry] of entries.entries()) { + if (mesh.material === entry.visualMaterial) { + mesh.material = entry.originalMaterial + } + disposeMaterials(entry.visualMaterial) + } + entries.clear() +} + +function createRepairMaterial(material: Material): Material { + const nextMaterial = material.clone() as MoveVisualMaterial + if (nextMaterial.color) { + nextMaterial.color.lerp(new Color('#52e8ff'), 0.42) + } + nextMaterial.opacity = (nextMaterial.opacity ?? 1) * 0.68 + nextMaterial.transparent = true + nextMaterial.depthWrite = false + nextMaterial.needsUpdate = true + return nextMaterial +} + +function createRepairMaterials(material: Material | Material[]): Material | Material[] { + return Array.isArray(material) + ? material.map((entry) => createRepairMaterial(entry)) + : createRepairMaterial(material) +} + +function restoreRepairMaterials(entries: Map) { + for (const [mesh, entry] of entries.entries()) { + if (mesh.material === entry.visualMaterial) { + mesh.material = entry.originalMaterial + } + disposeMaterials(entry.visualMaterial) + } + entries.clear() +} + +function syncRepairMaterials(object: Object3D, entries: Map) { + const activeMeshes = new Set() + + object.traverse((child) => { + if (!isRenderableMesh(child)) { + return + } + + activeMeshes.add(child) + if (entries.has(child)) { + return + } + + const entry = { + originalMaterial: child.material, + visualMaterial: createRepairMaterials(child.material), + } + child.material = entry.visualMaterial + entries.set(child, entry) + }) + + for (const [mesh, entry] of entries.entries()) { + if (activeMeshes.has(mesh)) { + continue + } + + if (mesh.material === entry.visualMaterial) { + mesh.material = entry.originalMaterial + } + disposeMaterials(entry.visualMaterial) + entries.delete(mesh) + } +} + +function syncMoveVisualMaterials( + object: Object3D, + entries: Map, + state: ItemMoveVisualState, +) { + const style = getMoveVisualStyle(state) + if (!style) { + restoreMoveVisualMaterials(entries) + return + } + + const activeMeshes = new Set() + + object.traverse((child) => { + if (!isRenderableMesh(child)) { + return + } + + activeMeshes.add(child) + let entry = entries.get(child) + if (entry?.state !== state) { + if (entry) { + if (child.material === entry.visualMaterial) { + child.material = entry.originalMaterial + } + disposeMaterials(entry.visualMaterial) + entries.delete(child) + } + + entry = { + originalMaterial: child.material, + state, + visualMaterial: createMoveVisualMaterials(child.material, state), + } + child.material = entry.visualMaterial + entries.set(child, entry) + } + }) + + for (const [mesh, entry] of entries.entries()) { + if (activeMeshes.has(mesh)) { + continue + } + + if (mesh.material === entry.visualMaterial) { + mesh.material = entry.originalMaterial + } + disposeMaterials(entry.visualMaterial) + entries.delete(mesh) + } +} + +export function NavigationItemVisualSystem() { + const restoresRef = useRef(new Map()) + const deleteFadeEntriesRef = useRef(new Map>()) + const moveVisualEntriesRef = useRef(new Map>()) + const repairEntriesRef = useRef(new Map>()) + + useFrame(() => { + const visualState = navigationVisualsStore.getState() + const liveTransforms = useLiveTransforms.getState().transforms + const trackedIds = new Set() + + for (const id of Object.keys(visualState.nodeVisibilityOverrides)) { + trackedIds.add(id) + } + for (const id of Object.keys(visualState.itemMoveVisualStates)) { + trackedIds.add(id) + } + for (const id of Object.keys(visualState.itemDeleteActivations)) { + trackedIds.add(id) + } + for (const id of Object.keys(visualState.itemRepairActivations)) { + trackedIds.add(id) + } + for (const id of liveTransforms.keys()) { + trackedIds.add(id) + } + for (const id of Object.keys(visualState.taskPreviewNodeIds)) { + trackedIds.add(id) + } + if (visualState.itemMovePreview) { + trackedIds.add(visualState.itemMovePreview.id) + trackedIds.add(visualState.itemMovePreview.sourceItemId) + } + + const now = typeof performance !== 'undefined' ? performance.now() : Date.now() + + for (const id of trackedIds) { + const object = sceneRegistry.nodes.get(id) + if (!object) { + continue + } + + if (!restoresRef.current.has(id)) { + restoresRef.current.set(id, { + position: [object.position.x, object.position.y, object.position.z], + rotationY: object.rotation.y, + visible: object.visible, + }) + } + + const transform = liveTransforms.get(id) + if (transform) { + object.position.set(transform.position[0], transform.position[1], transform.position[2]) + object.rotation.y = transform.rotation + object.updateMatrixWorld(true) + } + + const activation = visualState.itemDeleteActivations[id as BaseNode['id']] ?? null + const fadeStartedAtMs = activation?.fadeStartedAtMs ?? null + if (fadeStartedAtMs !== null) { + const moveEntries = moveVisualEntriesRef.current.get(id) + if (moveEntries) { + restoreMoveVisualMaterials(moveEntries) + moveVisualEntriesRef.current.delete(id) + } + const repairEntries = repairEntriesRef.current.get(id) + if (repairEntries) { + restoreRepairMaterials(repairEntries) + repairEntriesRef.current.delete(id) + } + + const fadeProgress = MathUtils.clamp( + (now - fadeStartedAtMs) / ITEM_DELETE_FADE_OUT_MS, + 0, + 1, + ) + const fadeAlpha = 1 - MathUtils.smootherstep(fadeProgress, 0, 1) + let entries = deleteFadeEntriesRef.current.get(id) + if (!entries) { + entries = new Map() + deleteFadeEntriesRef.current.set(id, entries) + } + syncDeleteFadeMaterials(object, entries, fadeAlpha) + object.visible = fadeAlpha > ITEM_DELETE_VISIBILITY_EPSILON + continue + } + + const fadeEntries = deleteFadeEntriesRef.current.get(id) + if (fadeEntries) { + restoreFadeMaterials(fadeEntries) + deleteFadeEntriesRef.current.delete(id) + } + + const repairActivation = visualState.itemRepairActivations[id as BaseNode['id']] ?? null + if (repairActivation) { + let entries = repairEntriesRef.current.get(id) + if (!entries) { + entries = new Map() + repairEntriesRef.current.set(id, entries) + } + syncRepairMaterials(object, entries) + } else { + const repairEntries = repairEntriesRef.current.get(id) + if (repairEntries) { + restoreRepairMaterials(repairEntries) + repairEntriesRef.current.delete(id) + } + } + + const moveVisualState = visualState.itemMoveVisualStates[id as BaseNode['id']] ?? null + if (moveVisualState) { + let entries = moveVisualEntriesRef.current.get(id) + if (!entries) { + entries = new Map() + moveVisualEntriesRef.current.set(id, entries) + } + syncMoveVisualMaterials(object, entries, moveVisualState) + if (entries.size === 0) { + moveVisualEntriesRef.current.delete(id) + } + } else { + const moveEntries = moveVisualEntriesRef.current.get(id) + if (moveEntries) { + restoreMoveVisualMaterials(moveEntries) + moveVisualEntriesRef.current.delete(id) + } + } + + const visibilityOverride = visualState.nodeVisibilityOverrides[id as BaseNode['id']] + if (visibilityOverride !== undefined) { + object.visible = visibilityOverride + } else { + object.visible = restoresRef.current.get(id)?.visible ?? object.visible + } + } + + for (const [id, restore] of restoresRef.current.entries()) { + if (trackedIds.has(id)) { + continue + } + + const object = sceneRegistry.nodes.get(id) + if (object) { + const sceneNode = useScene.getState().nodes[id as AnyNodeId] + if (sceneNode) { + const targetRestore = getSceneTransformRestore(id, restore) + object.position.set( + targetRestore.position[0], + targetRestore.position[1], + targetRestore.position[2], + ) + object.rotation.y = targetRestore.rotationY + object.visible = targetRestore.visible + object.updateMatrixWorld(true) + } else { + object.visible = false + object.updateMatrixWorld(true) + } + } + + const fadeEntries = deleteFadeEntriesRef.current.get(id) + if (fadeEntries) { + restoreFadeMaterials(fadeEntries) + deleteFadeEntriesRef.current.delete(id) + } + const moveEntries = moveVisualEntriesRef.current.get(id) + if (moveEntries) { + restoreMoveVisualMaterials(moveEntries) + moveVisualEntriesRef.current.delete(id) + } + const repairEntries = repairEntriesRef.current.get(id) + if (repairEntries) { + restoreRepairMaterials(repairEntries) + repairEntriesRef.current.delete(id) + } + restoresRef.current.delete(id) + } + }, 3) + + useEffect( + () => () => { + for (const [id, restore] of restoresRef.current.entries()) { + const object = sceneRegistry.nodes.get(id) + if (!object) { + continue + } + + object.position.set(restore.position[0], restore.position[1], restore.position[2]) + object.rotation.y = restore.rotationY + object.visible = restore.visible + object.updateMatrixWorld(true) + } + + for (const entries of deleteFadeEntriesRef.current.values()) { + restoreFadeMaterials(entries) + } + for (const entries of moveVisualEntriesRef.current.values()) { + restoreMoveVisualMaterials(entries) + } + for (const entries of repairEntriesRef.current.values()) { + restoreRepairMaterials(entries) + } + restoresRef.current.clear() + deleteFadeEntriesRef.current.clear() + moveVisualEntriesRef.current.clear() + repairEntriesRef.current.clear() + }, + [], + ) + + return null +} diff --git a/packages/robot/src/components/navigation-pascal-truck-material-system.tsx b/packages/robot/src/components/navigation-pascal-truck-material-system.tsx new file mode 100644 index 000000000..ca5070688 --- /dev/null +++ b/packages/robot/src/components/navigation-pascal-truck-material-system.tsx @@ -0,0 +1,256 @@ +'use client' + +import { sceneRegistry, useScene, type AnyNodeId, type ItemNode } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { + Texture, + type Material as MaterialType, + type Mesh, + type Object3D, +} from 'three' +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' +import { + PASCAL_TRUCK_ASSET, + PASCAL_TRUCK_ITEM_NODE_ID, +} from '../lib/pascal-truck' + +type MaterialInput = MaterialType | MaterialType[] + +type TruckMaterialSource = { + byMeshName: Map + fallback: MaterialInput | null + textures: Set + templates: Set +} + +type AppliedTruckMaterial = { + mesh: Mesh + originalMaterial: MaterialInput + texturedMaterial: MaterialInput +} + +const MATERIAL_TEXTURE_SLOTS = [ + 'alphaMap', + 'aoMap', + 'bumpMap', + 'clearcoatMap', + 'clearcoatNormalMap', + 'clearcoatRoughnessMap', + 'displacementMap', + 'emissiveMap', + 'envMap', + 'iridescenceMap', + 'iridescenceThicknessMap', + 'lightMap', + 'map', + 'metalnessMap', + 'normalMap', + 'roughnessMap', + 'sheenColorMap', + 'sheenRoughnessMap', + 'specularColorMap', + 'specularIntensityMap', + 'thicknessMap', + 'transmissionMap', +] as const + +function isRenderableMesh(object: Object3D): object is Mesh { + return Boolean((object as Mesh).isMesh && (object as Mesh).material) +} + +function asMaterialArray(material: MaterialInput): MaterialType[] { + return Array.isArray(material) ? material : [material] +} + +function cloneMaterialInput(material: MaterialInput): MaterialInput { + return Array.isArray(material) + ? material.map((entry) => entry.clone()) + : material.clone() +} + +function disposeMaterialInput(material: MaterialInput) { + for (const entry of asMaterialArray(material)) { + entry.dispose() + } +} + +function collectMaterialTextures(material: MaterialInput, textures: Set) { + for (const entry of asMaterialArray(material)) { + const materialRecord = entry as MaterialType & + Partial> + for (const slot of MATERIAL_TEXTURE_SLOTS) { + const texture = materialRecord[slot] + if (texture instanceof Texture) { + textures.add(texture) + } + } + } +} + +function buildTruckMaterialSource(root: Object3D): TruckMaterialSource | null { + const byMeshName = new Map() + const textures = new Set() + const templates = new Set() + let fallback: MaterialInput | null = null + + root.traverse((child) => { + if (!isRenderableMesh(child)) { + return + } + + const template = cloneMaterialInput(child.material) + for (const material of asMaterialArray(template)) { + templates.add(material) + material.needsUpdate = true + } + collectMaterialTextures(template, textures) + + if (child.name) { + byMeshName.set(child.name, template) + } + fallback ??= template + }) + + return fallback ? { byMeshName, fallback, textures, templates } : null +} + +function disposeTruckMaterialSource(source: TruckMaterialSource | null) { + if (!source) { + return + } + + for (const material of source.templates) { + material.dispose() + } + for (const texture of source.textures) { + texture.dispose() + } + source.byMeshName.clear() + source.templates.clear() + source.textures.clear() +} + +function disposeSourceSceneGeometry(root: Object3D) { + root.traverse((child) => { + if (isRenderableMesh(child)) { + child.geometry.dispose() + } + }) +} + +function getTruckAssetUrl() { + const sceneNode = useScene.getState().nodes[PASCAL_TRUCK_ITEM_NODE_ID as AnyNodeId] + const src = + sceneNode?.type === 'item' + ? ((sceneNode as ItemNode).asset.src ?? PASCAL_TRUCK_ASSET.src) + : PASCAL_TRUCK_ASSET.src + + if (typeof window === 'undefined') { + return src + } + + return new URL(src, window.location.origin).toString() +} + +function restoreAppliedMaterial(entry: AppliedTruckMaterial) { + if (entry.mesh.material === entry.texturedMaterial) { + entry.mesh.material = entry.originalMaterial + } + disposeMaterialInput(entry.texturedMaterial) +} + +function disposeAppliedMaterials(appliedMaterials: Map) { + for (const entry of appliedMaterials.values()) { + restoreAppliedMaterial(entry) + } + appliedMaterials.clear() +} + +export function NavigationPascalTruckMaterialSystem() { + const materialSourceRef = useRef(null) + const appliedMaterialsRef = useRef(new Map()) + + useEffect(() => { + let cancelled = false + const loader = new GLTFLoader() + + void loader + .loadAsync(getTruckAssetUrl()) + .then((gltf) => { + const source = buildTruckMaterialSource(gltf.scene) + disposeSourceSceneGeometry(gltf.scene) + + if (cancelled) { + disposeTruckMaterialSource(source) + return + } + + materialSourceRef.current = source + }) + .catch((error) => { + console.warn('[robot] Failed to load Pascal truck materials', error) + }) + + return () => { + cancelled = true + disposeAppliedMaterials(appliedMaterialsRef.current) + disposeTruckMaterialSource(materialSourceRef.current) + materialSourceRef.current = null + } + }, []) + + useFrame(() => { + const source = materialSourceRef.current + const truckObject = sceneRegistry.nodes.get(PASCAL_TRUCK_ITEM_NODE_ID) + const activeMeshIds = new Set() + + if (source && truckObject) { + truckObject.traverse((child) => { + if (!isRenderableMesh(child)) { + return + } + + activeMeshIds.add(child.uuid) + const currentEntry = appliedMaterialsRef.current.get(child.uuid) + if (currentEntry?.mesh === child && child.material === currentEntry.texturedMaterial) { + return + } + + if (currentEntry) { + restoreAppliedMaterial(currentEntry) + appliedMaterialsRef.current.delete(child.uuid) + } + + const sourceMaterial = + (child.name ? source.byMeshName.get(child.name) : null) ?? source.fallback + if (!sourceMaterial) { + return + } + + const texturedMaterial = cloneMaterialInput(sourceMaterial) + for (const material of asMaterialArray(texturedMaterial)) { + material.needsUpdate = true + } + + appliedMaterialsRef.current.set(child.uuid, { + mesh: child, + originalMaterial: child.material, + texturedMaterial, + }) + child.material = texturedMaterial + }) + } + + for (const [meshId, entry] of appliedMaterialsRef.current.entries()) { + if (activeMeshIds.has(meshId)) { + continue + } + + restoreAppliedMaterial(entry) + appliedMaterialsRef.current.delete(meshId) + } + }, 4) + + return null +} diff --git a/packages/robot/src/components/navigation-robot.tsx b/packages/robot/src/components/navigation-robot.tsx new file mode 100644 index 000000000..790542b3f --- /dev/null +++ b/packages/robot/src/components/navigation-robot.tsx @@ -0,0 +1,4788 @@ +'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 + toolConeResetToken?: number | string | null + 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, + toolConeResetToken = null, + 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()) + + useEffect(() => { + toolConeFrozenHullTargetItemIdRef.current = null + toolConeFrozenHullPointsRef.current = [] + toolConeFollowReleaseBlendRef.current = 0 + toolConeFollowReleasePoseReadyRef.current = false + toolConeLogicExpectedFrameIdRef.current = null + toolConeVisibleFrameIdRef.current = null + toolConeSubmittedAnyFrameIdRef.current = null + toolConeSubmittedMainFrameIdRef.current = null + toolConeSubmittedInwardGlowFrameIdRef.current = null + toolConeSubmittedOutwardGlowFrameIdRef.current = null + toolConeFailureStreakFramesRef.current = 0 + toolConeGeometryMissStreakFramesRef.current = 0 + toolConeRenderMissStreakFramesRef.current = 0 + for (const toolConeRenderable of leftToolConeRenderables) { + toolConeRenderable.mainMesh.visible = false + toolConeRenderable.inwardGlowMesh.visible = false + toolConeRenderable.outlineMesh.visible = false + toolConeRenderable.outwardGlowMesh.visible = false + } + setToolConeIsolatedOverlay(null) + }, [leftToolConeRenderables, toolConeResetToken]) + 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 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`, + warmupCamera, + ) + } + + 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/robot/src/components/navigation-scene-lifecycle.tsx b/packages/robot/src/components/navigation-scene-lifecycle.tsx new file mode 100644 index 000000000..286c3c2cb --- /dev/null +++ b/packages/robot/src/components/navigation-scene-lifecycle.tsx @@ -0,0 +1,691 @@ +'use client' + +import { + sceneRegistry, + type AnyNode, + type AnyNodeId, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { useEditor } from '@pascal-app/editor/runtime' +import { useViewer } from '@pascal-app/viewer' +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' +import { flushSync } from 'react-dom' +import { useShallow } from 'zustand/react/shallow' +import { setItemMoveVisualState } from '../lib/item-move-visuals' +import { setNavigationSceneRestorePending } from '../lib/navigation-auto-save' +import { + buildPascalTruckNodeForScene, + hasPascalTruckManualPlacement, + isPascalTruckNode, + PASCAL_TRUCK_ITEM_NODE_ID, + stripPascalTruckFromSceneGraph, +} from '../lib/pascal-truck' +import type { SceneGraph } from '../lib/scene' +import { stripTransientMetadata } from '../lib/transient' +import useNavigation, { type NavigationRobotMode } from '../store/use-navigation' +import navigationVisualsStore from '../store/use-navigation-visuals' + +type TaskModeRestoreMode = 'full' | 'task-loop' + +type TaskModeSceneLifecycleController = { + restoreTaskLoopSceneSnapshot: (taskLoopToken: number) => boolean +} + +let taskModeSceneLifecycleController: TaskModeSceneLifecycleController | null = null + +export function restoreNavigationTaskLoopSceneSnapshot(taskLoopToken: number) { + return taskModeSceneLifecycleController?.restoreTaskLoopSceneSnapshot(taskLoopToken) ?? false +} + +function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { + if (typeof structuredClone === 'function') { + return structuredClone(sceneGraph) + } + + return JSON.parse(JSON.stringify(sceneGraph)) as SceneGraph +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function hasTransientNavigationMetadata(node: AnyNode | unknown) { + if (!isRecord(node) || !isRecord(node.metadata)) { + return false + } + + return node.metadata.isTransient === true +} + +function sanitizeTaskModeSceneGraph(sceneGraph?: SceneGraph | null): SceneGraph | null | undefined { + if (!sceneGraph) { + return sceneGraph + } + + const withoutTruck = stripPascalTruckFromSceneGraph(sceneGraph).sceneGraph ?? sceneGraph + const sanitized = cloneSceneGraph(withoutTruck) + const previewNodeIds = navigationVisualsStore.getState().taskPreviewNodeIds + const removedNodeIds = new Set() + + for (const [nodeId, node] of Object.entries(sanitized.nodes)) { + if ( + previewNodeIds[nodeId] || + hasTransientNavigationMetadata(node) || + (isPascalTruckNode(node) && !hasPascalTruckManualPlacement(node)) + ) { + removedNodeIds.add(nodeId) + delete sanitized.nodes[nodeId] + continue + } + + if (isRecord(node) && 'metadata' in node) { + sanitized.nodes[nodeId] = { + ...node, + metadata: setItemMoveVisualState(stripTransientMetadata(node.metadata), null), + } + } + } + + if (removedNodeIds.size > 0) { + sanitized.rootNodeIds = sanitized.rootNodeIds.filter((nodeId) => !removedNodeIds.has(nodeId)) + + for (const [nodeId, node] of Object.entries(sanitized.nodes)) { + 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) { + sanitized.nodes[nodeId] = { + ...node, + children: nextChildren, + } + } + } + } + + return sanitized +} + +function hasTaskModeSceneContent( + sceneGraph: SceneGraph | null | undefined, +): sceneGraph is SceneGraph { + return Boolean( + sceneGraph && + Array.isArray(sceneGraph.rootNodeIds) && + sceneGraph.rootNodeIds.length > 0 && + Object.keys(sceneGraph.nodes ?? {}).length > 0, + ) +} + +function canCaptureTaskModeBaselineSnapshot() { + const navigationState = useNavigation.getState() + return ( + navigationState.enabled && + navigationState.robotMode === 'task' && + navigationState.taskQueue.length === 0 && + navigationState.itemDeleteRequest === null && + navigationState.itemMoveRequest === null && + navigationState.itemRepairRequest === null && + Object.keys(navigationState.itemMoveControllers).length === 0 + ) +} + +function hasActiveTaskModeRuntimeState() { + const navigationState = useNavigation.getState() + return ( + navigationState.taskQueue.length > 0 || + navigationState.itemDeleteRequest !== null || + navigationState.itemMoveRequest !== null || + navigationState.itemRepairRequest !== null || + Object.keys(navigationState.itemMoveControllers).length > 0 + ) +} + +function cloneSceneNode(node: T): T { + if (typeof structuredClone === 'function') { + return structuredClone(node) + } + + return JSON.parse(JSON.stringify(node)) as T +} + +function getManualPascalTruckNodeFromCurrentScene(): AnyNode | null { + const node = useScene.getState().nodes[PASCAL_TRUCK_ITEM_NODE_ID as AnyNodeId] + if (!(isPascalTruckNode(node) && hasPascalTruckManualPlacement(node))) { + return null + } + + return cloneSceneNode(node as AnyNode) +} + +function mergePascalTruckNodeIntoSceneGraph( + sceneGraph: SceneGraph, + truckNode: AnyNode, +): SceneGraph { + const nextSceneGraph = cloneSceneGraph(sceneGraph) + const truckId = PASCAL_TRUCK_ITEM_NODE_ID + const truckParentId = + isRecord(truckNode) && typeof truckNode.parentId === 'string' ? truckNode.parentId : null + + for (const [nodeId, node] of Object.entries(nextSceneGraph.nodes)) { + if (!isRecord(node) || !Array.isArray(node.children)) { + continue + } + + const withoutTruck = node.children.filter((childId) => childId !== truckId) + const nextChildren = + nodeId === truckParentId ? Array.from(new Set([...withoutTruck, truckId])) : withoutTruck + if (nextChildren.length !== node.children.length || nodeId === truckParentId) { + nextSceneGraph.nodes[nodeId] = { + ...node, + children: nextChildren, + } + } + } + + nextSceneGraph.nodes[truckId] = truckNode + nextSceneGraph.rootNodeIds = + truckParentId === null + ? Array.from(new Set([...nextSceneGraph.rootNodeIds, truckId])) + : nextSceneGraph.rootNodeIds.filter((nodeId) => nodeId !== truckId) + + return nextSceneGraph +} + +function resetViewerAndEditorState(mode: TaskModeRestoreMode) { + const viewerState = useViewer.getState() + viewerState.setHoveredId(null) + viewerState.resetSelection() + viewerState.setPreviewSelectedIds([]) + viewerState.setHoverHighlightMode('default') + + viewerState.outliner.selectedObjects.length = 0 + viewerState.outliner.hoveredObjects.length = 0 + useLiveTransforms.getState().clearAll() + const navigationVisuals = navigationVisualsStore.getState() + const preserveToolConeOverlay = + mode === 'task-loop' || useNavigation.getState().robotMode !== null + navigationVisuals.resetRuntimeVisuals({ preserveToolConeOverlay }) + if (preserveToolConeOverlay) { + navigationVisuals.setToolConeOverlayEnabled(true) + } + if (mode === 'task-loop') { + navigationVisuals.setToolConeOverlayCamera(null) + } + + useNavigation.setState((state) => + mode === 'task-loop' + ? { + actorAvailable: false, + actorWorldPosition: null, + itemMoveControllers: {}, + itemMoveLocked: false, + navigationClickSuppressedUntil: 0, + walkableOverlayVisible: false, + } + : { + activeTaskId: null, + activeTaskIndex: 0, + 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 revealPascalTruckRuntimeObject() { + navigationVisualsStore.getState().setNodeVisibilityOverride(PASCAL_TRUCK_ITEM_NODE_ID, null) + const truckObject = sceneRegistry.nodes.get(PASCAL_TRUCK_ITEM_NODE_ID) + if (!truckObject) { + return + } + + truckObject.visible = true + truckObject.updateMatrixWorld(true) +} + +function syncTaskModeSceneGraphToRegistry( + sceneGraph?: SceneGraph | null, + options?: { preservePascalTruck?: boolean }, +) { + const snapshotNodes = hasTaskModeSceneContent(sceneGraph) ? sceneGraph.nodes : {} + + for (const [nodeId, object] of sceneRegistry.nodes) { + const node = snapshotNodes[nodeId] + if (!node) { + if (options?.preservePascalTruck === true && nodeId === PASCAL_TRUCK_ITEM_NODE_ID) { + object.visible = true + object.updateMatrixWorld(true) + continue + } + + object.visible = false + object.updateMatrixWorld(true) + continue + } + + if (!isRecord(node) || node.type !== 'item') { + continue + } + + if (Array.isArray(node.position)) { + object.position.set( + Number(node.position[0] ?? 0), + Number(node.position[1] ?? 0), + Number(node.position[2] ?? 0), + ) + } + if (Array.isArray(node.rotation)) { + object.rotation.set( + Number(node.rotation[0] ?? 0), + Number(node.rotation[1] ?? 0), + Number(node.rotation[2] ?? 0), + ) + } + object.visible = node.visible !== false + object.updateMatrixWorld(true) + } +} + +function applyTaskModeSceneGraph( + sceneGraph?: SceneGraph | null, + mode: TaskModeRestoreMode = 'full', +) { + const syncOptions = { preservePascalTruck: mode === 'task-loop' } + + if (hasTaskModeSceneContent(sceneGraph)) { + flushSync(() => { + useScene.getState().setScene( + sceneGraph.nodes as Record, + sceneGraph.rootNodeIds as AnyNodeId[], + ) + }) + syncTaskModeSceneGraphToRegistry(sceneGraph, syncOptions) + return + } + + flushSync(() => { + useScene.getState().clearScene() + }) + syncTaskModeSceneGraphToRegistry(null, syncOptions) +} + +function getPendingTaskRuntimeItemIds() { + const navigationState = useNavigation.getState() + const itemIds = new Set() + + for (const task of navigationState.taskQueue) { + itemIds.add(task.request.itemId) + } + if (navigationState.itemMoveRequest) { + itemIds.add(navigationState.itemMoveRequest.itemId) + } + if (navigationState.itemDeleteRequest) { + itemIds.add(navigationState.itemDeleteRequest.itemId) + } + if (navigationState.itemRepairRequest) { + itemIds.add(navigationState.itemRepairRequest.itemId) + } + + return itemIds +} + +function getMissingTaskRuntimeItemIds() { + const sceneNodes = useScene.getState().nodes as Record + const missing: string[] = [] + + for (const itemId of getPendingTaskRuntimeItemIds()) { + const sceneNode = sceneNodes[itemId] + if (sceneNode?.type === 'item' && !sceneRegistry.nodes.get(itemId)) { + missing.push(itemId) + } + } + + return missing +} + +function getCurrentSceneGraph(): SceneGraph { + const sceneState = useScene.getState() + return { + nodes: sceneState.nodes as Record, + rootNodeIds: [...sceneState.rootNodeIds] as string[], + } +} + +function removeRuntimePascalTruckNodesFromCurrentScene() { + const sceneState = useScene.getState() + const truckIds = Object.entries(sceneState.nodes) + .filter(([, node]) => isPascalTruckNode(node) && !hasPascalTruckManualPlacement(node)) + .map(([nodeId]) => nodeId as AnyNodeId) + + if (truckIds.length > 0) { + sceneState.deleteNodes(truckIds) + } +} + +function hidePascalTruckNodesOutsideRobotMode() { + const sceneState = useScene.getState() + const truckIdsToDelete: AnyNodeId[] = [] + const truckUpdates: { id: AnyNodeId; data: Partial }[] = [] + + for (const [nodeId, node] of Object.entries(sceneState.nodes)) { + if (!isPascalTruckNode(node)) { + continue + } + + const truckNodeId = nodeId as AnyNodeId + if (hasPascalTruckManualPlacement(node)) { + if (node.visible !== false) { + truckUpdates.push({ id: truckNodeId, data: { visible: false } }) + } + + const truckObject = sceneRegistry.nodes.get(nodeId) + if (truckObject?.visible) { + truckObject.visible = false + truckObject.updateMatrixWorld(true) + } + continue + } + + truckIdsToDelete.push(truckNodeId) + } + + if (truckUpdates.length > 0) { + sceneState.updateNodes(truckUpdates) + } + if (truckIdsToDelete.length > 0) { + sceneState.deleteNodes(truckIdsToDelete) + } +} + +function ensurePascalTruckNodeInCurrentScene() { + const sceneGraph = getCurrentSceneGraph() + const stripped = stripPascalTruckFromSceneGraph(sceneGraph) + const baseGraph = stripped.sceneGraph ?? sceneGraph + removeRuntimePascalTruckNodesFromCurrentScene() + + const { node, parentId } = buildPascalTruckNodeForScene(baseGraph, stripped.truckNode) + useScene.getState().createNode(node as AnyNode, parentId ? (parentId as AnyNodeId) : undefined) + revealPascalTruckRuntimeObject() +} + +export function NavigationSceneLifecycle() { + const { robotMode, taskLoopToken } = useNavigation( + useShallow((state) => ({ + robotMode: state.robotMode, + taskLoopToken: state.taskLoopToken, + })), + ) + const previousRobotModeRef = useRef(null) + const previousTaskLoopTokenRef = useRef(taskLoopToken) + const restorePendingRef = useRef(false) + const restoreFinalizeFrameRef = useRef(null) + const taskModeSceneSnapshotRef = useRef(null) + const restoredTaskLoopTokenRef = useRef(null) + + useEffect(() => () => setNavigationSceneRestorePending(false), []) + + useEffect(() => { + if (robotMode !== 'task') { + return + } + + const syncManualTruckPlacementToSnapshot = () => { + const manualTruckNode = getManualPascalTruckNodeFromCurrentScene() + if (!(manualTruckNode && hasTaskModeSceneContent(taskModeSceneSnapshotRef.current))) { + return + } + + taskModeSceneSnapshotRef.current = mergePascalTruckNodeIntoSceneGraph( + taskModeSceneSnapshotRef.current, + manualTruckNode, + ) + } + + syncManualTruckPlacementToSnapshot() + return useScene.subscribe(syncManualTruckPlacementToSnapshot) + }, [robotMode]) + + const captureCurrentSceneGraph = useCallback((): SceneGraph => { + const sceneState = useScene.getState() + const sceneGraph = sanitizeTaskModeSceneGraph({ + nodes: sceneState.nodes as Record, + rootNodeIds: [...sceneState.rootNodeIds] as string[], + }) + + return sceneGraph ?? { nodes: {}, rootNodeIds: [] } + }, []) + + const captureMissingTaskModeBaselineSnapshot = useCallback(() => { + if ( + robotMode !== 'task' || + hasTaskModeSceneContent(taskModeSceneSnapshotRef.current) || + !canCaptureTaskModeBaselineSnapshot() + ) { + return + } + + const currentScene = captureCurrentSceneGraph() + if (!hasTaskModeSceneContent(currentScene)) { + return + } + + taskModeSceneSnapshotRef.current = currentScene + ensurePascalTruckNodeInCurrentScene() + useNavigation.getState().setTaskLoopSettledToken(useNavigation.getState().taskLoopToken) + }, [captureCurrentSceneGraph, robotMode]) + + useLayoutEffect(() => { + if (robotMode !== 'task') { + return + } + + captureMissingTaskModeBaselineSnapshot() + return useScene.subscribe(captureMissingTaskModeBaselineSnapshot) + }, [captureMissingTaskModeBaselineSnapshot, robotMode]) + + const restoreTaskModeSceneSnapshot = useCallback( + (options?: { clearSnapshot?: boolean; mode?: TaskModeRestoreMode; settledToken?: number }) => { + if (restoreFinalizeFrameRef.current !== null) { + cancelAnimationFrame(restoreFinalizeFrameRef.current) + restoreFinalizeFrameRef.current = null + } + + const finalizeRestore = () => { + restoreFinalizeFrameRef.current = null + restorePendingRef.current = false + setNavigationSceneRestorePending(false) + if (options?.clearSnapshot) { + taskModeSceneSnapshotRef.current = null + } + if (typeof options?.settledToken === 'number') { + const settledToken = options.settledToken + flushSync(() => { + useNavigation.getState().setTaskLoopSettledToken(settledToken) + }) + } + } + + const finalizeWhenTaskRuntimeReady = () => { + if ( + typeof options?.settledToken === 'number' && + useNavigation.getState().taskLoopToken !== options.settledToken + ) { + restorePendingRef.current = false + setNavigationSceneRestorePending(false) + restoreFinalizeFrameRef.current = null + return + } + + if (getMissingTaskRuntimeItemIds().length === 0) { + finalizeRestore() + return + } + + restoreFinalizeFrameRef.current = requestAnimationFrame(finalizeWhenTaskRuntimeReady) + } + const mode = options?.mode ?? 'task-loop' + + const currentManualTruckNode = getManualPascalTruckNodeFromCurrentScene() + if (currentManualTruckNode && hasTaskModeSceneContent(taskModeSceneSnapshotRef.current)) { + taskModeSceneSnapshotRef.current = mergePascalTruckNodeIntoSceneGraph( + taskModeSceneSnapshotRef.current, + currentManualTruckNode, + ) + } + + const snapshot = taskModeSceneSnapshotRef.current + if (!hasTaskModeSceneContent(snapshot)) { + if (mode === 'task-loop' && hasActiveTaskModeRuntimeState()) { + finalizeRestore() + return false + } + + const currentScene = captureCurrentSceneGraph() + taskModeSceneSnapshotRef.current = hasTaskModeSceneContent(currentScene) + ? currentScene + : null + finalizeRestore() + return false + } + + restorePendingRef.current = true + setNavigationSceneRestorePending(true) + if ( + typeof options?.settledToken === 'number' && + useNavigation.getState().taskLoopSettledToken === options.settledToken + ) { + useNavigation.getState().setTaskLoopSettledToken(Math.max(0, options.settledToken - 1)) + } + resetViewerAndEditorState(mode) + applyTaskModeSceneGraph(cloneSceneGraph(snapshot), mode) + if (useNavigation.getState().robotMode === 'task') { + ensurePascalTruckNodeInCurrentScene() + } + + restoreFinalizeFrameRef.current = requestAnimationFrame(finalizeWhenTaskRuntimeReady) + return true + }, + [captureCurrentSceneGraph], + ) + + const restoreTaskLoopSceneSnapshot = useCallback( + (settledToken: number) => { + const restored = restoreTaskModeSceneSnapshot({ mode: 'task-loop', settledToken }) + if (restored) { + restoredTaskLoopTokenRef.current = settledToken + } + return restored + }, + [restoreTaskModeSceneSnapshot], + ) + + useLayoutEffect(() => { + const controller: TaskModeSceneLifecycleController = { + restoreTaskLoopSceneSnapshot, + } + taskModeSceneLifecycleController = controller + + return () => { + if (taskModeSceneLifecycleController === controller) { + taskModeSceneLifecycleController = null + } + } + }, [restoreTaskLoopSceneSnapshot]) + + useEffect( + () => () => { + if (restoreFinalizeFrameRef.current !== null) { + cancelAnimationFrame(restoreFinalizeFrameRef.current) + restoreFinalizeFrameRef.current = null + } + restorePendingRef.current = false + setNavigationSceneRestorePending(false) + }, + [], + ) + + useLayoutEffect(() => { + const previousRobotMode = previousRobotModeRef.current + if (previousRobotMode !== 'task' && robotMode === 'task') { + const currentScene = captureCurrentSceneGraph() + taskModeSceneSnapshotRef.current = hasTaskModeSceneContent(currentScene) ? currentScene : null + ensurePascalTruckNodeInCurrentScene() + useNavigation.getState().setTaskLoopSettledToken(useNavigation.getState().taskLoopToken) + } else if (previousRobotMode === null && robotMode !== null) { + ensurePascalTruckNodeInCurrentScene() + } else if (previousRobotMode === 'task' && robotMode !== 'task') { + restoreTaskModeSceneSnapshot({ clearSnapshot: true, mode: 'full' }) + if (robotMode === null) { + hidePascalTruckNodesOutsideRobotMode() + } else { + ensurePascalTruckNodeInCurrentScene() + } + } else if (previousRobotMode !== null && robotMode === null) { + hidePascalTruckNodesOutsideRobotMode() + } else if (robotMode === null) { + hidePascalTruckNodesOutsideRobotMode() + } + + previousRobotModeRef.current = robotMode + }, [captureCurrentSceneGraph, restoreTaskModeSceneSnapshot, robotMode]) + + useLayoutEffect(() => { + const previousTaskLoopToken = previousTaskLoopTokenRef.current + if (previousTaskLoopToken === taskLoopToken) { + return + } + + previousTaskLoopTokenRef.current = taskLoopToken + if (robotMode !== 'task') { + return + } + + if (restoredTaskLoopTokenRef.current === taskLoopToken) { + return + } + + restoreTaskLoopSceneSnapshot(taskLoopToken) + }, [restoreTaskLoopSceneSnapshot, robotMode, taskLoopToken]) + + return null +} diff --git a/packages/robot/src/components/navigation-system.tsx b/packages/robot/src/components/navigation-system.tsx new file mode 100644 index 000000000..a077ec2ac --- /dev/null +++ b/packages/robot/src/components/navigation-system.tsx @@ -0,0 +1,13712 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + emitter, + getScaledDimensions, + ItemNode, + type LevelNode, + resolveLevelId, + sceneRegistry, + spatialGridManager, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { triggerSFX } from '@pascal-app/editor/runtime' +import { useViewer } from '@pascal-app/viewer' +import { addAfterEffect, useFrame, useLoader, useThree } from '@react-three/fiber' +import { + Suspense, + startTransition, + useCallback, + useEffect, + useLayoutEffect, + 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 { + getItemMoveVisualState, + type ItemMoveVisualState, + setItemMoveVisualState as setItemMoveVisualMetadata, +} from '../lib/item-move-visuals' +import { isNavigationItemMoveCopyOperation } from '../lib/item-move-request' +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 { + buildPascalTruckNodeForScene, + getPascalTruckIntroReleaseDurationMs, + getPascalTruckLocalAsset, + isPascalTruckNode, + 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 { stripTransientMetadata } from '../lib/transient' +import useNavigation, { + type NavigationItemDeleteRequest, + type NavigationItemMoveController, + type NavigationItemMoveRequest, + type NavigationItemRepairRequest, + type NavigationQueuedTask, + type NavigationRobotMode, + navigationEmitter, + requestNavigationItemDelete, +} from '../store/use-navigation' +import { getNavigationDraftRobotCopySourceIdFromNode } from '../store/use-navigation-drafts' +import navigationVisualsStore, { useNavigationVisuals } from '../store/use-navigation-visuals' +import { + getActiveNavigationDoorIds, + getActiveNavigationDoorOpenAmounts, + NavigationDoorSystem, +} from './navigation-door-system' +import { restoreNavigationTaskLoopSceneSnapshot } from './navigation-scene-lifecycle' + +function appendTaskModeTrace(type: string, payload: Record = {}) { + if (!isNavigationDebugEnabled()) { + return + } + + const debugWindow = window as typeof window & { + __pascalNavTrace?: Array<{ at: number; payload: Record; type: string }> + } + const trace = debugWindow.__pascalNavTrace ?? [] + trace.push({ + at: performance.now(), + payload, + type, + }) + debugWindow.__pascalNavTrace = trace.slice(-250) + console.info(`[navigation] ${type}`, 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 = 2.5 +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 ITEM_DELETE_FADE_OUT_MS = 900 +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 ( + typeof window !== 'undefined' && + (window.localStorage.getItem('pascal-navigation-debug') === '1' || + window.location.search.includes('navigationDebug=1')) + ) +} +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_APPROACH_FALLBACK_MAX_SNAP_DISTANCE = 7.5 +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] +} + +let lastItemMoveApproachDebug: Record | null = null + +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' + loopToken: number + opacity: number + position: [number, number, number] + taskId: string +} + +function isNavigationCopyItemMoveRequest(request: NavigationItemMoveRequest | null) { + return isNavigationItemMoveCopyOperation(request) +} + +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, + taskLoopToken: number, +): 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, + loopToken: taskLoopToken, + 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 + queueRestartToken: number + 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 + queueRestartToken: number + request: NavigationItemDeleteRequest + sourceApproach: NavigationItemMoveApproach + stage: 'delete-transfer' | 'to-source' + taskId: string | null +} + +type NavigationItemRepairSequence = { + gesture: NavigationItemMoveGesture + queueRestartToken: number + 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] + warmupStartedAtMs: 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 isFiniteCurveVector(vector: Vector3 | null | undefined): vector is Vector3 { + return ( + Boolean(vector) && + Number.isFinite(vector!.x) && + Number.isFinite(vector!.y) && + Number.isFinite(vector!.z) + ) +} + +function sampleCurvePointAt(curve: Curve, progress: number, target: Vector3) { + const t = MathUtils.clamp(progress, 0, 1) + + try { + const point = curve.getPointAt(t, target) as Vector3 | null + const resolvedPoint = point ?? target + if (!isFiniteCurveVector(resolvedPoint)) { + return false + } + + if (resolvedPoint !== target) { + target.copy(resolvedPoint) + } + return true + } catch { + return false + } +} + +function sampleCurveTangentAt( + curve: Curve, + progress: number, + target: Vector3, + scratchBefore: Vector3, + scratchAfter: Vector3, +) { + const t = MathUtils.clamp(progress, 0, 1) + + try { + const tangent = curve.getTangentAt(t, target) as Vector3 | null + const resolvedTangent = tangent ?? target + if (isFiniteCurveVector(resolvedTangent) && resolvedTangent.lengthSq() > Number.EPSILON) { + if (resolvedTangent !== target) { + target.copy(resolvedTangent) + } + target.normalize() + return true + } + } catch { + // Fall through to a finite-difference tangent below. + } + + const sampleDelta = 0.001 + const beforeT = Math.max(0, t - sampleDelta) + const afterT = Math.min(1, t + sampleDelta) + if ( + afterT === beforeT || + !sampleCurvePointAt(curve, beforeT, scratchBefore) || + !sampleCurvePointAt(curve, afterT, scratchAfter) + ) { + return false + } + + target.subVectors(scratchAfter, scratchBefore) + if (!isFiniteCurveVector(target) || target.lengthSq() <= Number.EPSILON) { + return false + } + + target.normalize() + return true +} + +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 sameLiveTransform( + current: { position: [number, number, number]; rotation: number } | undefined, + expected: { position: [number, number, number]; rotation: number }, +) { + if (!current) { + return false + } + + return ( + Math.abs(current.position[0] - expected.position[0]) < 0.0001 && + Math.abs(current.position[1] - expected.position[1]) < 0.0001 && + Math.abs(current.position[2] - expected.position[2]) < 0.0001 && + Math.abs(current.rotation - expected.rotation) < 0.0001 + ) +} + +function sceneNodeMatchesLiveTransform( + itemId: string, + expected: { position: [number, number, number]; rotation: number }, +) { + const node = useScene.getState().nodes[itemId as AnyNodeId] + if (!node || !('position' in node) || !Array.isArray(node.position)) { + return false + } + + const rotation = 'rotation' in node && Array.isArray(node.rotation) ? node.rotation : null + return ( + Math.abs(node.position[0] - expected.position[0]) < 0.0001 && + Math.abs(node.position[2] - expected.position[2]) < 0.0001 && + Math.abs((rotation?.[1] ?? 0) - expected.rotation) < 0.0001 + ) +} + +function clearLiveTransformAfterSceneCommit( + itemId: string, + expectedTransform?: { position: [number, number, number]; rotation: number }, +) { + if (typeof window === 'undefined') { + useLiveTransforms.getState().clear(itemId) + return + } + + let frameCount = 0 + const tick = () => { + frameCount += 1 + const currentTransform = useLiveTransforms.getState().get(itemId) + if (!expectedTransform) { + if (frameCount >= 2) { + useLiveTransforms.getState().clear(itemId) + return + } + } else if ( + sameLiveTransform(currentTransform, expectedTransform) && + sceneNodeMatchesLiveTransform(itemId, expectedTransform) + ) { + useLiveTransforms.getState().clear(itemId) + return + } + + if (frameCount < 90) { + window.requestAnimationFrame(tick) + } + } + window.requestAnimationFrame(tick) +} + +function setNavigationItemLiveTransformNow( + itemId: string, + transform: { position: [number, number, number]; rotation: number }, +) { + useLiveTransforms.getState().set(itemId, transform) + + const object = sceneRegistry.nodes.get(itemId) + if (!object) { + return + } + + object.position.set(transform.position[0], transform.position[1], transform.position[2]) + object.rotation.y = transform.rotation + object.updateMatrixWorld(true) +} + +function clearDeletedItemVisualStateAfterUnmount(itemId: string) { + if (typeof window === 'undefined') { + navigationVisualsStore.getState().setNodeVisibilityOverride(itemId, false) + return + } + + navigationVisualsStore.getState().setNodeVisibilityOverride(itemId, false) + let frameCount = 0 + const tick = () => { + frameCount += 1 + const sceneNodeExists = Boolean(useScene.getState().nodes[itemId as AnyNodeId]) + const objectExists = sceneRegistry.nodes.has(itemId) + + if (!sceneNodeExists && !objectExists) { + navigationVisualsStore.getState().clearItemDelete(itemId) + navigationVisualsStore.getState().setNodeVisibilityOverride(itemId, null) + return + } + + if (objectExists) { + const object = sceneRegistry.nodes.get(itemId) + if (object) { + object.visible = false + } + } + + if (frameCount < 120) { + window.requestAnimationFrame(tick) + } + } + window.requestAnimationFrame(tick) +} + +function getNavigationItemMoveVisualItemId(request: NavigationItemMoveRequest) { + if (isNavigationCopyItemMoveRequest(request)) { + if (request.visualItemId && request.visualItemId !== request.targetPreviewItemId) { + return request.visualItemId + } + + return `${request.targetPreviewItemId ?? request.itemId}__copy_carry` + } + + return request.visualItemId ?? request.itemId +} + +function getNavigationItemMoveCommitTargetId(request: NavigationItemMoveRequest) { + if (isNavigationCopyItemMoveRequest(request)) { + return request.targetPreviewItemId ?? request.visualItemId ?? request.itemId + } + + return request.itemId +} + +function hasSeparateNavigationMoveDestinationGhost(request: NavigationItemMoveRequest) { + return Boolean( + request.targetPreviewItemId && + request.targetPreviewItemId !== getNavigationItemMoveVisualItemId(request), + ) +} + +function shouldDelayPickupCarryUntilCheckoutComplete(request: NavigationItemMoveRequest) { + return true +} + +function ensureNavigationItemMoveCarryVisualNode(request: NavigationItemMoveRequest) { + const visualItemId = getNavigationItemMoveVisualItemId(request) + if (visualItemId === request.itemId) { + return visualItemId + } + + const sceneState = useScene.getState() + const existingVisualNode = sceneState.nodes[visualItemId as AnyNodeId] + if (existingVisualNode?.type === 'item') { + return visualItemId + } + + const sourceNode = sceneState.nodes[request.itemId as AnyNodeId] + if (sourceNode?.type !== 'item') { + appendTaskModeTrace('navigation.ensureCarryVisualSkipped', { + itemId: request.itemId, + reason: 'missing-source-node', + visualItemId, + }) + return null + } + + const parentId = request.levelId ?? sourceNode.parentId + if (!parentId) { + appendTaskModeTrace('navigation.ensureCarryVisualSkipped', { + itemId: request.itemId, + reason: 'missing-parent', + visualItemId, + }) + return null + } + + const carryNode = ItemNode.parse({ + asset: sourceNode.asset, + id: visualItemId, + metadata: { + ...(stripTransientMetadata(sourceNode.metadata) as Record), + isTransient: true, + }, + name: sourceNode.name, + parentId, + position: [...request.sourcePosition] as [number, number, number], + rotation: [...request.sourceRotation] as [number, number, number], + scale: [...sourceNode.scale] as [number, number, number], + side: sourceNode.side, + visible: true, + }) + + sceneState.createNode(carryNode, parentId as AnyNodeId) + appendTaskModeTrace('navigation.ensureCarryVisualCreated', { + itemId: request.itemId, + visualItemId, + }) + return visualItemId +} + +function removeNavigationItemMoveCarryVisualNode(request: NavigationItemMoveRequest) { + const visualItemId = getNavigationItemMoveVisualItemId(request) + if (visualItemId === request.itemId || visualItemId === request.targetPreviewItemId) { + return + } + + const sceneState = useScene.getState() + const visualNode = sceneState.nodes[visualItemId as AnyNodeId] + const metadata = + visualNode?.type === 'item' && + typeof visualNode.metadata === 'object' && + visualNode.metadata !== null + ? (visualNode.metadata as Record) + : null + if (visualNode?.type === 'item' && metadata?.isTransient === true) { + sceneState.deleteNode(visualItemId as AnyNodeId) + } +} + +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 getNavigationItemMoveInteractionTargetItemId(sequence: NavigationItemMoveSequence) { + if (sequence.stage === 'pickup-transfer') { + return sequence.pickupCarryVisualStartedAt !== null + ? getNavigationItemMoveVisualItemId(sequence.request) + : sequence.request.itemId + } + + if ( + sequence.stage === 'to-target' || + sequence.stage === 'drop-transfer' || + sequence.stage === 'drop-settle' + ) { + return getNavigationItemMoveVisualItemId(sequence.request) + } + + return null +} + +function createNavigationItemMoveFallbackController( + request: NavigationItemMoveRequest, +): NavigationItemMoveController { + const visualItemId = getNavigationItemMoveVisualItemId(request) + const usesSeparateMoveCarryVisual = + !isNavigationCopyItemMoveRequest(request) && visualItemId !== request.itemId + + return { + itemId: request.itemId, + beginCarry: () => { + const ensuredVisualItemId = ensureNavigationItemMoveCarryVisualNode(request) + if (!ensuredVisualItemId) { + return + } + + if (isNavigationCopyItemMoveRequest(request) || usesSeparateMoveCarryVisual) { + const sourceRotationY = request.sourceRotation[1] ?? 0 + useLiveTransforms.getState().set(ensuredVisualItemId, { + position: [...request.sourcePosition] as [number, number, number], + rotation: sourceRotationY, + }) + appendTaskModeTrace('navigation.carryVisualSeededFromSource', { + itemId: request.itemId, + operation: isNavigationCopyItemMoveRequest(request) ? 'copy' : 'move', + sourcePosition: request.sourcePosition, + sourceRotationY, + visualItemId: ensuredVisualItemId, + }) + } + if (usesSeparateMoveCarryVisual) { + navigationVisualsStore.getState().setNodeVisibilityOverride(request.itemId, false) + } + navigationVisualsStore.getState().setItemMoveVisualState(ensuredVisualItemId, 'carried') + }, + cancel: () => { + navigationVisualsStore.getState().setItemMoveVisualState(visualItemId, null) + navigationVisualsStore.getState().setNodeVisibilityOverride(visualItemId, null) + navigationVisualsStore.getState().setNodeVisibilityOverride(request.itemId, null) + useLiveTransforms.getState().clear(visualItemId) + removeNavigationItemMoveCarryVisualNode(request) + }, + commit: (finalUpdate, finalCarryTransform) => { + const sceneState = useScene.getState() + const commitTargetId = getNavigationItemMoveCommitTargetId(request) + const commitTargetNode = sceneState.nodes[commitTargetId as AnyNodeId] + + if (commitTargetNode?.type === 'item') { + sceneState.updateNode(commitTargetId as AnyNodeId, { + ...finalUpdate, + metadata: setItemMoveVisualMetadata( + stripTransientMetadata(commitTargetNode.metadata), + null, + ) 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: setItemMoveVisualMetadata( + stripTransientMetadata(sourceNode.metadata), + null, + ) as ItemNode['metadata'], + visible: true, + }) + } + + if (finalCarryTransform) { + useLiveTransforms.getState().set(visualItemId, finalCarryTransform) + } + navigationVisualsStore.getState().setItemMoveVisualState(visualItemId, null) + navigationVisualsStore.getState().setNodeVisibilityOverride(visualItemId, null) + navigationVisualsStore.getState().setItemMoveVisualState(commitTargetId, null) + navigationVisualsStore.getState().setNodeVisibilityOverride(commitTargetId, null) + navigationVisualsStore.getState().setNodeVisibilityOverride(request.itemId, null) + clearRuntimeItemMoveVisualState(visualItemId) + clearRuntimeItemMoveVisualState(commitTargetId) + removeNavigationItemMoveCarryVisualNode(request) + if (visualItemId === commitTargetId) { + clearLiveTransformAfterSceneCommit(visualItemId, finalCarryTransform) + } else { + useLiveTransforms.getState().clear(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 + } + + const metadata = + typeof node.metadata === 'object' && node.metadata !== null + ? (node.metadata as Record) + : null + if (metadata?.isTransient !== true) { + unregisterNavigationTaskPreviewNode(itemId) + return + } + + appendTaskModeTrace('navigation.removeTransientPreviewNode', { + itemId, + }) + useScene.getState().deleteNode(itemId as AnyNode['id']) + unregisterNavigationTaskPreviewNode(itemId) +} + +function removeNavigationMoveDestinationGhost(request: NavigationItemMoveRequest) { + if (isNavigationCopyItemMoveRequest(request)) { + return + } + + const previewId = request.targetPreviewItemId + if (!previewId || !hasSeparateNavigationMoveDestinationGhost(request)) { + return + } + + const navigationVisuals = navigationVisualsStore.getState() + navigationVisuals.setItemMoveVisualState(previewId, null) + navigationVisuals.setNodeVisibilityOverride(previewId, null) + useLiveTransforms.getState().clear(previewId) + clearRuntimeItemMoveVisualState(previewId) + const object = sceneRegistry.nodes.get(previewId) + if (object) { + object.visible = false + object.updateMatrixWorld(true) + } + if (navigationVisuals.itemMovePreview?.id === previewId) { + navigationVisuals.setItemMovePreview(null) + } + + const viewerState = useViewer.getState() + if (viewerState.previewSelectedIds.includes(previewId)) { + viewerState.setPreviewSelectedIds( + viewerState.previewSelectedIds.filter((candidateId) => candidateId !== previewId), + ) + } + + const node = useScene.getState().nodes[previewId as AnyNode['id']] + const metadata = + node?.type === 'item' && typeof node.metadata === 'object' && node.metadata !== null + ? (node.metadata as Record) + : null + if ( + node?.type === 'item' && + (metadata?.isTransient === true || isNavigationTaskPreviewNodeId(previewId)) + ) { + useScene.getState().deleteNode(previewId as AnyNode['id']) + unregisterNavigationTaskPreviewNode(previewId) + } +} + +function clearNavigationCopyDestinationGhostForDrop(request: NavigationItemMoveRequest) { + if (!isNavigationCopyItemMoveRequest(request)) { + return + } + + const previewId = request.targetPreviewItemId + if (!previewId) { + return + } + + const navigationVisuals = navigationVisualsStore.getState() + navigationVisuals.setItemMoveVisualState(previewId, null) + navigationVisuals.setNodeVisibilityOverride(previewId, false) + useLiveTransforms.getState().clear(previewId) + clearRuntimeItemMoveVisualState(previewId) + + const object = sceneRegistry.nodes.get(previewId) + if (object) { + object.visible = false + object.updateMatrixWorld(true) + } + + const sceneState = useScene.getState() + const node = sceneState.nodes[previewId as AnyNodeId] + if (node?.type === 'item') { + const hasMoveVisualMetadata = getItemMoveVisualState(node.metadata) !== null + if ((node.visible ?? true) !== false || hasMoveVisualMetadata) { + sceneState.updateNode(previewId as AnyNodeId, { + metadata: hasMoveVisualMetadata + ? (setItemMoveVisualMetadata(node.metadata, null) as ItemNode['metadata']) + : node.metadata, + visible: false, + }) + } + } + + const viewerState = useViewer.getState() + if (viewerState.previewSelectedIds.includes(previewId)) { + viewerState.setPreviewSelectedIds( + viewerState.previewSelectedIds.filter((candidateId) => candidateId !== previewId), + ) + } + if (viewerState.selection.selectedIds.includes(previewId as AnyNodeId)) { + viewerState.setSelection({ + ...viewerState.selection, + selectedIds: viewerState.selection.selectedIds.filter( + (candidateId) => candidateId !== previewId, + ), + }) + } + if (viewerState.outliner.selectedObjects.length > 0) { + viewerState.outliner.selectedObjects.length = 0 + } +} + +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 = { + ...(stripTransientMetadata(sourceNode.metadata) as Record), + isTransient: true, + } 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.loopToken, 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' + | 'warmupStartedAtMs' + | '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 navigationY = + (candidateLevelId ? graph.levelBaseYById.get(candidateLevelId) : undefined) ?? y + 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, + navigationY, + 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 + let fallbackBest: { approach: NavigationItemMoveApproach; score: number } | null = null + const collectDebug = isNavigationDebugEnabled() + const debug = collectDebug + ? { + acceptedCandidates: 0, + candidateCount: candidatePoints.length, + closestSnapDistance: Number.POSITIVE_INFINITY, + componentFilteredCandidates: 0, + fallbackAcceptedCandidates: 0, + componentId, + missingCellCandidates: 0, + missingPathCandidates: 0, + snapTooFarCandidates: 0, + startCellIndex, + } + : null + if (!debug) { + lastItemMoveApproachDebug = null + } + + for (const candidate of candidatePoints) { + const cellIndex = findClosestNavigationCell( + graph, + candidate.world, + candidateLevelId ?? undefined, + componentId, + ) + if (cellIndex === null) { + if (debug) { + const unconstrainedCellIndex = findClosestNavigationCell( + graph, + candidate.world, + candidateLevelId ?? undefined, + null, + ) + const unconstrainedComponentId = + unconstrainedCellIndex !== null + ? (graph.componentIdByCell[unconstrainedCellIndex] ?? null) + : null + if ( + componentId !== null && + unconstrainedCellIndex !== null && + unconstrainedComponentId !== componentId + ) { + debug.componentFilteredCandidates += 1 + } else { + debug.missingCellCandidates += 1 + } + } + continue + } + + const cell = graph.cells[cellIndex] + if (!cell) { + if (debug) { + debug.missingCellCandidates += 1 + } + 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 (debug) { + debug.closestSnapDistance = Math.min(debug.closestSnapDistance, snapDistance) + } + if (snapDistance > ITEM_MOVE_APPROACH_MAX_SNAP_DISTANCE) { + if (debug) { + debug.snapTooFarCandidates += 1 + } + if (snapDistance > ITEM_MOVE_APPROACH_FALLBACK_MAX_SNAP_DISTANCE) { + continue + } + } + + let pathCost = pathCostByCellIndex.get(cellIndex) + if (pathCost === undefined) { + const pathResult = + startCellIndex !== null ? findNavigationPath(graph, startCellIndex, cellIndex) : null + if (!pathResult) { + if (debug) { + debug.missingPathCandidates += 1 + } + 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 + if (snapDistance > ITEM_MOVE_APPROACH_MAX_SNAP_DISTANCE) { + if (debug) { + debug.fallbackAcceptedCandidates += 1 + } + const fallbackScore = + pathCost + snapDistance * 2.5 + referenceDistance * 0.05 + candidate.penalty + 100 + if (!fallbackBest || fallbackScore < fallbackBest.score) { + fallbackBest = { + approach: { + cellIndex, + world: [...cell.center] as [number, number, number], + }, + score: fallbackScore, + } + } + continue + } + + if (debug) { + debug.acceptedCandidates += 1 + } + 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, + } + } + } + + if (debug) { + lastItemMoveApproachDebug = { + ...debug, + closestSnapDistance: Number.isFinite(debug.closestSnapDistance) + ? debug.closestSnapDistance + : null, + result: best?.approach ?? fallbackBest?.approach ?? null, + usedFallback: best === null && fallbackBest !== null, + } + } + + return best?.approach ?? fallbackBest?.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 toolConeRuntimeResetToken = useMemo( + () => `${robotMode ?? 'off'}:${taskLoopToken}`, + [robotMode, taskLoopToken], + ) + const itemMoveControllerCount = useMemo( + () => Object.keys(itemMoveControllers).length, + [itemMoveControllers], + ) + const movingItemNodeRef = useRef(null) + const movingItemNode = movingItemNodeRef.current + 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], + ) + + useEffect(() => { + if ( + !enabled || + sceneState.rootNodeIds.length === 0 || + Object.keys(sceneState.nodes).length === 0 + ) { + return + } + + if (Object.values(sceneState.nodes).some((node) => isPascalTruckNode(node))) { + for (const truckNode of Object.values(sceneState.nodes)) { + if (!isPascalTruckNode(truckNode)) { + continue + } + + const localTruckAsset = getPascalTruckLocalAsset() + if ( + truckNode.asset?.src !== localTruckAsset.src || + truckNode.asset?.thumbnail !== localTruckAsset.thumbnail + ) { + useScene.getState().updateNode(truckNode.id as AnyNodeId, { + asset: localTruckAsset, + }) + } + } + return + } + + const { node, parentId } = buildPascalTruckNodeForScene({ + nodes: sceneState.nodes, + rootNodeIds: sceneState.rootNodeIds, + }) + if (!parentId || !sceneState.nodes[parentId]) { + return + } + + useScene.getState().createNode(node as AnyNode, parentId as AnyNodeId) + }, [enabled, sceneState.nodes, sceneState.rootNodeIds]) + + const actorPointRef = useRef(new Vector3()) + const actorTangentRef = useRef(new Vector3()) + const actorTangentAheadRef = useRef(new Vector3()) + const actorTangentSampleBeforeRef = useRef(new Vector3()) + const actorTangentSampleAfterRef = 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(() => { + navigationGraphWarmWorkerRef.current = null + + return () => { + navigationGraphWarmPendingRequestsRef.current.clear() + navigationGraphWarmPendingKeyRef.current = null + navigationGraphWarmWorkerRef.current = null + } + }, []) + + 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 commitTargetId = getNavigationItemMoveCommitTargetId(request) + const commitTargetNode = snapshotNodes[commitTargetId] + const copySnapshotNode = + commitTargetNode?.type === 'item' + ? ({ + ...commitTargetNode, + metadata: setItemMoveVisualMetadata( + stripTransientMetadata(commitTargetNode.metadata), + null, + ) as ItemNode['metadata'], + parentId: targetParentId, + position: [...targetPosition] as [number, number, number], + rotation: [...targetRotation] as [number, number, number], + visible: true, + } as ItemNode as AnyNode) + : ({ + ...sourceNode, + id: commitTargetId, + metadata: setItemMoveVisualMetadata( + stripTransientMetadata(sourceNode.metadata), + null, + ) as ItemNode['metadata'], + parentId: targetParentId, + position: [...targetPosition] as [number, number, number], + rotation: [...targetRotation] as [number, number, number], + visible: true, + } as ItemNode as AnyNode) + + snapshotNodes[commitTargetId] = copySnapshotNode + updateParentChildren(targetParentId, (children) => + children.includes(commitTargetId) ? children : [...children, commitTargetId], + ) + } 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 => { + const fail = (reason: string, meta: Record = {}) => { + lastItemMovePlanDebugRef.current = { + actorComponentIdOverride, + actorNavigationPoint, + actorStartCellCenter: graph?.cells[actorStartCellIndex]?.center ?? null, + actorStartCellIndex, + graphCellCount: graph?.cells.length ?? null, + reason, + request: { + finalPosition: request.finalUpdate.position ?? null, + finalRotation: request.finalUpdate.rotation ?? request.sourceRotation ?? null, + itemId: request.itemId, + levelId: request.levelId, + sourcePosition: request.sourcePosition, + sourceRotation: request.sourceRotation, + }, + ...meta, + } + return null + } + + if (!graph) { + return fail('missing-live-graph') + } + + 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 fail('missing-target-transform') + } + + 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 fail('missing-source-approach', { + approachDebug: lastItemMoveApproachDebug, + actorStartCellCenter: graph.cells[actorStartCellIndex]?.center ?? null, + actorStartCellComponentId: graph.componentIdByCell[actorStartCellIndex] ?? null, + }) + } + + const sourcePath = findNavigationPath(graph, actorStartCellIndex, sourceApproach.cellIndex) + if (!sourcePath) { + return fail('missing-source-path', { + sourceApproach: { + cellCenter: graph.cells[sourceApproach.cellIndex]?.center ?? null, + cellIndex: sourceApproach.cellIndex, + componentId: graph.componentIdByCell[sourceApproach.cellIndex] ?? null, + world: sourceApproach.world, + }, + }) + } + + const targetPlanningSnapshot = buildItemMoveTargetSceneSnapshot(request) + const targetPlanningGraph = getCachedNavigationGraphForSnapshot( + targetPlanningSnapshot, + targetGraphPerfMetricName, + ) + if (!targetPlanningGraph) { + return fail('missing-target-planning-graph', { + targetPlanningSnapshotKey: targetPlanningSnapshot.key, + }) + } + + const releasedSourceCellIndex = findClosestNavigationCell( + targetPlanningGraph, + sourceApproach.world, + toLevelNodeId(request.levelId), + null, + ) + if (releasedSourceCellIndex === null) { + return fail('missing-released-source-cell', { + sourceApproach: { + cellCenter: graph.cells[sourceApproach.cellIndex]?.center ?? null, + cellIndex: sourceApproach.cellIndex, + world: sourceApproach.world, + }, + targetPlanningGraphCellCount: targetPlanningGraph.cells.length, + targetPlanningSnapshotKey: targetPlanningSnapshot.key, + }) + } + + const targetApproach = findItemMoveApproach( + targetPlanningGraph, + { + dimensions: request.itemDimensions, + footprintBounds: sourceFootprintBounds, + levelId: request.levelId, + position: targetPosition, + rotation: targetRotation, + }, + null, + releasedSourceCellIndex, + sourceApproach.world, + ) + if (!targetApproach) { + return fail('missing-target-approach', { + releasedSourceCellCenter: + targetPlanningGraph.cells[releasedSourceCellIndex]?.center ?? null, + releasedSourceCellIndex, + sourceApproach: { + cellCenter: graph.cells[sourceApproach.cellIndex]?.center ?? null, + cellIndex: sourceApproach.cellIndex, + world: sourceApproach.world, + }, + targetPlanningGraphCellCount: targetPlanningGraph.cells.length, + targetPlanningSnapshotKey: targetPlanningSnapshot.key, + }) + } + + const targetPath = findNavigationPath( + targetPlanningGraph, + releasedSourceCellIndex, + targetApproach.cellIndex, + ) + if (!targetPath) { + return fail('missing-target-path', { + releasedSourceCellCenter: + targetPlanningGraph.cells[releasedSourceCellIndex]?.center ?? null, + releasedSourceCellIndex, + targetApproach: { + cellCenter: targetPlanningGraph.cells[targetApproach.cellIndex]?.center ?? null, + cellIndex: targetApproach.cellIndex, + componentId: targetPlanningGraph.componentIdByCell[targetApproach.cellIndex] ?? null, + world: targetApproach.world, + }, + targetPlanningGraphCellCount: targetPlanningGraph.cells.length, + targetPlanningSnapshotKey: targetPlanningSnapshot.key, + }) + } + + 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) + } + 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 + }, [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 trajectoryRetargetSuppressRef = 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 lastNavigationClickDebugRef = 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 taskQueueSyncedRepairIdsRef = useRef>(new Set()) + const taskQueueCompletedVisualSuppressionsRef = useRef>({}) + const taskQueueVisualSyncLoopTokenRef = useRef(taskLoopToken) + 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([]) + viewerState.outliner.selectedObjects.length = 0 + } + + 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) + const copyCommitTargetId = isNavigationCopyItemMoveRequest(request) + ? getNavigationItemMoveCommitTargetId(request) + : null + if ( + request.targetPreviewItemId && + request.targetPreviewItemId !== preservedDestinationGhostId + ) { + if (request.targetPreviewItemId === copyCommitTargetId) { + unregisterNavigationTaskPreviewNode(request.targetPreviewItemId) + } else 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() + taskQueueSyncedRepairIdsRef.current = new Set() + taskQueueCompletedVisualSuppressionsRef.current = {} + 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, + 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, + navigationState.taskLoopToken, + ), + 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(() => { + if (taskQueueVisualSyncLoopTokenRef.current !== taskLoopToken) { + taskQueueVisualSyncLoopTokenRef.current = taskLoopToken + taskQueueSyncedMoveVisualStatesRef.current = {} + taskQueueSyncedDeleteIdsRef.current = new Set() + taskQueueSyncedRepairIdsRef.current = new Set() + taskQueueCompletedVisualSuppressionsRef.current = {} + } + + if (enabled && robotMode === 'task' && !taskQueuePlanningReady) { + recordTaskModeTrace( + 'navigation.taskQueueVisualSyncDeferred', + { + activeTaskId, + navigationSceneSnapshotKey: summarizeDebugSnapshotKey( + navigationSceneSnapshot?.key ?? null, + ), + pendingTaskGraphSyncKey: summarizeDebugSnapshotKey(pendingTaskGraphSyncKey), + queueLength: taskQueue.length, + taskLoopSettledToken, + taskLoopToken, + }, + { includeSnapshot: true }, + ) + return + } + + const navigationVisuals = navigationVisualsStore.getState() + const previousMoveVisualStates = taskQueueSyncedMoveVisualStatesRef.current + const previousDeleteIds = taskQueueSyncedDeleteIdsRef.current + const previousRepairIds = taskQueueSyncedRepairIdsRef.current + const nextMoveVisualStates: Partial> = {} + const nextDeleteIds = new Set() + const nextRepairIds = new Set() + const moveTaskVisualRequests = new Map() + const moveSourceIds = new Set() + + if (enabled && robotMode === 'task') { + for (const task of taskQueue) { + const completedInCurrentLoop = + taskQueueCompletedVisualSuppressionsRef.current[task.taskId] === taskLoopToken + if (completedInCurrentLoop) { + continue + } + + 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 + const activeTaskCompletedInCurrentLoop = + activeTask !== null && + taskQueueCompletedVisualSuppressionsRef.current[activeTask.taskId] === taskLoopToken + if (activeTaskCompletedInCurrentLoop) { + // Completed tasks stay queued for the next loop, but their one-shot visuals should not + // be re-applied on top of the result that was just committed in this loop. + } else 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) + } else if (activeTaskVisualKind === 'repair') { + nextRepairIds.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) + } + } + + for (const itemId of previousRepairIds) { + if (!nextRepairIds.has(itemId)) { + navigationVisuals.clearItemRepair(itemId) + } + } + + for (const itemId of nextRepairIds) { + if (!navigationVisuals.itemRepairActivations[itemId]) { + navigationVisuals.activateItemRepair(itemId) + } + } + + taskQueueSyncedMoveVisualStatesRef.current = nextMoveVisualStates + taskQueueSyncedDeleteIdsRef.current = nextDeleteIds + taskQueueSyncedRepairIdsRef.current = nextRepairIds + recordTaskModeTrace( + 'navigation.taskQueueVisualSync', + { + activeTaskId, + deleteCount: nextDeleteIds.size, + ghostIds: Object.entries(nextMoveVisualStates) + .filter(([, state]) => state === 'destination-ghost') + .map(([id]) => id), + moveVisualCount: Object.keys(nextMoveVisualStates).length, + navigationSceneSnapshotKey: summarizeDebugSnapshotKey(navigationSceneSnapshot?.key ?? null), + queueLength: taskQueue.length, + repairCount: nextRepairIds.size, + taskLoopSettledToken, + taskLoopToken, + }, + { includeSnapshot: true }, + ) + }, [ + activeTaskId, + enabled, + navigationSceneSnapshot?.key, + pendingTaskGraphSyncKey, + recordTaskModeTrace, + robotMode, + taskLoopSettledToken, + taskLoopToken, + taskQueue, + taskQueuePlanningReady, + ]) + + const taskQueueSourceMarkerSpecs = useMemo( + () => getTaskQueueSourceMarkerSpecs(taskQueue, activeTaskId, enabled, robotMode, taskLoopToken), + [activeTaskId, enabled, robotMode, taskLoopToken, 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 = getNavigationDraftRobotCopySourceIdFromNode(movingItemNode) + 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, + operation: robotCopySourceId ? 'copy' : 'move', + sourcePosition: [...requestSourceNode.position] as [number, number, number], + sourceRotation: [...requestSourceNode.rotation] as [number, number, number], + targetPreviewItemId: robotCopySourceId ? previewTargetNode.id : null, + visualItemId: robotCopySourceId + ? (`${previewTargetNode.id}__copy_carry` as ItemNode['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, + warmupStartedAtMs: performance.now(), + 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 || + Object.keys(navigationVisualState.itemRepairActivations).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 + toolInteractionPhaseRef.current = null + toolInteractionTargetItemIdRef.current = null + carriedVisualItemIdRef.current = null + setReleasedNavigationItemId(null) + clearItemMoveGestureClipState() + resetTaskQueueVisuals() + navigationVisualsStore.getState().setToolConeIsolatedOverlay(null) + 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]) + useEffect(() => { + if (!trajectoryRetargetSuppressRef.current) { + return + } + + const frameId = window.requestAnimationFrame(() => { + trajectoryRetargetSuppressRef.current = false + }) + return () => window.cancelAnimationFrame(frameId) + }, [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, + } + trajectoryRetargetSuppressRef.current = true + trajectoryRibbonMaterial.userData.uReveal.value = 0 + trajectoryRibbonMaterial.userData.uFadeLength.value = 0 + 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, + trajectoryRibbonMaterial, + ], + ) + + 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 targetLevelId = preferredLevelId ?? selection.levelId ?? null + const targetBlockers = getNavigationPointBlockers(planningGraph, targetPoint, targetLevelId) + if (targetBlockers.obstacleIds.length > 0 || targetBlockers.wallIds.length > 0) { + lastNavigationClickDebugRef.current = { + ...lastNavigationClickDebugRef.current, + targetBlockers, + targetPoint, + reason: 'target-blocked', + } + return false + } + + 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 actorWorldPosition = getResolvedActorWorldPosition() + const actorToExitDistance = + actorWorldPosition === null + ? Number.POSITIVE_INFINITY + : Math.hypot( + actorWorldPosition[0] - exitState.endPosition[0], + actorWorldPosition[1] - exitState.endPosition[1], + actorWorldPosition[2] - exitState.endPosition[2], + ) + const exitTargetLevelId = + exitState.finalCellIndex !== null + ? (toLevelNodeId(graph.cells[exitState.finalCellIndex]?.levelId) ?? + selection.levelId ?? + null) + : (selection.levelId ?? null) + + const clearRejectedNoMotionPath = () => { + pendingMotionRef.current = null + setPathIndices([]) + setPathAnchorWorldPosition(null) + setPathTargetWorldPosition(null) + setMotionState( + { + ...createActorMotionState(), + visibilityRevealProgress: 1, + }, + 'pascalTruckExit:rejectNoMotionPath', + ) + setActorMoving(false) + } + + const acceptStartedPath = (started: boolean) => { + if (!started) { + return false + } + + if (actorToExitDistance <= 0.2 || pendingMotionRef.current?.moving === true) { + return true + } + + clearRejectedNoMotionPath() + return false + } + + if ( + precomputedExitPath && + acceptStartedPath( + commitPlannedNavigationPath( + precomputedExitPath.planningGraph, + precomputedExitPath.pathResult, + precomputedExitPath.targetWorldPosition, + precomputedExitPath.destinationCellIndex, + ), + ) + ) { + return true + } + + if (acceptStartedPath(requestNavigationToPoint(exitTargetPoint, exitTargetLevelId))) { + return true + } + + if (exitState.finalCellIndex === null) { + return false + } + const finalCellIndex = exitState.finalCellIndex + + const { actorStartCellIndex } = getActorNavigationPlanningState(graph, exitTargetLevelId) + if (actorStartCellIndex === null) { + return false + } + + const pathResult = measureNavigationPerf('navigation.pascalTruckExitPathfindMs', () => + findNavigationPath(graph, actorStartCellIndex, finalCellIndex), + ) + if (!pathResult) { + return false + } + + return acceptStartedPath( + commitPlannedNavigationPath( + graph, + pathResult, + exitTargetPoint, + finalCellIndex, + ), + ) + }, + [ + commitPlannedNavigationPath, + getActorNavigationPlanningState, + getResolvedActorWorldPosition, + graph, + requestNavigationToPoint, + selection.levelId, + setMotionState, + ], + ) + + 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) { + pendingMotionRef.current = null + setPathIndices([]) + setPathAnchorWorldPosition(null) + setMotionState( + { + ...createActorMotionState(), + visibilityRevealProgress: 1, + }, + 'pascalTruckExit:fallback', + ) + setActorMoving(false) + recordTaskModeTrace('navigation.pascalTruckExitAwaitingPath', { + actorToTruckDistance, + finalCellIndex: exitState.finalCellIndex, + }) + } + }, [ + 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, + requiredTaskLoopToken: useNavigation.getState().taskLoopToken, + }) + } + + 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) + if (robotMode === 'task') { + const sceneState = useScene.getState() + const itemNode = sceneState.nodes[sequence.request.itemId as AnyNodeId] + if (itemNode?.type === 'item') { + sceneState.updateNode( + sequence.request.itemId as AnyNodeId, + { + visible: false, + } as Partial, + ) + navigationVisualsStore + .getState() + .setNodeVisibilityOverride(sequence.request.itemId, false) + const itemObject = sceneRegistry.nodes.get(sequence.request.itemId) + if (itemObject) { + itemObject.visible = false + itemObject.updateMatrixWorld(true) + } + } else { + sceneState.deleteNode(sequence.request.itemId) + clearDeletedItemVisualStateAfterUnmount(sequence.request.itemId) + } + + const updatedSceneState = useScene.getState() + const nextTaskGraphSyncKey = buildNavigationSceneSnapshot( + updatedSceneState.nodes as Record, + updatedSceneState.rootNodeIds as string[], + ).key + setPendingTaskGraphSyncKey(nextTaskGraphSyncKey) + } else { + useScene.getState().deleteNode(sequence.request.itemId) + clearDeletedItemVisualStateAfterUnmount(sequence.request.itemId) + } + triggerSFX('sfx:item-delete') + if (robotMode === 'task') { + if (sequence.taskId) { + taskQueueCompletedVisualSuppressionsRef.current[sequence.taskId] = + useNavigation.getState().taskLoopToken + advanceTaskLoopAfterCompletion(sequence.taskId) + } else { + requestItemDelete(null) + if (!hasPendingQueuedNavigationTask()) { + schedulePascalTruckExit() + } + } + } else if (!hasPendingQueuedNavigationTask()) { + schedulePascalTruckExit() + } + }, + [ + advanceTaskLoopAfterCompletion, + clearItemMoveGestureClipState, + hasPendingQueuedNavigationTask, + recordTaskModeTrace, + removeQueuedTask, + robotMode, + requestItemDelete, + resetMotion, + schedulePascalTruckExit, + setPendingTaskGraphSyncKey, + 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) + navigationVisualsStore.getState().clearItemRepair(activeSequence?.request.itemId) + 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) + navigationVisualsStore.getState().clearItemRepair(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, + ]) + + 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) + navigationVisualsStore.getState().clearItemRepair(sequence.request.itemId) + if (robotMode === 'task') { + if (sequence.taskId) { + taskQueueCompletedVisualSuppressionsRef.current[sequence.taskId] = + useNavigation.getState().taskLoopToken + 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 = (reason: string) => { + recordTaskModeTrace( + 'navigation.itemMoveSequenceAbortBeforeStart', + { + activeTaskId, + itemId: itemMoveRequest.itemId, + reason, + taskQueueLength: useNavigation.getState().taskQueue.length, + }, + { includeSnapshot: true }, + ) + 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('missing-target-transform') + return + } + + const { actorNavigationPoint, actorStartCellIndex, actorStartComponentId } = + getActorNavigationPlanningState( + graph, + selection.levelId ?? toLevelNodeId(itemMoveRequest.levelId) ?? null, + ) + if (actorStartCellIndex === null) { + abortPendingItemMove('missing-actor-start-cell') + 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('unresolved-item-move-plan') + return + } + + if (robotMode === 'task' && !resolvedItemMovePlan.exitPath) { + abortPendingItemMove('missing-truck-exit-path') + 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('commit-path-failed') + return + } + + itemMoveSequenceRef.current = { + controller: headItemMoveController, + dropGesture: getRandomItemMoveGesture(), + dropStartedAt: null, + dropStartPosition: null, + dropSettledAt: null, + exitPath, + pickupCarryVisualStartedAt: null, + pickupGesture: getRandomItemMoveGesture(), + pickupStartedAt: null, + pickupTransferStartedAt: null, + queueRestartToken, + 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, + queueRestartToken, + 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) { + recordTaskModeTrace('navigation.itemDeleteSequenceStartFailed', { + activeTaskId, + itemId: itemDeleteRequest.itemId, + reason: 'missing-actor-start-cell', + }) + 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) { + recordTaskModeTrace('navigation.itemDeleteSequenceStartFailed', { + activeTaskId, + actorStartCellIndex, + actorStartComponentId, + approachDebug: lastItemMoveApproachDebug, + itemId: itemDeleteRequest.itemId, + reason: 'missing-source-approach', + }) + cancelItemDeleteSequence() + return + } + + if (!findNavigationPath(graph, actorStartCellIndex, sourceApproach.cellIndex)) { + recordTaskModeTrace('navigation.itemDeleteSequenceStartFailed', { + activeTaskId, + actorStartCellIndex, + actorStartComponentId, + itemId: itemDeleteRequest.itemId, + reason: 'missing-source-path', + sourceCellIndex: sourceApproach.cellIndex, + }) + cancelItemDeleteSequence() + return + } + + const started = requestNavigationToPoint(sourceApproach.world) + if (!started) { + recordTaskModeTrace('navigation.itemDeleteSequenceStartFailed', { + activeTaskId, + actorStartCellIndex, + actorStartComponentId, + itemId: itemDeleteRequest.itemId, + reason: 'commit-path-failed', + sourceCellIndex: sourceApproach.cellIndex, + }) + cancelItemDeleteSequence() + return + } + + itemDeleteSequenceRef.current = { + deleteStartedAt: null, + gesture: getRandomItemMoveGesture(), + queueRestartToken, + 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, + queueRestartToken, + 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(), + queueRestartToken, + 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, + queueRestartToken, + recordTaskModeTrace, + requestItemRepair, + requestNavigationToPoint, + selection.levelId, + taskQueuePlanningReady, + ]) + + useEffect(() => { + const manualNavigationReady = + robotMode === 'normal' && actorCellIndex !== null && Boolean(graph?.cells[actorCellIndex]) + const taskNavigationReady = + robotMode === 'task' && pascalTruckIntroCompleted && pascalTruckIntroTaskReady + + if ( + !( + enabled && + graph && + (manualNavigationReady || taskNavigationReady) && + !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 || + performance.now() < navigationClickSuppressedUntil + ) { + lastNavigationClickDebugRef.current = { + actorCellIndex, + hasQueuedMoveController, + itemDeleteRequest: Boolean(useNavigation.getState().itemDeleteRequest), + itemDeleteSequence: Boolean(itemDeleteSequenceRef.current), + itemMoveLocked: useNavigation.getState().itemMoveLocked, + itemRepairRequest: Boolean(currentItemRepairRequest), + itemRepairSequence: Boolean(itemRepairSequenceRef.current), + pascalTruckExit: pendingPascalTruckExitRef.current !== null, + reason: 'blocked', + suppressed: performance.now() < navigationClickSuppressedUntil, + } + return false + } + + const committedActorIndex = actorCellIndex + if (committedActorIndex === null || !graph.cells[committedActorIndex]) { + lastNavigationClickDebugRef.current = { + actorCellIndex: committedActorIndex, + graphReady: Boolean(graph), + reason: 'missing-actor-cell', + } + 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 + const requestNavigationOnActorPlane = (reason: string) => { + const actorCell = graph.cells[committedActorIndex] + const planeY = actorCell?.center[1] ?? 0 + const ray = raycasterRef.current.ray + if (Math.abs(ray.direction.y) <= Number.EPSILON) { + lastNavigationClickDebugRef.current = { + ...lastNavigationClickDebugRef.current, + reason: `${reason}:parallel-ray`, + } + return false + } + + const distanceToPlane = (planeY - ray.origin.y) / ray.direction.y + if (distanceToPlane <= 0) { + lastNavigationClickDebugRef.current = { + ...lastNavigationClickDebugRef.current, + reason: `${reason}:behind-camera`, + } + return false + } + + const point = ray.at(distanceToPlane, new Vector3()) + const accepted = requestNavigationToPoint([point.x, planeY, point.z], preferredLevelId) + lastNavigationClickDebugRef.current = { + ...lastNavigationClickDebugRef.current, + actorPlanePoint: [point.x, planeY, point.z], + reason: accepted ? `${reason}:actor-plane-accepted` : `${reason}:actor-plane-rejected`, + } + return accepted + } + lastNavigationClickDebugRef.current = { + clientX, + clientY, + firstHitDistance: firstHit?.distance ?? null, + firstOccludingHitDistance: firstOccludingHit?.distance ?? null, + hitCount: hits.length, + intersectionCount: intersections.length, + pickableObjectCount: pickableObjects.length, + preferredLevelId, + reason: 'raycast', + } + if ( + firstOccludingHit && + (!firstHit || firstOccludingHit.distance <= firstHit.distance + Number.EPSILON) + ) { + lastNavigationClickDebugRef.current = { + ...lastNavigationClickDebugRef.current, + reason: 'occluded', + } + return requestNavigationOnActorPlane('occluded') + } + + if (hits.length === 0) { + lastNavigationClickDebugRef.current = { + ...lastNavigationClickDebugRef.current, + reason: 'no-pickable-hit', + } + return requestNavigationOnActorPlane('no-pickable-hit') + } + + // 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)) { + lastNavigationClickDebugRef.current = { + ...lastNavigationClickDebugRef.current, + acceptedDistance: hit.distance, + acceptedPoint: [hit.point.x, hit.point.y, hit.point.z], + reason: 'accepted', + } + return true + } + } + lastNavigationClickDebugRef.current = { + ...lastNavigationClickDebugRef.current, + reason: 'path-rejected', + } + return requestNavigationOnActorPlane('path-rejected') + } + + 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() + lastNavigationClickDebugRef.current = { + clientX: event.clientX, + clientY: event.clientY, + reason: 'contextmenu-received', + target: event.target instanceof Element ? event.target.tagName : null, + } + 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 === 'normal' || + (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 + } + + void navigationPostWarmupCompletedToken + void navigationPostWarmupRequestKey + void navigationPostWarmupRequestToken + void tryStartPascalTruckIntroReveal('robot-ready', { ignorePendingWarmup: true }) + }, [ + navigationPostWarmupCompletedToken, + navigationPostWarmupRequestKey, + navigationPostWarmupRequestToken, + tryStartPascalTruckIntroReveal, + ]) + + useEffect(() => { + if (!(pascalTruckIntroActive && actorRobotWarmupReady)) { + return + } + + handlePascalTruckIntroRobotReady() + }, [actorRobotWarmupReady, handlePascalTruckIntroRobotReady, pascalTruckIntroActive]) + + useLayoutEffect(() => { + 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 + } + }, []) + + const restartTaskLoopFromBaseline = useCallback( + (reason: 'loop-wrap' | 'queue-restart') => { + const nextTaskLoopToken = beginTaskLoopReset() + toolInteractionPhaseRef.current = null + toolInteractionTargetItemIdRef.current = null + carriedVisualItemIdRef.current = null + setToolCarryItemId(null) + navigationVisualsStore.getState().setToolConeIsolatedOverlay(null) + const restored = restoreNavigationTaskLoopSceneSnapshot(nextTaskLoopToken) + pendingTaskLoopResetBeforeIntroRef.current = false + pendingTaskLoopIntroAfterResetTokenRef.current = nextTaskLoopToken + recordTaskModeTrace( + reason === 'queue-restart' + ? 'navigation.taskLoopResetAfterQueueReorder' + : 'navigation.taskLoopResetBeforeNextIntro', + { + restored, + taskLoopToken: nextTaskLoopToken, + }, + { includeSnapshot: true }, + ) + setActorCellIndex(null) + resetMotion(true) + return nextTaskLoopToken + }, + [beginTaskLoopReset, recordTaskModeTrace, resetMotion, setToolCarryItemId], + ) + + 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 + } + + const activeMoveSequence = itemMoveSequenceRef.current + const activeDeleteSequence = itemDeleteSequenceRef.current + const activeRepairSequence = itemRepairSequenceRef.current + if ( + activeMoveSequence?.queueRestartToken === queueRestartToken || + activeDeleteSequence?.queueRestartToken === queueRestartToken || + activeRepairSequence?.queueRestartToken === queueRestartToken + ) { + recordTaskModeTrace( + 'navigation.queueRestartAlreadyOwnedByActiveSequence', + { + activeTaskId, + queueRestartToken, + }, + { includeSnapshot: true }, + ) + return + } + + cancelDeferredItemMoveCommit() + + const timeoutId = pascalTruckIntroTaskReadyTimeoutRef.current + if (timeoutId !== null) { + window.clearTimeout(timeoutId) + pascalTruckIntroTaskReadyTimeoutRef.current = null + } + + 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)) + activeMoveSequence.controller.cancel() + } + + itemDeleteSequenceRef.current = null + if (activeDeleteSequence) { + navigationVisualsStore.getState().clearItemDelete(activeDeleteSequence.request.itemId) + } + + itemRepairSequenceRef.current = null + if (activeRepairSequence) { + navigationVisualsStore.getState().clearItemRepair(activeRepairSequence.request.itemId) + } + + 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) + resetTaskQueueVisuals() + restartTaskLoopFromBaseline('queue-restart') + }, [ + activeTaskId, + cancelDeferredItemMoveCommit, + clearItemMoveGestureClipState, + clearNavigationItemMoveVisualResidue, + enabled, + queueRestartToken, + recordTaskModeTrace, + resetTaskQueueVisuals, + restartTaskLoopFromBaseline, + 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), + ) + const navigationStateAfterCommit = useNavigation.getState() + if (navigationStateAfterCommit.itemMoveControllers[sequence.controller.itemId]) { + navigationStateAfterCommit.registerItemMoveController(sequence.controller.itemId, null) + } + clearNavigationItemMoveVisualResidue(sequence.request) + 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, + clearNavigationItemMoveVisualResidue, + 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: + !isNavigationCopyItemMoveRequest(sequence.request) && + hasSeparateNavigationMoveDestinationGhost(sequence.request), + }) + if ( + robotMode !== 'task' && + !isNavigationCopyItemMoveRequest(sequence.request) && + getNavigationItemMoveVisualItemId(sequence.request) !== sequence.request.itemId + ) { + navigationVisualsStore.getState().setNodeVisibilityOverride(sequence.request.itemId, false) + } + if (carriedVisualItemIdRef.current) { + navigationVisualsStore + .getState() + .setItemMoveVisualState(carriedVisualItemIdRef.current, null) + carriedVisualItemIdRef.current = null + } + setToolCarryItemId(null) + resetMotion() + clearItemMoveGestureClipState() + if (robotMode !== 'task') { + requestItemMove(null) + } + if (robotMode === 'task' && sequence.taskId) { + taskQueueCompletedVisualSuppressionsRef.current[sequence.taskId] = + useNavigation.getState().taskLoopToken + } + 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' || trajectoryRetargetSuppressRef.current + 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 = Math.max( + pascalTruckIntro.warmupWaitElapsedMs, + now - pascalTruckIntro.warmupStartedAtMs, + ) + if (actorRobotWarmupReadyRef.current) { + void tryStartPascalTruckIntroReveal('robot-ready', { ignorePendingWarmup: true }) + } else if ( + 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 + ) { + restartTaskLoopFromBaseline('loop-wrap') + } 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) + if ( + sampleCurvePointAt(primaryPathCurve, debugProgress, actorPointRef.current) && + sampleCurveTangentAt( + primaryPathCurve, + Math.min(0.999, debugProgress + 0.0001), + actorTangentRef.current, + actorTangentSampleBeforeRef.current, + actorTangentSampleAfterRef.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 + ? getNavigationItemMoveInteractionTargetItemId(activeItemMoveSequence) + : activeItemDeleteSequence?.stage === 'delete-transfer' + ? activeItemDeleteSequence.request.itemId + : activeItemRepairSequence?.stage === 'repair-transfer' + ? activeItemRepairSequence.request.itemId + : null + if ( + !activeItemMoveSequence && + !activeItemDeleteSequence && + !activeItemRepairSequence && + itemMoveForcedClipPlayback + ) { + clearItemMoveGestureClipState() + } + if (NAVIGATION_FRAME_TRACE_ENABLED) { + const registeredMoveControllerId = Object.keys(itemMoveControllers)[0] ?? null + const traceSourceId = + 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 (itemMovePreviewActive && !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, + ) + setNavigationItemLiveTransformNow(visualItemId, { + position: carryAnchor.position, + rotation: sequence.request.sourceRotation[1] ?? 0, + }) + const navigationVisuals = navigationVisualsStore.getState() + if (navigationVisuals.itemMoveVisualStates[visualItemId] !== 'carried') { + navigationVisuals.setItemMoveVisualState(visualItemId, 'carried') + } + } + 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 startPickupCarryVisual = ( + sequence: NavigationItemMoveSequence, + pickupTransferProgress: number, + ) => { + const visualItemId = getNavigationItemMoveVisualItemId(sequence.request) + if (sequence.pickupCarryVisualStartedAt === null) { + sequence.pickupCarryVisualStartedAt = now + clearNavigationItemMovePickupSourcePending(sequence.request) + sequence.controller.beginCarry() + } + if (carriedVisualItemIdRef.current !== visualItemId) { + if (carriedVisualItemIdRef.current) { + navigationVisualsStore + .getState() + .setItemMoveVisualState(carriedVisualItemIdRef.current, null) + } + carriedVisualItemIdRef.current = visualItemId + } + toolInteractionTargetItemIdRef.current = visualItemId + if (sequence.pickupTransferStartedAt === null) { + sequence.pickupTransferStartedAt = now + } + const pickupTransform = getPickupTransferTransform( + [actorGroup.position.x, actorGroup.position.y, actorGroup.position.z], + actorGroup.rotation.y, + sequence.request.itemDimensions, + sequence.sourceDisplayPosition, + sequence.request.sourceRotation[1] ?? 0, + now, + pickupTransferProgress, + ) + sequence.controller.updateCarryTransform(pickupTransform.position, pickupTransform.rotationY) + setNavigationItemLiveTransformNow(visualItemId, { + position: pickupTransform.position, + rotation: pickupTransform.rotationY, + }) + const navigationVisuals = navigationVisualsStore.getState() + if (navigationVisuals.itemMoveVisualStates[visualItemId] !== 'carried') { + navigationVisuals.setItemMoveVisualState(visualItemId, 'carried') + } + } + 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)) { + startPickupCarryVisual(sequence, 0) + } + } + 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, + }) + } + + if (actorToTruckDistance > 0.2) { + recordTaskModeTrace('navigation.pascalTruckExitWaitingForPath', { + actorCellIndex, + actorToTruckDistance, + finalCellIndex: pascalTruckExit.finalCellIndex, + pathNodeCount: pathIndices.length, + }) + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + + 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 + ) { + startPickupCarryVisual(activeItemMoveSequence, 0) + } + if ( + activeItemMoveSequence.pickupTransferStartedAt === null && + pickupGestureProgress >= 0.999 + ) { + startPickupCarryVisual(activeItemMoveSequence, 0) + } + 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, + ) + setNavigationItemLiveTransformNow(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') { + clearNavigationCopyDestinationGhostForDrop(activeItemMoveSequence.request) + 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, + ) + setNavigationItemLiveTransformNow(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, + ) + setNavigationItemLiveTransformNow(visualItemId, { + position: dropTransform.position, + rotation: dropTransform.rotationY, + }) + + if (dropTransferProgress >= 0.999) { + activeItemMoveSequence.controller.updateCarryTransform( + activeItemMoveSequence.targetDisplayPosition, + activeItemMoveSequence.targetRotationY, + ) + setNavigationItemLiveTransformNow(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' }) + removeNavigationMoveDestinationGhost(activeItemMoveSequence.request) + clearNavigationCopyDestinationGhostForDrop(activeItemMoveSequence.request) + } + } + } + } + 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 + if ( + !sampleCurveTangentAt( + motionPathCurve, + Math.min(0.999, currentProgress + 0.0001), + actorTangentAheadRef.current, + actorTangentSampleBeforeRef.current, + actorTangentSampleAfterRef.current, + ) + ) { + motionRef.current.speed = 0 + motionRef.current.moving = false + if (actorMoving) { + setActorMoving(false) + } + recordNavigationPerfMark('navigation.invalidMotionPathTangent') + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + 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 + + if (!sampleCurvePointAt(motionPathCurve, candidateProgress, doorCollisionPointScratch)) { + motionRef.current.speed = 0 + motionRef.current.moving = false + if (actorMoving) { + setActorMoving(false) + } + recordNavigationPerfMark('navigation.invalidMotionPathPoint') + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + let blockingDoorIds = getBlockingDoorIdsForPoint(doorCollisionPointScratch, activeDoorBounds) + + if ( + blockingDoorIds.length > 0 && + candidatePathCurve && + conservativePathCurve && + motionPathCurve === candidatePathCurve + ) { + const conservativeCandidateProgress = + conservativePathLength > Number.EPSILON + ? Math.min(1, candidateDistance / conservativePathLength) + : candidateProgress + const fallbackBlockingDoorIds = sampleCurvePointAt( + conservativePathCurve, + conservativeCandidateProgress, + actorFallbackPointRef.current, + ) + ? getBlockingDoorIdsForPoint(actorFallbackPointRef.current, activeDoorBounds) + : blockingDoorIds + + 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) { + if ( + !sampleCurvePointAt(candidatePathCurve, progress, actorPointRef.current) || + getBlockingDoorIdsForPoint(actorPointRef.current, activeDoorBounds).length > 0 + ) { + renderCurve = conservativePathCurve + } + } + + if (renderCurve === conservativePathCurve) { + if ( + !( + conservativePathCurve && + sampleCurvePointAt(conservativePathCurve, conservativeProgress, actorPointRef.current) && + sampleCurveTangentAt( + conservativePathCurve, + Math.min(0.999, conservativeProgress + 0.0001), + actorTangentRef.current, + actorTangentSampleBeforeRef.current, + actorTangentSampleAfterRef.current, + ) + ) + ) { + motionRef.current.speed = 0 + motionRef.current.moving = false + if (actorMoving) { + setActorMoving(false) + } + recordNavigationPerfMark('navigation.invalidConservativeMotionPath') + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + } else { + if ( + !( + sampleCurvePointAt(motionPathCurve, progress, actorPointRef.current) && + sampleCurveTangentAt( + motionPathCurve, + Math.min(0.999, progress + 0.0001), + actorTangentRef.current, + actorTangentSampleBeforeRef.current, + actorTangentSampleAfterRef.current, + ) + ) + ) { + motionRef.current.speed = 0 + motionRef.current.moving = false + if (actorMoving) { + setActorMoving(false) + } + recordNavigationPerfMark('navigation.invalidRenderMotionPath') + recordNavigationPerfSample('navigation.frameMs', performance.now() - frameStart) + return + } + } + 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]))) + + useEffect(() => { + if (!followRobotEnabled) { + return + } + + const actorVisualWorldPosition = getResolvedActorVisualWorldPosition() + if (!actorVisualWorldPosition) { + return + } + + setActorWorldPosition(actorVisualWorldPosition) + navigationEmitter.emit('navigation:actor-transform', { + moving: motionRef.current.moving, + position: actorVisualWorldPosition, + rotationY: actorGroupRef.current?.rotation.y ?? 0, + }) + }, [ + actorCellIndex, + actorVisible, + followRobotEnabled, + getResolvedActorVisualWorldPosition, + pascalTruckIntroActive, + pascalTruckIntroCompleted, + pascalTruckExitActive, + setActorWorldPosition, + ]) + + useFrame(() => { + if (!followRobotEnabled) { + return + } + + const actorVisualWorldPosition = getResolvedActorVisualWorldPosition() + if (!actorVisualWorldPosition) { + return + } + + navigationEmitter.emit('navigation:actor-transform', { + moving: motionRef.current.moving, + position: actorVisualWorldPosition, + rotationY: actorGroupRef.current?.rotation.y ?? 0, + }) + }) + 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, + followRobotEnabled: navigationState.followRobotEnabled, + 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, + 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, + position: [object.position.x, object.position.y, object.position.z], + rotationY: object.rotation.y, + 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 itemMoveSequence = itemMoveSequenceRef.current + const actorRobotDebugState = actorRobotDebugStateRef.current + const previewSelectedIds = [...useViewer.getState().previewSelectedIds] + const liveSceneNodes = useScene.getState().nodes as Record + const movingNodeId = + 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] + const movingNodeMetadata = + movingNode && typeof movingNode.metadata === 'object' && movingNode.metadata !== null + ? (movingNode.metadata as Record) + : null + + return { + draftRobotCopySourceId: getNavigationDraftRobotCopySourceIdFromNode(movingNode), + 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, + movingNodeAssetAttachTo: movingNode?.asset?.attachTo ?? null, + movingNodeMetadata, + movingNodeLiveTransform: movingNodeId + ? (useLiveTransforms.getState().get(movingNodeId) ?? null) + : null, + movingNodePosition: 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 canPlanMoveItemToWorld = (itemId: string, world: [number, number, number]) => { + const placement = canMoveItemToWorld(itemId, world) + if (!placement) { + return null + } + + if (!placement.valid) { + return { + ...placement, + planResolved: false, + } + } + + const candidate = sceneState.nodes[itemId] + if (!(candidate && candidate.type === 'item') || !graph) { + return { + ...placement, + planResolved: false, + reason: graph ? 'missing-item' : 'missing-graph', + } + } + + const item = candidate as ItemNode + const { actorNavigationPoint, actorStartCellIndex, actorStartComponentId } = + getActorNavigationPlanningState( + graph, + selection.levelId ?? toLevelNodeId(item.parentId) ?? null, + ) + if (actorStartCellIndex === null) { + return { + ...placement, + actorNavigationPoint, + actorStartCellIndex, + planResolved: false, + reason: 'missing-actor-start-cell', + } + } + + const request: NavigationItemMoveRequest = { + finalUpdate: { + position: placement.finalPosition, + rotation: placement.finalRotation, + }, + itemDimensions: getScaledDimensions(item), + itemId: item.id, + levelId: item.parentId, + sourcePosition: [...item.position] as [number, number, number], + sourceRotation: [...item.rotation] as [number, number, number], + } + const previousPlanDebug = lastItemMovePlanDebugRef.current + const resolvedPlan = resolveItemMovePlan( + request, + actorStartCellIndex, + actorNavigationPoint, + actorStartComponentId, + { + recordFallbackMeta: false, + targetGraphPerfMetricName: 'navigation.debugCanPlanMoveTargetGraphBuildMs', + }, + ) + const planDebug = lastItemMovePlanDebugRef.current + lastItemMovePlanDebugRef.current = previousPlanDebug + const exitPathResolved = Boolean(resolvedPlan?.exitPath) + const taskPlanResolved = Boolean(resolvedPlan && (robotMode !== 'task' || exitPathResolved)) + + return { + ...placement, + actorStartCellIndex, + exitPathResolved, + planDebug, + planResolved: taskPlanResolved, + reason: + resolvedPlan && robotMode === 'task' && !exitPathResolved + ? 'missing-truck-exit-path' + : placement.reason, + } + } + + 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() + const cameraWorldPosition = camera.getWorldPosition(new Vector3()) + return { + cameraAspect: + camera instanceof PerspectiveCamera + ? camera.aspect + : rect.height > 0 + ? rect.width / rect.height + : null, + cameraLayers: camera.layers.mask, + cameraWorldPosition: [ + cameraWorldPosition.x, + cameraWorldPosition.y, + cameraWorldPosition.z, + ] as [number, number, number], + 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 + 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 + } + + const request: NavigationItemMoveRequest = { + finalUpdate: { + position: finalPosition, + rotation: finalRotation, + }, + itemDimensions, + itemId: item.id, + levelId: item.parentId, + operation: 'move', + 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], + }) + + navigationVisualsStore.getState().setItemMoveVisualState(item.id, 'source-pending') + const navigationState = useNavigation.getState() + navigationState.requestItemMove(request) + navigationState.setItemMoveLocked(false) + 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 plan = canPlanMoveItemToWorld(item.id, finalPosition) + if (!plan?.planResolved) { + recordNavigationPerfMark('navigation.debugQueueMoveItemRejected', { + itemId: item.id, + reason: plan?.reason ?? 'unresolved-plan', + }) + return false + } + + const request: NavigationItemMoveRequest = { + finalUpdate: { + position: finalPosition, + rotation: finalRotation, + }, + itemDimensions, + itemId: item.id, + levelId: item.parentId, + operation: 'move', + 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: stripTransientMetadata(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) + clearLiveTransformAfterSceneCommit(item.id, finalCarryTransform) + 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 queueCopyItemToWorld = (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.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 placement = spatialGridManager.canPlaceOnFloor( + levelId, + finalPosition, + itemDimensions, + finalRotation, + [item.id], + ) + if (!placement.valid) { + return false + } + + const plan = canPlanMoveItemToWorld(item.id, finalPosition) + if (!plan?.planResolved) { + recordNavigationPerfMark('navigation.debugQueueCopyItemRejected', { + itemId: item.id, + reason: plan?.reason ?? 'unresolved-plan', + }) + return false + } + + const previewId = + `item_debug_copy_preview_${item.id}_${Math.round(performance.now())}` as ItemNode['id'] + const previewNode = ItemNode.parse({ + asset: item.asset, + id: previewId, + metadata: { + ...(stripTransientMetadata(item.metadata) as Record), + isTransient: true, + robotCopySourceId: item.id, + }, + name: item.name, + parentId: item.parentId, + position: finalPosition, + rotation: finalRotation, + scale: [...item.scale] as [number, number, number], + side: item.side, + visible: true, + }) + + useScene.getState().createNode(previewNode, item.parentId as AnyNodeId) + registerNavigationTaskPreviewNode(previewId) + + const request: NavigationItemMoveRequest = { + finalUpdate: { + position: finalPosition, + rotation: finalRotation, + }, + itemDimensions, + itemId: item.id, + levelId: item.parentId, + operation: 'copy', + sourcePosition: [...item.position] as [number, number, number], + sourceRotation: [...item.rotation] as [number, number, number], + targetPreviewItemId: previewId, + visualItemId: `${previewId}__copy_carry` as ItemNode['id'], + } + + navigationState.registerItemMoveController( + item.id, + createNavigationItemMoveFallbackController(request), + ) + navigationVisualsStore.getState().setItemMoveVisualState(item.id, 'copy-source-pending') + navigationVisualsStore.getState().setItemMoveVisualState(previewId, 'destination-ghost') + recordNavigationPerfMark('navigation.debugQueueCopyItemToWorld', { + itemId: item.id, + previewId, + targetX: finalPosition[0], + targetY: finalPosition[1], + targetZ: finalPosition[2], + }) + navigationState.requestItemMove(request) + navigationState.setItemMoveLocked(false) + return { previewId, target: finalPosition, visualItemId: request.visualItemId } + } + + const emitGridMove = (world: [number, number, number]) => { + emitter.emit('grid:move', { + nativeEvent: null as never, + object: scene, + localPosition: world, + position: world, + }) + } + + const emitGridClick = (world: [number, number, number]) => { + emitter.emit('grid:click', { + nativeEvent: null as never, + object: scene, + 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 selectItemById = (itemId: string) => { + const node = sceneState.nodes[itemId] + if (node?.type !== 'item') { + return false + } + + const levelId = resolveLevelId(node, sceneState.nodes) + const selectionLevelId = toLevelNodeId(levelId) ?? useViewer.getState().selection.levelId + useViewer.getState().setHoveredId(null) + useViewer.getState().setPreviewSelectedIds([]) + useViewer.getState().setSelection({ + levelId: selectionLevelId, + selectedIds: [node.id], + zoneId: null, + }) + return true + } + + const clearEditorSelection = () => { + const viewerState = useViewer.getState() + viewerState.setHoveredId(null) + viewerState.setPreviewSelectedIds([]) + viewerState.setSelection({ + buildingId: viewerState.selection.buildingId, + levelId: viewerState.selection.levelId, + selectedIds: [], + zoneId: null, + }) + viewerState.outliner.selectedObjects.length = 0 + viewerState.outliner.hoveredObjects.length = 0 + return true + } + + const navDebugApi = { + canMoveItemToWorld, + canPlanMoveItemToWorld, + clearEditorSelection, + getConnectivitySnapshot, + getCurrentMovePlanDiagnostics, + getDoorTangentDiagnostics, + getNodeRenderTree, + getPathDiagnostics, + getRenderBreakdown, + getState, + getTaskModeSnapshot, + getTrajectorySamples, + getItemMoveState, + getItemRuntimePose: (itemId: string) => { + const sceneNode = useScene.getState().nodes[itemId as AnyNodeId] + const object = sceneRegistry.nodes.get(itemId) + const navigationVisualState = navigationVisualsStore.getState() + const scenePosition = + sceneNode && 'position' in sceneNode && Array.isArray(sceneNode.position) + ? sceneNode.position + : null + const sceneRotation = + sceneNode && 'rotation' in sceneNode && Array.isArray(sceneNode.rotation) + ? sceneNode.rotation + : null + + return { + deleteActivation: + navigationVisualState.itemDeleteActivations[itemId as AnyNodeId] ?? null, + liveTransform: useLiveTransforms.getState().get(itemId) ?? null, + moveVisualState: navigationVisualState.itemMoveVisualStates[itemId as AnyNodeId] ?? null, + renderPosition: object + ? ([object.position.x, object.position.y, object.position.z] as [ + number, + number, + number, + ]) + : null, + renderRotationY: object?.rotation.y ?? null, + renderVisible: object?.visible ?? null, + sceneExists: Boolean(sceneNode), + scenePosition, + sceneRotationY: sceneRotation?.[1] ?? null, + sceneVisible: + sceneNode && 'visible' in sceneNode ? ((sceneNode as ItemNode).visible ?? null) : null, + visibilityOverride: + navigationVisualState.nodeVisibilityOverrides[itemId as AnyNodeId] ?? null, + } + }, + getMovableItems, + getRenderDiagnostics, + getToolConeDiagnostics, + getToolConeIsolatedOverlay: () => navigationVisualsStore.getState().toolConeIsolatedOverlay, + getViewportDiagnostics, + getActorWorldPosition, + getActorComponentNavigationCells: () => { + if (!graph || actorCellIndex === null) { + return [] + } + + const componentId = graph.componentIdByCell[actorCellIndex] + if (componentId === undefined || componentId < 0) { + return [] + } + + const componentCells = graph.components[componentId] ?? [] + const stride = Math.max(1, Math.floor(componentCells.length / 80)) + return componentCells + .filter((_, index) => index % stride === 0) + .slice(0, 80) + .map((cellIndex) => { + const cell = graph.cells[cellIndex] + return cell + ? { + cellIndex, + center: cell.center, + gridX: cell.gridX, + gridY: cell.gridY, + levelId: cell.levelId, + surfaceType: cell.surfaceType, + } + : null + }) + .filter(Boolean) + }, + getRuntimeSummary: () => { + const navigationState = useNavigation.getState() + const navigationVisualState = navigationVisualsStore.getState() + const viewerState = useViewer.getState() + const itemMoveSequence = itemMoveSequenceRef.current + return { + activeTaskId: navigationState.activeTaskId, + activeTaskIndex: navigationState.activeTaskIndex, + activeNavigationDoorIds: [...getActiveNavigationDoorIds()], + activeNavigationDoorOpenAmounts: Object.fromEntries(getActiveNavigationDoorOpenAmounts()), + actorCellIndex, + actorMoving, + actorRenderVisible, + actorRobotWarmupReady, + actorSpawnPosition, + actorVisible, + enabled, + graphCellCount: graph?.cells.length ?? 0, + hasGraph: Boolean(graph), + itemMoveControllerIds: Object.keys(navigationState.itemMoveControllers), + itemMovePreview: navigationVisualState.itemMovePreview, + itemDeleteActivations: navigationVisualState.itemDeleteActivations, + itemDeleteRequestId: navigationState.itemDeleteRequest?.itemId ?? null, + itemMoveRequestId: navigationState.itemMoveRequest?.itemId ?? null, + itemMoveSequenceStage: itemMoveSequence?.stage ?? null, + itemMoveVisualStates: navigationVisualState.itemMoveVisualStates, + itemRepairActivations: navigationVisualState.itemRepairActivations, + itemRepairRequestId: navigationState.itemRepairRequest?.itemId ?? null, + lastNavigationClick: lastNavigationClickDebugRef.current, + motionWriteSource: motionWriteSourceRef.current, + pathDistanceTravelled: trajectoryDebugDistanceRef.current ?? motionRef.current.distance, + pathIndexCount: pathIndices.length, + pathLength, + pascalTruckIntroActive, + pascalTruckIntroAttemptCount: debugPascalTruckIntroAttemptCountRef.current, + pascalTruckIntroCompleted, + pascalTruckIntroPlanReady: pascalTruckIntroPlan !== null, + pascalTruckIntroStartCount: debugPascalTruckIntroStartCountRef.current, + pascalTruckIntroTaskReady, + pascalTruckExitActive, + pendingTaskRequestActive: + navigationState.itemMoveRequest !== null || + navigationState.itemDeleteRequest !== null || + navigationState.itemRepairRequest !== null, + pendingPascalTruckExitActive: pendingPascalTruckExitRef.current !== null, + previewSelectedIds: viewerState.previewSelectedIds, + queueRestartToken: navigationState.queueRestartToken, + robotMode: navigationState.robotMode, + selectedOutlineObjectCount: viewerState.outliner.selectedObjects.length, + selectedOutlineObjectIds: viewerState.outliner.selectedObjects.map( + (object) => object.userData?.nodeId ?? object.name ?? object.uuid, + ), + selectedIds: viewerState.selection.selectedIds, + taskQueueLength: navigationState.taskQueue.length, + taskQueueSourceMarkers: getTaskQueueSourceMarkerSpecs( + navigationState.taskQueue, + navigationState.activeTaskId, + navigationState.enabled, + navigationState.robotMode, + navigationState.taskLoopToken, + ), + taskLoopSettledToken: navigationState.taskLoopSettledToken, + taskLoopToken: navigationState.taskLoopToken, + taskPreviewNodeIds: navigationVisualState.taskPreviewNodeIds, + trajectoryFadeLength: trajectoryRibbonMaterial.userData.uFadeLength.value, + trajectoryRetargetSuppress: trajectoryRetargetSuppressRef.current, + trajectoryReveal: trajectoryRibbonMaterial.userData.uReveal.value, + trajectoryVisibleStart: trajectoryRibbonMaterial.userData.uVisibleStart.value, + } + }, + 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) + }, + setRobotMode, + requestDeleteItemById, + queueCopyItemToWorld, + requestMoveItemToWorld, + queueMoveItemToWorld, + moveQueuedTaskToIndex: (taskId: string, targetIndex: number) => { + useNavigation.getState().moveQueuedTask(taskId, targetIndex) + return useNavigation.getState().taskQueue.map((task) => ({ + itemId: task.request.itemId, + kind: task.kind, + taskId: task.taskId, + })) + }, + selectItemById, + setToolConeIsolatedOverlay: ( + overlay: ReturnType['toolConeIsolatedOverlay'], + ) => { + navigationVisualsStore.getState().setToolConeIsolatedOverlay(overlay) + }, + startMoveItem, + setLookAt, + } + + const debugWindow = window as typeof window & { + __pascalNavDebug?: typeof navDebugApi + } + debugWindow.__pascalNavDebug = navDebugApi + const publishDebugSummary = () => { + document.documentElement.dataset.pascalNavRuntimeSummary = JSON.stringify( + navDebugApi.getRuntimeSummary(), + ) + } + publishDebugSummary() + const debugSummaryInterval = window.setInterval(publishDebugSummary, 250) + return () => { + window.clearInterval(debugSummaryInterval) + delete document.documentElement.dataset.pascalNavRuntimeSummary + if (debugWindow.__pascalNavDebug === navDebugApi) { + delete debugWindow.__pascalNavDebug + } + } + }, [ + 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, + resolveItemMovePlan, + ]) + + 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/robot/src/components/navigation-toolbar-button.tsx b/packages/robot/src/components/navigation-toolbar-button.tsx new file mode 100644 index 000000000..2b6601350 --- /dev/null +++ b/packages/robot/src/components/navigation-toolbar-button.tsx @@ -0,0 +1,115 @@ +'use client' + +import { emitter } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Bot, Check, Shield } from 'lucide-react' +import { useCallback } from 'react' +import { cn } from '../lib/utils' +import useNavigation, { + type NavigationRobotModel, + type NavigationRobotMode, +} from '../store/use-navigation' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from './ui/primitives/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/primitives/tooltip' + +const TOOLBAR_BTN = + 'flex items-center justify-center w-8 text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90' + +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', +} + +export function NavigationToolbarButton() { + const robotModel = useNavigation((state) => state.robotModel) + const robotMode = useNavigation((state) => state.robotMode) + const setRobotModel = useNavigation((state) => state.setRobotModel) + const setFollowRobotEnabled = useNavigation((state) => state.setFollowRobotEnabled) + 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 + + setRobotMode(mode) + setFollowRobotEnabled(false) + }, + [setFollowRobotEnabled, 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' ? ( + + ) : ( + + )} + + + + + ) +} diff --git a/packages/robot/src/components/tool-cone-overlay-viewer.tsx b/packages/robot/src/components/tool-cone-overlay-viewer.tsx new file mode 100644 index 000000000..640516bcc --- /dev/null +++ b/packages/robot/src/components/tool-cone-overlay-viewer.tsx @@ -0,0 +1,914 @@ +'use client' + +import { Viewer } from '@pascal-app/viewer' +import { Canvas, useFrame, useThree } from '@react-three/fiber' +import { type ComponentProps, useEffect, useMemo, useRef } from 'react' +import { + ACESFilmicToneMapping, + AdditiveBlending, + BufferGeometry, + Color, + DoubleSide, + Float32BufferAttribute, + LineBasicMaterial, + LineSegments, + Mesh, + PerspectiveCamera, + Quaternion, + Scene, + ShaderMaterial, + SRGBColorSpace, + Vector2, + Vector3, + WebGLRenderer, + WebGLRenderTarget, +} from 'three' +import { recordNavigationPerfSample } from '../lib/navigation-performance' +import navigationVisualsStore, { useNavigationVisuals } from '../store/use-navigation-visuals' + +const VIEWER_FIXED_DPR = 0.85 +const TREE_OVERLAY_COLOR = '#52e8ff' +const CONE_EDGE_GLOW_COLOR = TREE_OVERLAY_COLOR +const CONE_EDGE_GLOW_ATTENUATION = 0.26 +const CONE_EDGE_GLOW_BRIGHTNESS = 1.24 +const CONE_EDGE_GLOW_INWARD_DIFFUSION_DEPTH = 0.19504 +const CONE_EDGE_GLOW_INWARD_GRADIENT_BEND = 0.1 +const CONE_EDGE_GLOW_OUTWARD_DIFFUSION_DEPTH = 0.02184 +const CONE_EDGE_GLOW_OUTWARD_GRADIENT_BEND = 0.09 +const CONE_GRADIENT_BEND = 0.58 +const CONE_EXTRA_TRANSPARENCY_PERCENT = 61 +const CONE_MAX_PROJECTED_HULL_VERTEX_COUNT = 9 +const EXPONENTIAL_BEND_STRENGTH_MULTIPLIER = 6 +const OVERLAY_CAMERA_SCALE = new Vector3(1, 1, 1) + +type ProjectedHullCandidate = { + isApex: boolean + projectedPoint: Vector2 + worldPoint: Vector3 +} + +function arraysEqual(a: number[], b: number[]) { + if (a.length !== b.length) { + return false + } + for (let index = 0; index < a.length; index += 1) { + if (a[index] !== b[index]) { + return false + } + } + return true +} + +function createProjectedHullGeometry() { + const geometry = new BufferGeometry() + const positionAttribute = new Float32BufferAttribute( + new Array(CONE_MAX_PROJECTED_HULL_VERTEX_COUNT * 3).fill(0), + 3, + ) + const uvAttribute = new Float32BufferAttribute( + new Array(CONE_MAX_PROJECTED_HULL_VERTEX_COUNT * 2).fill(0), + 2, + ) + const indices: number[] = [] + for (let index = 1; index < CONE_MAX_PROJECTED_HULL_VERTEX_COUNT - 1; index += 1) { + indices.push(0, index, index + 1) + } + geometry.setAttribute('position', positionAttribute) + geometry.setAttribute('uv', uvAttribute) + geometry.setIndex(indices) + geometry.setDrawRange(0, 0) + geometry.computeVertexNormals() + return { geometry, positionAttribute, uvAttribute } +} + +function createProjectedHullEdgeGlowGeometry() { + const geometry = new BufferGeometry() + const maxEdgeCount = CONE_MAX_PROJECTED_HULL_VERTEX_COUNT + const positionAttribute = new Float32BufferAttribute(new Array(maxEdgeCount * 6 * 3).fill(0), 3) + const uvValues: number[] = [] + for (let edgeIndex = 0; edgeIndex < maxEdgeCount; edgeIndex += 1) { + uvValues.push(0, 0, 0, 1, 1, 1) + uvValues.push(0, 0, 1, 1, 1, 0) + } + geometry.setAttribute('position', positionAttribute) + geometry.setAttribute('uv', new Float32BufferAttribute(uvValues, 2)) + geometry.setDrawRange(0, 0) + geometry.computeVertexNormals() + return { geometry, positionAttribute } +} + +function createConeFillMaterial(opacityScale: number) { + const material = new ShaderMaterial({ + depthTest: false, + depthWrite: false, + fragmentShader: ` + varying vec2 vUv; + uniform vec3 uColor; + uniform float uOpacityScale; + + void main() { + float bendNode = max(${CONE_GRADIENT_BEND.toFixed(8)}, 0.0); + float bendMix = smoothstep(0.0, 0.03, bendNode); + float strength = bendNode * ${EXPONENTIAL_BEND_STRENGTH_MULTIPLIER.toFixed(8)}; + float progress = clamp(vUv.x, 0.0, 1.0); + float linearFade = 1.0 - progress; + float expStrength = exp(-strength); + float expFade = (exp(-strength * progress) - expStrength) / (1.0 - expStrength + 1e-5); + float fade = mix(linearFade, expFade, bendMix); + float alpha = fade * uOpacityScale; + if (alpha <= 0.001) discard; + gl_FragColor = vec4(uColor, alpha); + } + `, + side: DoubleSide, + transparent: true, + uniforms: { + uColor: { value: new Vector3(0x52 / 255, 0xe8 / 255, 0xff / 255) }, + uOpacityScale: { value: opacityScale }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + }) + material.toneMapped = false + return material +} + +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 previous = sorted[index - 1] + if (!previous) { + return true + } + return ( + Math.abs(candidate.projectedPoint.x - previous.projectedPoint.x) > 1e-6 || + Math.abs(candidate.projectedPoint.y - previous.projectedPoint.y) > 1e-6 + ) + }) + + if (uniqueCandidates.length < 3) { + return uniqueCandidates + } + + const lowerHull: ProjectedHullCandidate[] = [] + for (const candidate of uniqueCandidates) { + while ( + lowerHull.length >= 2 && + cross2D( + lowerHull[lowerHull.length - 2]!.projectedPoint, + lowerHull[lowerHull.length - 1]!.projectedPoint, + candidate.projectedPoint, + ) <= 0 + ) { + 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 && + cross2D( + upperHull[upperHull.length - 2]!.projectedPoint, + upperHull[upperHull.length - 1]!.projectedPoint, + candidate.projectedPoint, + ) <= 0 + ) { + 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 createConeGlowMaterial(power: number) { + const material = new ShaderMaterial({ + blending: AdditiveBlending, + depthTest: false, + depthWrite: false, + fragmentShader: ` + varying vec2 vUv; + uniform vec3 uColor; + + void main() { + float edgeFade = 1.0 - pow(smoothstep(0.0, 1.0, clamp(vUv.x, 0.0, 1.0)), ${power.toFixed(8)}); + float lengthFade = pow(1.0 - clamp(vUv.y, 0.0, 1.0), ${CONE_EDGE_GLOW_ATTENUATION.toFixed(8)}); + float alpha = edgeFade * lengthFade * ${CONE_EDGE_GLOW_BRIGHTNESS.toFixed(8)}; + if (alpha <= 0.001) discard; + gl_FragColor = vec4(uColor, alpha); + } + `, + side: DoubleSide, + transparent: true, + uniforms: { + uColor: { value: new Vector3(0x52 / 255, 0xe8 / 255, 0xff / 255) }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + }) + material.toneMapped = false + return material +} + +function applyConeOverlayColor( + color: string | null | undefined, + coneMaterial: ShaderMaterial, + coneInwardEdgeGlowMaterial: ShaderMaterial, + coneOutwardEdgeGlowMaterial: ShaderMaterial, + outlineMaterial: LineBasicMaterial, +) { + const nextColor = new Color(color ?? TREE_OVERLAY_COLOR) + const nextColorVector = new Vector3(nextColor.r, nextColor.g, nextColor.b) + const coneUniform = coneMaterial.uniforms.uColor + const inwardGlowUniform = coneInwardEdgeGlowMaterial.uniforms.uColor + const outwardGlowUniform = coneOutwardEdgeGlowMaterial.uniforms.uColor + + if ( + coneUniform && + inwardGlowUniform && + outwardGlowUniform && + coneUniform.value instanceof Vector3 && + inwardGlowUniform.value instanceof Vector3 && + outwardGlowUniform.value instanceof Vector3 + ) { + coneUniform.value.copy(nextColorVector) + inwardGlowUniform.value.copy(nextColorVector) + outwardGlowUniform.value.copy(nextColorVector) + } + outlineMaterial.color.copy(nextColor) +} + +function ToolConeOverlayCameraBridge() { + const camera = useThree((state) => state.camera) + const previousSnapshotRef = useRef<{ + position: [number, number, number] + projectionMatrix: number[] + projectionMatrixInverse: number[] + quaternion: [number, number, number, number] + } | null>(null) + + useFrame(() => { + const nextSnapshot = { + position: [camera.position.x, camera.position.y, camera.position.z] as [ + number, + number, + number, + ], + projectionMatrix: camera.projectionMatrix.elements.slice(), + projectionMatrixInverse: camera.projectionMatrixInverse.elements.slice(), + quaternion: [ + camera.quaternion.x, + camera.quaternion.y, + camera.quaternion.z, + camera.quaternion.w, + ] as [number, number, number, number], + } + const previousSnapshot = previousSnapshotRef.current + const overlayCamera = navigationVisualsStore.getState().toolConeOverlayCamera + if ( + overlayCamera && + previousSnapshot && + arraysEqual(previousSnapshot.position, nextSnapshot.position) && + arraysEqual(previousSnapshot.quaternion, nextSnapshot.quaternion) && + arraysEqual(previousSnapshot.projectionMatrix, nextSnapshot.projectionMatrix) && + arraysEqual(previousSnapshot.projectionMatrixInverse, nextSnapshot.projectionMatrixInverse) + ) { + return + } + previousSnapshotRef.current = nextSnapshot + navigationVisualsStore.getState().setToolConeOverlayCamera(nextSnapshot) + }) + + useEffect(() => { + return () => { + navigationVisualsStore.getState().setToolConeOverlayCamera(null) + } + }, []) + + return null +} + +function ToolConeIsolatedOverlayScene() { + const camera = useThree((state) => state.camera) + const gl = useThree((state) => state.gl) + const coneOpacityScale = Math.max(0, 1 - CONE_EXTRA_TRANSPARENCY_PERCENT / 100) + const coneMaterial = useMemo(() => createConeFillMaterial(coneOpacityScale), [coneOpacityScale]) + const coneInwardEdgeGlowMaterial = useMemo( + () => createConeGlowMaterial(CONE_EDGE_GLOW_INWARD_GRADIENT_BEND), + [], + ) + const coneOutwardEdgeGlowMaterial = useMemo( + () => createConeGlowMaterial(CONE_EDGE_GLOW_OUTWARD_GRADIENT_BEND), + [], + ) + const outlineMaterial = useMemo(() => { + const material = new LineBasicMaterial({ + color: TREE_OVERLAY_COLOR, + depthTest: true, + opacity: 0.96 * coneOpacityScale, + transparent: true, + }) + material.toneMapped = false + return material + }, [coneOpacityScale]) + + const coneMeshRef = useRef(null) + const coneInwardEdgeGlowMeshRef = useRef(null) + const coneOutwardEdgeGlowMeshRef = useRef(null) + const coneOutlineRef = useRef(null) + const projectedHullCandidatesRef = useRef([]) + const projectedHullCentroidRef = useRef(new Vector3()) + const inwardGlowStartScratchRef = useRef(new Vector3()) + const inwardGlowEndScratchRef = useRef(new Vector3()) + const outwardGlowStartScratchRef = useRef(new Vector3()) + const outwardGlowEndScratchRef = useRef(new Vector3()) + const worldPointScratchRef = useRef(new Vector3()) + const projectedPointScratchRef = useRef(new Vector3()) + const overlayCameraPositionRef = useRef(new Vector3()) + const overlayCameraQuaternionRef = useRef(new Quaternion()) + const { + inwardGlowGeometry, + inwardGlowPositionAttribute, + mainGeometry, + mainPositionAttribute, + mainUvAttribute, + outlineGeometry, + outlinePositionAttribute, + outwardGlowGeometry, + outwardGlowPositionAttribute, + } = useMemo(() => { + const { + geometry: mainGeometry, + positionAttribute: mainPositionAttribute, + uvAttribute: mainUvAttribute, + } = createProjectedHullGeometry() + const outlineGeometry = new BufferGeometry() + const outlinePositionAttribute = new Float32BufferAttribute( + new Array(CONE_MAX_PROJECTED_HULL_VERTEX_COUNT * 2 * 3).fill(0), + 3, + ) + outlineGeometry.setAttribute('position', outlinePositionAttribute) + const { geometry: inwardGlowGeometry, positionAttribute: inwardGlowPositionAttribute } = + createProjectedHullEdgeGlowGeometry() + const { geometry: outwardGlowGeometry, positionAttribute: outwardGlowPositionAttribute } = + createProjectedHullEdgeGlowGeometry() + return { + inwardGlowGeometry, + inwardGlowPositionAttribute, + mainGeometry, + mainPositionAttribute, + mainUvAttribute, + outlineGeometry, + outlinePositionAttribute, + outwardGlowGeometry, + outwardGlowPositionAttribute, + } + }, []) + + useEffect(() => { + navigationVisualsStore.getState().setToolConeOverlayWarmupReady(false) + const { + geometry: warmMainGeometry, + positionAttribute: warmMainPositionAttribute, + uvAttribute: warmMainUvAttribute, + } = createProjectedHullGeometry() + const warmOutlineGeometry = new BufferGeometry() + const warmOutlinePositionAttribute = new Float32BufferAttribute(new Array(6 * 3).fill(0), 3) + warmOutlineGeometry.setAttribute('position', warmOutlinePositionAttribute) + const { geometry: warmInwardGlowGeometry, positionAttribute: warmInwardGlowPositionAttribute } = + createProjectedHullEdgeGlowGeometry() + const { + geometry: warmOutwardGlowGeometry, + positionAttribute: warmOutwardGlowPositionAttribute, + } = createProjectedHullEdgeGlowGeometry() + + const apex = new Vector3(0, -0.08, 0) + const pointA = new Vector3(-0.18, 0.18, 0) + const pointB = new Vector3(0.18, 0.18, 0) + ;[ + [apex, 0, 0], + [pointA, 1, 0.5], + [pointB, 1, 1], + ].forEach(([point, u, v], index) => { + const vector = point as Vector3 + warmMainPositionAttribute.setXYZ(index, vector.x, vector.y, vector.z) + warmMainUvAttribute.setXY(index, u as number, v as number) + }) + warmMainPositionAttribute.needsUpdate = true + warmMainUvAttribute.needsUpdate = true + warmMainGeometry.setDrawRange(0, 3) + warmMainGeometry.computeVertexNormals() + + const warmOutlinePoints = [apex, pointA, pointA, pointB, pointB, apex] + warmOutlinePoints.forEach((point, index) => { + warmOutlinePositionAttribute.setXYZ(index, point.x, point.y, point.z) + }) + warmOutlinePositionAttribute.needsUpdate = true + warmOutlineGeometry.setDrawRange(0, warmOutlinePoints.length) + + const warmGlowTriangles = [ + apex, + pointA, + pointB, + apex, + pointB, + apex.clone().lerp(pointA.clone().add(pointB).multiplyScalar(0.5), 0.25), + ] + warmGlowTriangles.forEach((point, index) => { + warmInwardGlowPositionAttribute.setXYZ(index, point.x, point.y, point.z) + warmOutwardGlowPositionAttribute.setXYZ(index, point.x, point.y, point.z) + }) + warmInwardGlowPositionAttribute.needsUpdate = true + warmOutwardGlowPositionAttribute.needsUpdate = true + warmInwardGlowGeometry.setDrawRange(0, warmGlowTriangles.length) + warmOutwardGlowGeometry.setDrawRange(0, warmGlowTriangles.length) + warmInwardGlowGeometry.computeVertexNormals() + warmOutwardGlowGeometry.computeVertexNormals() + + const warmupScene = new Scene() + const warmupCamera = new PerspectiveCamera(50, 1, 0.01, 10) + warmupCamera.position.set(0, 0, 1) + warmupCamera.lookAt(0, 0, 0) + warmupCamera.updateProjectionMatrix() + warmupCamera.updateMatrixWorld(true) + + const warmupMainMesh = new Mesh(warmMainGeometry, coneMaterial) + const warmupInwardGlowMesh = new Mesh(warmInwardGlowGeometry, coneInwardEdgeGlowMaterial) + const warmupOutwardGlowMesh = new Mesh(warmOutwardGlowGeometry, coneOutwardEdgeGlowMaterial) + const warmupOutline = new LineSegments(warmOutlineGeometry, outlineMaterial) + warmupScene.add(warmupMainMesh, warmupInwardGlowMesh, warmupOutwardGlowMesh, warmupOutline) + + const renderTarget = new WebGLRenderTarget(64, 64, { depthBuffer: true }) + const renderer = gl as WebGLRenderer + const warmupStart = performance.now() + + try { + renderer.compile(warmupScene, warmupCamera) + renderer.setRenderTarget(renderTarget) + renderer.render(warmupScene, warmupCamera) + } catch { + } finally { + renderer.setRenderTarget(null) + renderTarget.dispose() + recordNavigationPerfSample( + 'navigationToolConeOverlay.renderWarmupMs', + performance.now() - warmupStart, + ) + navigationVisualsStore.getState().setToolConeOverlayWarmupReady(true) + warmupScene.clear() + warmMainGeometry.dispose() + warmOutlineGeometry.dispose() + warmInwardGlowGeometry.dispose() + warmOutwardGlowGeometry.dispose() + } + }, [coneInwardEdgeGlowMaterial, coneMaterial, coneOutwardEdgeGlowMaterial, gl, outlineMaterial]) + + useEffect(() => { + return () => { + navigationVisualsStore.getState().setToolConeOverlayWarmupReady(false) + mainGeometry.dispose() + outlineGeometry.dispose() + inwardGlowGeometry.dispose() + outwardGlowGeometry.dispose() + coneMaterial.dispose() + coneInwardEdgeGlowMaterial.dispose() + coneOutwardEdgeGlowMaterial.dispose() + outlineMaterial.dispose() + } + }, [ + coneInwardEdgeGlowMaterial, + coneMaterial, + coneOutwardEdgeGlowMaterial, + inwardGlowGeometry, + mainGeometry, + outlineGeometry, + outlineMaterial, + outwardGlowGeometry, + ]) + + useEffect(() => { + const overlayColor = navigationVisualsStore.getState().toolConeIsolatedOverlay?.color ?? null + applyConeOverlayColor( + overlayColor, + coneMaterial, + coneInwardEdgeGlowMaterial, + coneOutwardEdgeGlowMaterial, + outlineMaterial, + ) + }, [coneInwardEdgeGlowMaterial, coneMaterial, coneOutwardEdgeGlowMaterial, outlineMaterial]) + + useFrame(() => { + const overlayCamera = navigationVisualsStore.getState().toolConeOverlayCamera + if (overlayCamera) { + overlayCameraPositionRef.current.fromArray(overlayCamera.position) + overlayCameraQuaternionRef.current.set( + overlayCamera.quaternion[0], + overlayCamera.quaternion[1], + overlayCamera.quaternion[2], + overlayCamera.quaternion[3], + ) + camera.position.copy(overlayCameraPositionRef.current) + camera.quaternion.copy(overlayCameraQuaternionRef.current) + camera.matrixWorld.compose( + overlayCameraPositionRef.current, + overlayCameraQuaternionRef.current, + OVERLAY_CAMERA_SCALE, + ) + camera.matrixWorldInverse.copy(camera.matrixWorld).invert() + camera.projectionMatrix.fromArray(overlayCamera.projectionMatrix) + camera.projectionMatrixInverse.fromArray(overlayCamera.projectionMatrixInverse) + camera.updateMatrixWorld(false) + } + + const coneMesh = coneMeshRef.current + const coneInwardEdgeGlowMesh = coneInwardEdgeGlowMeshRef.current + const coneOutwardEdgeGlowMesh = coneOutwardEdgeGlowMeshRef.current + const coneOutline = coneOutlineRef.current + const overlay = navigationVisualsStore.getState().toolConeIsolatedOverlay + const hullPoints = overlay?.visible ? overlay.hullPoints : [] + + if (!coneMesh || !coneInwardEdgeGlowMesh || !coneOutwardEdgeGlowMesh || !coneOutline) { + return + } + + applyConeOverlayColor( + overlay?.color ?? null, + coneMaterial, + coneInwardEdgeGlowMaterial, + coneOutwardEdgeGlowMaterial, + outlineMaterial, + ) + + let renderedHullPoints = hullPoints + if ( + overlay?.visible && + overlay.apexWorldPoint && + (overlay.supportWorldPoints?.length ?? 0) > 0 + ) { + const projectedHullCandidates = projectedHullCandidatesRef.current + projectedHullCandidates.length = 0 + + worldPointScratchRef.current.fromArray(overlay.apexWorldPoint) + projectedPointScratchRef.current.copy(worldPointScratchRef.current).project(camera) + projectedHullCandidates.push({ + isApex: true, + projectedPoint: new Vector2( + projectedPointScratchRef.current.x, + projectedPointScratchRef.current.y, + ), + worldPoint: worldPointScratchRef.current.clone(), + }) + + for (const supportWorldPoint of overlay.supportWorldPoints ?? []) { + worldPointScratchRef.current.fromArray(supportWorldPoint) + projectedPointScratchRef.current.copy(worldPointScratchRef.current).project(camera) + if ( + !Number.isFinite(projectedPointScratchRef.current.x) || + !Number.isFinite(projectedPointScratchRef.current.y) + ) { + continue + } + projectedHullCandidates.push({ + isApex: false, + projectedPoint: new Vector2( + projectedPointScratchRef.current.x, + projectedPointScratchRef.current.y, + ), + worldPoint: worldPointScratchRef.current.clone(), + }) + } + + renderedHullPoints = reorderHullFromApex(computeProjectedHull(projectedHullCandidates)).map( + (candidate) => ({ + isApex: candidate.isApex, + worldPoint: [candidate.worldPoint.x, candidate.worldPoint.y, candidate.worldPoint.z] as [ + number, + number, + number, + ], + }), + ) + } + + if (!overlay?.visible || renderedHullPoints.length < 3) { + coneMesh.visible = false + coneInwardEdgeGlowMesh.visible = false + coneOutwardEdgeGlowMesh.visible = false + coneOutline.visible = false + return + } + + projectedHullCentroidRef.current.set(0, 0, 0) + for (const hullPoint of renderedHullPoints) { + worldPointScratchRef.current.fromArray(hullPoint.worldPoint) + projectedHullCentroidRef.current.add(worldPointScratchRef.current) + } + projectedHullCentroidRef.current.divideScalar(renderedHullPoints.length) + + for (let index = 0; index < renderedHullPoints.length; index += 1) { + const hullPoint = renderedHullPoints[index] + if (!hullPoint) { + continue + } + worldPointScratchRef.current.fromArray(hullPoint.worldPoint) + mainPositionAttribute.setXYZ( + index, + worldPointScratchRef.current.x, + worldPointScratchRef.current.y, + worldPointScratchRef.current.z, + ) + mainUvAttribute.setXY( + index, + hullPoint.isApex ? 0 : 1, + index / Math.max(renderedHullPoints.length - 1, 1), + ) + } + mainPositionAttribute.needsUpdate = true + mainUvAttribute.needsUpdate = true + mainGeometry.setDrawRange(0, Math.max(0, (renderedHullPoints.length - 2) * 3)) + mainGeometry.computeVertexNormals() + + let outlineVertexIndex = 0 + let glowVertexIndex = 0 + for (let index = 0; index < renderedHullPoints.length; index += 1) { + const startPoint = renderedHullPoints[index] + const endPoint = renderedHullPoints[(index + 1) % renderedHullPoints.length] + if (!(startPoint && endPoint)) { + continue + } + + const startWorldPoint = inwardGlowStartScratchRef.current.fromArray(startPoint.worldPoint) + const endWorldPoint = inwardGlowEndScratchRef.current.fromArray(endPoint.worldPoint) + + outlinePositionAttribute.setXYZ( + outlineVertexIndex, + startWorldPoint.x, + startWorldPoint.y, + startWorldPoint.z, + ) + outlineVertexIndex += 1 + outlinePositionAttribute.setXYZ( + outlineVertexIndex, + endWorldPoint.x, + endWorldPoint.y, + endWorldPoint.z, + ) + outlineVertexIndex += 1 + + const inwardGlowStart = outwardGlowStartScratchRef.current + .copy(startWorldPoint) + .lerp(projectedHullCentroidRef.current, CONE_EDGE_GLOW_INWARD_DIFFUSION_DEPTH) + const inwardGlowEnd = outwardGlowEndScratchRef.current + .copy(endWorldPoint) + .lerp(projectedHullCentroidRef.current, CONE_EDGE_GLOW_INWARD_DIFFUSION_DEPTH) + inwardGlowPositionAttribute.setXYZ( + glowVertexIndex, + startWorldPoint.x, + startWorldPoint.y, + startWorldPoint.z, + ) + inwardGlowPositionAttribute.setXYZ( + glowVertexIndex + 1, + endWorldPoint.x, + endWorldPoint.y, + endWorldPoint.z, + ) + inwardGlowPositionAttribute.setXYZ( + glowVertexIndex + 2, + inwardGlowEnd.x, + inwardGlowEnd.y, + inwardGlowEnd.z, + ) + inwardGlowPositionAttribute.setXYZ( + glowVertexIndex + 3, + startWorldPoint.x, + startWorldPoint.y, + startWorldPoint.z, + ) + inwardGlowPositionAttribute.setXYZ( + glowVertexIndex + 4, + inwardGlowEnd.x, + inwardGlowEnd.y, + inwardGlowEnd.z, + ) + inwardGlowPositionAttribute.setXYZ( + glowVertexIndex + 5, + inwardGlowStart.x, + inwardGlowStart.y, + inwardGlowStart.z, + ) + + const outwardGlowStart = inwardGlowStartScratchRef.current + .copy(startWorldPoint) + .lerp(projectedHullCentroidRef.current, -CONE_EDGE_GLOW_OUTWARD_DIFFUSION_DEPTH) + const outwardGlowEnd = inwardGlowEndScratchRef.current + .copy(endWorldPoint) + .lerp(projectedHullCentroidRef.current, -CONE_EDGE_GLOW_OUTWARD_DIFFUSION_DEPTH) + outwardGlowPositionAttribute.setXYZ( + glowVertexIndex, + startWorldPoint.x, + startWorldPoint.y, + startWorldPoint.z, + ) + outwardGlowPositionAttribute.setXYZ( + glowVertexIndex + 1, + outwardGlowEnd.x, + outwardGlowEnd.y, + outwardGlowEnd.z, + ) + outwardGlowPositionAttribute.setXYZ( + glowVertexIndex + 2, + endWorldPoint.x, + endWorldPoint.y, + endWorldPoint.z, + ) + outwardGlowPositionAttribute.setXYZ( + glowVertexIndex + 3, + startWorldPoint.x, + startWorldPoint.y, + startWorldPoint.z, + ) + outwardGlowPositionAttribute.setXYZ( + glowVertexIndex + 4, + outwardGlowStart.x, + outwardGlowStart.y, + outwardGlowStart.z, + ) + outwardGlowPositionAttribute.setXYZ( + glowVertexIndex + 5, + outwardGlowEnd.x, + outwardGlowEnd.y, + outwardGlowEnd.z, + ) + + glowVertexIndex += 6 + } + + outlinePositionAttribute.needsUpdate = true + coneOutline.geometry.setDrawRange(0, outlineVertexIndex) + inwardGlowPositionAttribute.needsUpdate = true + coneInwardEdgeGlowMesh.geometry.setDrawRange(0, glowVertexIndex) + coneInwardEdgeGlowMesh.geometry.computeVertexNormals() + outwardGlowPositionAttribute.needsUpdate = true + coneOutwardEdgeGlowMesh.geometry.setDrawRange(0, glowVertexIndex) + coneOutwardEdgeGlowMesh.geometry.computeVertexNormals() + + coneMesh.visible = true + coneInwardEdgeGlowMesh.visible = true + coneOutwardEdgeGlowMesh.visible = true + coneOutline.visible = true + }) + + return ( + <> + + + + + + + + + + + + + + ) +} + +function ToolConeIsolatedOverlayCanvas() { + const enabled = useNavigationVisuals((state) => state.toolConeOverlayEnabled) + + if (!enabled) { + return null + } + + return ( +
+ { + const { powerPreference: _ignoredPowerPreference, ...rendererProps } = props as any + const renderer = new WebGLRenderer({ + ...rendererProps, + alpha: true, + premultipliedAlpha: true, + }) + renderer.setClearColor(0x000000, 0) + renderer.outputColorSpace = SRGBColorSpace + renderer.toneMapping = ACESFilmicToneMapping + renderer.toneMappingExposure = 0.9 + renderer.domElement.style.pointerEvents = 'none' + return renderer + }} + style={{ pointerEvents: 'none' }} + resize={{ + debounce: 100, + }} + shadows={false} + > + + +
+ ) +} + +export function ToolConeOverlayViewer({ + children, + enabled = false, + ...viewerProps +}: ComponentProps & { enabled?: boolean }) { + useEffect(() => { + const state = navigationVisualsStore.getState() + if (state.toolConeOverlayEnabled !== enabled) { + state.setToolConeOverlayEnabled(enabled) + } + if (!enabled) { + if (state.toolConeOverlayCamera !== null) { + state.setToolConeOverlayCamera(null) + } + if (state.toolConeIsolatedOverlay !== null) { + state.setToolConeIsolatedOverlay(null) + } + if (state.toolConeOverlayWarmupReady) { + state.setToolConeOverlayWarmupReady(false) + } + } + }) + + return ( +
+ + {enabled ? : null} + {children} + + {enabled ? : null} +
+ ) +} diff --git a/packages/robot/src/components/ui/navigation-panel.tsx b/packages/robot/src/components/ui/navigation-panel.tsx new file mode 100644 index 000000000..18c2c9505 --- /dev/null +++ b/packages/robot/src/components/ui/navigation-panel.tsx @@ -0,0 +1,72 @@ +'use client' + +import { emitter } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Power } from 'lucide-react' +import { useShallow } from 'zustand/react/shallow' +import { cn } from '../../lib/utils' +import useNavigation from '../../store/use-navigation' +import { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip' + +const PANEL_BUTTON_CLASS = + 'flex h-10 w-10 items-center justify-center rounded-xl border border-border/60 bg-background/70 text-muted-foreground transition-colors hover:border-border hover:bg-background hover:text-foreground disabled:cursor-not-allowed disabled:opacity-45' + +export function NavigationPanel() { + const { robotMode, setRobotMode } = useNavigation( + useShallow((state) => ({ + robotMode: state.robotMode, + setRobotMode: state.setRobotMode, + })), + ) + const setSelection = useViewer((state) => state.setSelection) + + const clearViewerSelectionState = () => { + 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 handleRobotOff = () => { + emitter.emit('tool:cancel') + clearViewerSelectionState() + setSelection({ selectedIds: [], zoneId: null }) + setRobotMode(null) + } + + if (!robotMode) { + return null + } + + const robotTooltip = + robotMode === 'normal' ? 'Turn robot off (manual mode).' : 'Turn robot off (task mode).' + + return ( +
+
+
+ + + + + {robotTooltip} + +
+
+
+ ) +} diff --git a/packages/robot/src/components/ui/navigation-task-queue-panel.tsx b/packages/robot/src/components/ui/navigation-task-queue-panel.tsx new file mode 100644 index 000000000..5d3858f4f --- /dev/null +++ b/packages/robot/src/components/ui/navigation-task-queue-panel.tsx @@ -0,0 +1,462 @@ +'use client' + +import { Copy, Move, Trash2, Wrench } from 'lucide-react' +import { + type CSSProperties, + type PointerEvent as ReactPointerEvent, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useShallow } from 'zustand/react/shallow' +import { isNavigationItemMoveCopyOperation } from '../../lib/item-move-request' +import { cn } from '../../lib/utils' +import type { NavigationQueuedTask } from '../../store/use-navigation' +import useNavigation from '../../store/use-navigation' +import navigationVisualsStore from '../../store/use-navigation-visuals' + +type TaskDragState = { + clientX: number + clientY: number + dragging: boolean + dropIndex: number + overPanel: boolean + pointerId: number + startClientX: number + startClientY: number + taskId: string +} + +type TaskQueueRenderEntry = + | { + key: string + task: NavigationQueuedTask + type: 'task' + } + | { + key: string + type: 'placeholder' + } + +const TASK_QUEUE_LAYER_STYLE: CSSProperties = { + bottom: '5.25rem', + left: '50%', + pointerEvents: 'none', + position: 'absolute', + transform: 'translateX(-50%)', +} + +const TASK_QUEUE_ROOT_STYLE: CSSProperties = { + inset: 0, + pointerEvents: 'none', + position: 'absolute', + zIndex: 1000, +} + +const TASK_QUEUE_PANEL_STYLE: CSSProperties = { + backgroundColor: 'rgba(15, 23, 42, 0.92)', + color: '#f8fafc', +} + +function isCopyTask(task: NavigationQueuedTask) { + if (task.kind !== 'move') { + return false + } + + return isNavigationItemMoveCopyOperation(task.request) +} + +function getTaskMeta(task: NavigationQueuedTask) { + if (task.kind === 'delete') { + return { + buttonClassName: + 'border-red-200/55 bg-red-500 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] hover:border-red-100/80 hover:bg-red-400', + buttonStyle: { + backgroundColor: '#ef4444', + borderColor: 'rgba(254, 202, 202, 0.72)', + color: '#ffffff', + } satisfies CSSProperties, + icon: Trash2, + label: 'Delete', + } + } + + if (task.kind === 'repair') { + return { + buttonClassName: + 'border-amber-100/70 bg-amber-400 text-amber-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.24)] hover:border-amber-50 hover:bg-amber-300', + buttonStyle: { + backgroundColor: '#fbbf24', + borderColor: 'rgba(254, 243, 199, 0.82)', + color: '#451a03', + } satisfies CSSProperties, + icon: Wrench, + label: 'Repair', + } + } + + if (isCopyTask(task)) { + return { + buttonClassName: + 'border-green-100/70 bg-green-500 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] hover:border-green-50 hover:bg-green-400', + buttonStyle: { + backgroundColor: '#22c55e', + borderColor: 'rgba(220, 252, 231, 0.78)', + color: '#ffffff', + } satisfies CSSProperties, + icon: Copy, + label: 'Copy', + } + } + + return { + buttonClassName: + 'border-sky-100/70 bg-sky-500 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] hover:border-sky-50 hover:bg-sky-400', + buttonStyle: { + backgroundColor: '#0ea5e9', + borderColor: 'rgba(224, 242, 254, 0.78)', + color: '#ffffff', + } satisfies CSSProperties, + icon: Move, + label: 'Move', + } +} + +function reorderTaskQueuePreview( + taskQueue: NavigationQueuedTask[], + taskId: string, + dropIndex: number, +): NavigationQueuedTask[] { + const sourceIndex = taskQueue.findIndex((task) => task.taskId === taskId) + if (sourceIndex < 0) { + return taskQueue + } + + const draggedTask = taskQueue[sourceIndex] + if (!draggedTask) { + return taskQueue + } + + const nextTaskQueue = taskQueue.filter((task) => task.taskId !== taskId) + const normalizedDropIndex = Math.min(Math.max(dropIndex, 0), nextTaskQueue.length) + nextTaskQueue.splice(normalizedDropIndex, 0, draggedTask) + return nextTaskQueue +} + +function getTaskQueueRenderEntries(taskQueue: NavigationQueuedTask[], dragState: TaskDragState | null) { + if (!(dragState?.dragging)) { + return taskQueue.map( + (task): TaskQueueRenderEntry => ({ + key: task.taskId, + task, + type: 'task', + }), + ) + } + + const queueWithoutDraggedTask = taskQueue.filter((task) => task.taskId !== dragState.taskId) + const normalizedDropIndex = Math.min(Math.max(dragState.dropIndex, 0), queueWithoutDraggedTask.length) + const entries: TaskQueueRenderEntry[] = queueWithoutDraggedTask.map((task) => ({ + key: task.taskId, + task, + type: 'task', + })) + entries.splice(normalizedDropIndex, 0, { + key: `placeholder-${dragState.taskId}-${normalizedDropIndex}`, + type: 'placeholder', + }) + return entries +} + +function getTaskDropIndex( + taskQueue: NavigationQueuedTask[], + taskId: string, + clientX: number, + buttonRefs: Partial>, +) { + const nextTaskQueue = taskQueue.filter((task) => task.taskId !== taskId) + for (let taskIndex = 0; taskIndex < nextTaskQueue.length; taskIndex += 1) { + const currentTask = nextTaskQueue[taskIndex] + if (!currentTask) { + continue + } + + const button = buttonRefs[currentTask.taskId] + if (!button) { + continue + } + + const bounds = button.getBoundingClientRect() + if (clientX < bounds.left + bounds.width / 2) { + return taskIndex + } + } + + return nextTaskQueue.length +} + +export function NavigationTaskQueuePanel() { + const { + activeTaskId, + itemMoveControllers, + moveQueuedTask, + removeQueuedTask, + robotMode, + setActiveTask, + taskQueue, + } = useNavigation( + useShallow((state) => ({ + activeTaskId: state.activeTaskId, + itemMoveControllers: state.itemMoveControllers, + moveQueuedTask: state.moveQueuedTask, + removeQueuedTask: state.removeQueuedTask, + robotMode: state.robotMode, + setActiveTask: state.setActiveTask, + taskQueue: state.taskQueue, + })), + ) + const [dragState, setDragState] = useState(null) + const rootRef = useRef(null) + const panelRef = useRef(null) + const buttonRefs = useRef>>({}) + const dragStateRef = useRef(null) + + const handleRemoveTask = (task: NavigationQueuedTask) => { + if (task.kind === 'move') { + itemMoveControllers[task.request.itemId]?.cancel() + } else if (task.kind === 'delete') { + navigationVisualsStore.getState().clearItemDelete(task.request.itemId) + } + + removeQueuedTask(task.taskId) + } + + const taskQueueRenderEntries = useMemo( + () => getTaskQueueRenderEntries(taskQueue, dragState), + [dragState, taskQueue], + ) + const draggedTask = useMemo( + () => taskQueue.find((task) => task.taskId === dragState?.taskId) ?? null, + [dragState?.taskId, taskQueue], + ) + const draggedTaskMeta = useMemo( + () => (draggedTask ? getTaskMeta(draggedTask) : null), + [draggedTask], + ) + const DraggedTaskIcon = draggedTaskMeta?.icon ?? null + const dragLayerBounds = rootRef.current?.getBoundingClientRect() ?? null + + useEffect(() => { + dragStateRef.current = dragState + }, [dragState]) + + useEffect(() => { + if (!dragState) { + return + } + + if (!taskQueue.some((task) => task.taskId === dragState.taskId)) { + setDragState(null) + } + }, [dragState, taskQueue]) + + useEffect(() => { + if (!dragState) { + return + } + + const handlePointerMove = (event: PointerEvent) => { + const currentDragState = dragStateRef.current + if (!currentDragState || event.pointerId !== currentDragState.pointerId) { + return + } + + event.preventDefault() + const panelBounds = panelRef.current?.getBoundingClientRect() ?? null + const overPanel = panelBounds + ? event.clientX >= panelBounds.left && + event.clientX <= panelBounds.right && + event.clientY >= panelBounds.top && + event.clientY <= panelBounds.bottom + : false + const dragging = + currentDragState.dragging || + Math.hypot( + event.clientX - currentDragState.startClientX, + event.clientY - currentDragState.startClientY, + ) > 6 + const dropIndex = + dragging && overPanel + ? getTaskDropIndex(taskQueue, currentDragState.taskId, event.clientX, buttonRefs.current) + : currentDragState.dropIndex + setDragState((currentState) => + currentState && currentState.pointerId === event.pointerId + ? { + ...currentState, + clientX: event.clientX, + clientY: event.clientY, + dragging, + dropIndex, + overPanel, + } + : currentState, + ) + } + + const handlePointerEnd = (event: PointerEvent) => { + const currentDragState = dragStateRef.current + if (!currentDragState || event.pointerId !== currentDragState.pointerId) { + return + } + + const currentTask = taskQueue.find((task) => task.taskId === currentDragState.taskId) ?? null + if (!currentTask) { + setDragState(null) + return + } + + if (!currentDragState.dragging) { + setActiveTask(currentDragState.taskId) + setDragState(null) + return + } + + if (!currentDragState.overPanel) { + handleRemoveTask(currentTask) + setDragState(null) + return + } + + const previewQueue = reorderTaskQueuePreview( + taskQueue, + currentDragState.taskId, + currentDragState.dropIndex, + ) + const nextTaskIndex = previewQueue.findIndex((task) => task.taskId === currentDragState.taskId) + if (nextTaskIndex >= 0) { + moveQueuedTask(currentDragState.taskId, nextTaskIndex) + } + setDragState(null) + } + + window.addEventListener('pointermove', handlePointerMove, { passive: false }) + window.addEventListener('pointerup', handlePointerEnd) + window.addEventListener('pointercancel', handlePointerEnd) + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerEnd) + window.removeEventListener('pointercancel', handlePointerEnd) + } + }, [dragState, handleRemoveTask, moveQueuedTask, setActiveTask, taskQueue]) + + const handleTaskPointerDown = + (task: NavigationQueuedTask, taskIndex: number) => + (event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + setDragState({ + clientX: event.clientX, + clientY: event.clientY, + dragging: false, + dropIndex: taskIndex, + overPanel: true, + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + taskId: task.taskId, + }) + } + + if (robotMode !== 'task') { + return null + } + + return ( +
+
+
+ {taskQueue.length === 0 ? ( +
+ Ghost Queue +
+ ) : ( + taskQueueRenderEntries.map((entry, taskIndex) => { + if (entry.type === 'placeholder') { + return ( +
+ ) + } + + const task = entry.task + const { buttonClassName, buttonStyle, 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/robot/src/components/ui/primitives/dropdown-menu.tsx b/packages/robot/src/components/ui/primitives/dropdown-menu.tsx new file mode 100644 index 000000000..3c42767e4 --- /dev/null +++ b/packages/robot/src/components/ui/primitives/dropdown-menu.tsx @@ -0,0 +1,228 @@ +'use client' + +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react' +import type * as React from 'react' + +import { cn } from '../../../lib/utils' + +function DropdownMenu({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: 'default' | 'destructive' +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/packages/robot/src/components/ui/primitives/tooltip.tsx b/packages/robot/src/components/ui/primitives/tooltip.tsx new file mode 100644 index 000000000..548dd61f7 --- /dev/null +++ b/packages/robot/src/components/ui/primitives/tooltip.tsx @@ -0,0 +1,57 @@ +'use client' + +import * as TooltipPrimitive from '@radix-ui/react-tooltip' +import type * as React from 'react' + +import { cn } from '../../../lib/utils' + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ ...props }: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ ...props }: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/packages/robot/src/editor-scene.ts b/packages/robot/src/editor-scene.ts new file mode 100644 index 000000000..3650c1ca9 --- /dev/null +++ b/packages/robot/src/editor-scene.ts @@ -0,0 +1,101 @@ +import { getItemMoveVisualState, setItemMoveVisualState } from './lib/item-move-visuals' +import { + getPascalTruckLocalAsset, + isPascalTruckNode, + stripPascalTruckFromSceneGraph, +} from './lib/pascal-truck' +import type { SceneGraph } from './lib/scene' +import { stripTransientMetadata } from './lib/transient' + +type SceneGraphWithCollections = SceneGraph & { + collections?: Record +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function hasTransientNavigationMetadata(node: unknown) { + return isRecord(node) && isRecord(node.metadata) && node.metadata.isTransient === true +} + +export function prepareNavigationSceneGraph( + graph: T | null | undefined, +): T | null | undefined { + if (!graph?.nodes) { + return graph + } + + const withoutTruck = stripPascalTruckFromSceneGraph(graph).sceneGraph as T | null | undefined + const baseGraph = withoutTruck ?? graph + let nextNodes: Record | null = + withoutTruck === graph ? null : { ...baseGraph.nodes } + const removedNodeIds = new Set() + for (const [nodeId, node] of Object.entries(baseGraph.nodes)) { + if (isPascalTruckNode(node)) { + const localTruckAsset = getPascalTruckLocalAsset() + const shouldStripTruckMetadata = + hasTransientNavigationMetadata(node) || getItemMoveVisualState(node.metadata) !== null + if ( + node.asset?.src !== localTruckAsset.src || + node.asset?.thumbnail !== localTruckAsset.thumbnail || + node.visible !== false || + shouldStripTruckMetadata + ) { + nextNodes ??= { ...baseGraph.nodes } + nextNodes[nodeId] = { + ...node, + asset: localTruckAsset, + metadata: shouldStripTruckMetadata + ? setItemMoveVisualState(stripTransientMetadata(node.metadata), null) + : node.metadata, + visible: false, + } + } + continue + } + + if (hasTransientNavigationMetadata(node)) { + nextNodes ??= { ...baseGraph.nodes } + delete nextNodes[nodeId] + removedNodeIds.add(nodeId) + continue + } + + if (isRecord(node) && getItemMoveVisualState(node.metadata) !== null) { + nextNodes ??= { ...baseGraph.nodes } + nextNodes[nodeId] = { + ...node, + metadata: setItemMoveVisualState(stripTransientMetadata(node.metadata), null), + } + } + } + + if (!nextNodes) { + return graph + } + + if (removedNodeIds.size > 0) { + for (const [nodeId, node] of Object.entries(nextNodes)) { + 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) { + nextNodes[nodeId] = { + ...node, + children: nextChildren, + } + } + } + } + + return { + ...graph, + nodes: nextNodes, + rootNodeIds: baseGraph.rootNodeIds.filter((nodeId) => !removedNodeIds.has(nodeId)), + } as T +} diff --git a/packages/robot/src/editor.tsx b/packages/robot/src/editor.tsx new file mode 100644 index 000000000..837f28e3e --- /dev/null +++ b/packages/robot/src/editor.tsx @@ -0,0 +1,64 @@ +'use client' + +import { Suspense, type ReactNode } from 'react' +import type { HoverStyles } from '@pascal-app/viewer' +import useNavigation from './store/use-navigation' +import { NavigationItemActionMenu } from './components/navigation-item-action-menu' +import { NavigationItemVisualSystem } from './components/navigation-item-visual-system' +import { NavigationPascalTruckMaterialSystem } from './components/navigation-pascal-truck-material-system' +import { NavigationSceneLifecycle } from './components/navigation-scene-lifecycle' +import { NavigationPanel } from './components/ui/navigation-panel' +import { NavigationTaskQueuePanel } from './components/ui/navigation-task-queue-panel' +export { NavigationSystem } from './components/navigation-system' +export { NavigationToolbarButton } from './components/navigation-toolbar-button' +export { NavigationPanel } from './components/ui/navigation-panel' +export { NavigationTaskQueuePanel } from './components/ui/navigation-task-queue-panel' +export { prepareNavigationSceneGraph } from './editor-scene' +export { shouldPauseNavigationAutoSave } from './lib/navigation-auto-save' +import { NavigationSystem } from './components/navigation-system' +import { ToolConeOverlayViewer } from './components/tool-cone-overlay-viewer' + +export function NavigationEditorSystems() { + const robotMode = useNavigation((state) => state.robotMode) + + if (robotMode === null) { + return null + } + + return ( + + + + + + + ) +} + +export function NavigationViewerFrame({ + children, + hoverStyles, + selectionManager, +}: { + children: ReactNode + hoverStyles: HoverStyles + selectionManager: 'custom' | 'default' +}) { + const robotMode = useNavigation((state) => state.robotMode) + + return ( +
+ + + + {children} + + + +
+ ) +} diff --git a/packages/robot/src/index.ts b/packages/robot/src/index.ts new file mode 100644 index 000000000..1037313eb --- /dev/null +++ b/packages/robot/src/index.ts @@ -0,0 +1,9 @@ +export { + DEFAULT_WALL_OVERLAY_FILTERS, + navigationEmitter, + requestNavigationItemDelete, + requestNavigationItemRepair, + default as useNavigation, +} from './store/use-navigation' +export { default as navigationVisualsStore, useNavigationVisuals } from './store/use-navigation-visuals' +export { shouldPauseNavigationAutoSave } from './lib/navigation-auto-save' diff --git a/packages/robot/src/lib/item-move-request.ts b/packages/robot/src/lib/item-move-request.ts new file mode 100644 index 000000000..2e9e575cc --- /dev/null +++ b/packages/robot/src/lib/item-move-request.ts @@ -0,0 +1,37 @@ +type NavigationItemMoveRequestLike = { + itemId: string + operation?: 'copy' | 'move' + targetPreviewItemId?: string | null + visualItemId?: string | null +} + +function isCopyPreviewId(id: string | null | undefined) { + return Boolean(id?.startsWith('item_debug_copy_preview_')) +} + +function isCopyCarryVisualId(id: string | null | undefined) { + return Boolean(id?.endsWith('__copy_carry')) +} + +export function isNavigationItemMoveCopyOperation( + request: NavigationItemMoveRequestLike | null | undefined, +) { + if (!request) { + return false + } + + if (request.operation) { + return request.operation === 'copy' + } + + return isCopyPreviewId(request.targetPreviewItemId) || isCopyCarryVisualId(request.visualItemId) +} + +export function normalizeNavigationItemMoveOperation( + request: T, +): T & { operation: 'copy' | 'move' } { + return { + ...request, + operation: isNavigationItemMoveCopyOperation(request) ? 'copy' : 'move', + } +} diff --git a/packages/robot/src/lib/item-move-visuals.ts b/packages/robot/src/lib/item-move-visuals.ts new file mode 100644 index 000000000..9dd6b1d38 --- /dev/null +++ b/packages/robot/src/lib/item-move-visuals.ts @@ -0,0 +1,46 @@ +export const ITEM_MOVE_VISUAL_METADATA_KEY = 'navigationMoveVisual' + +export type ItemMoveVisualState = + | 'carried' + | 'copy-source-pending' + | 'destination-ghost' + | 'destination-preview' + | 'source-pending' + +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/robot/src/lib/navigation-auto-save.ts b/packages/robot/src/lib/navigation-auto-save.ts new file mode 100644 index 000000000..9949cdae4 --- /dev/null +++ b/packages/robot/src/lib/navigation-auto-save.ts @@ -0,0 +1,19 @@ +'use client' + +import useNavigation from '../store/use-navigation' + +let navigationSceneRestorePending = false + +export function setNavigationSceneRestorePending(pending: boolean) { + navigationSceneRestorePending = pending +} + +export function shouldPauseNavigationAutoSave() { + const navigationState = useNavigation.getState() + const now = typeof performance !== 'undefined' ? performance.now() : Date.now() + const durableSceneSaveAllowed = navigationState.durableSceneSaveAllowedUntil > now + return ( + navigationSceneRestorePending || + (navigationState.robotMode !== null && !durableSceneSaveAllowed) + ) +} diff --git a/packages/robot/src/lib/navigation-performance.ts b/packages/robot/src/lib/navigation-performance.ts new file mode 100644 index 000000000..36805ad53 --- /dev/null +++ b/packages/robot/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/robot/src/lib/navigation.ts b/packages/robot/src/lib/navigation.ts new file mode 100644 index 000000000..0947dcdf1 --- /dev/null +++ b/packages/robot/src/lib/navigation.ts @@ -0,0 +1,2819 @@ +'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, + type ZoneNode, +} 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 +const NAV_MIN_WALKABLE_SLAB_ELEVATION = -0.01 +const NAV_MAX_WALKABLE_SLAB_ELEVATION = 0.75 + +// 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) + +function isNavigationWalkableSlab(slab: SlabNode) { + const elevation = slab.elevation ?? 0.05 + return elevation >= NAV_MIN_WALKABLE_SLAB_ELEVATION && elevation <= NAV_MAX_WALKABLE_SLAB_ELEVATION +} + +function isNavigationBlockedZone(zone: ZoneNode) { + const name = (zone.name ?? '').toLowerCase() + return /\b(pool|pond|water|spa)\b/.test(name) +} + +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 zones = level.children + .map((childId) => nodes[childId]) + .filter((node): node is ZoneNode => node?.type === 'zone') + 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) => { + if (!isNavigationWalkableSlab(slab)) { + return [] + } + + 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 slabObstacleSamples = measureNavigationPerf('navigation.build.slabObstacleSamplesMs', () => + slabs.flatMap((slab) => { + if (isNavigationWalkableSlab(slab)) { + return [] + } + + const polygon = toWalkablePlanPolygon(slab.polygon) + if (polygon.length < 3) { + return [] + } + + return [ + { + bounds: getPolygonBounds(polygon), + levelId: level.id, + polygon, + sourceId: slab.id, + } satisfies NavigationCollisionPolygonSample, + ] + }), + ) + 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 zoneObstacleSamples = measureNavigationPerf('navigation.build.zoneObstacleSamplesMs', () => + zones.flatMap((zone) => { + if (zone.visible === false || !isNavigationBlockedZone(zone)) { + return [] + } + + const polygon = toWalkablePlanPolygon(zone.polygon) + if (polygon.length < 3) { + return [] + } + + return [ + { + bounds: getPolygonBounds(polygon), + levelId: level.id, + polygon, + sourceId: zone.id, + } satisfies NavigationCollisionPolygonSample, + ] + }), + ) + const obstacleSamples = measureNavigationPerf('navigation.build.obstacleSamplesMs', () => [ + ...slabObstacleSamples, + ...zoneObstacleSamples, + ...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), + } +} + +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 openingId = openingIdByBridgeKey.get(pairKey) + if (!openingId) { + continue + } + + const doorOpening = doorOpeningById.get(openingId) + const fromCell = graph.cells[segment.fromCellIndex] + const toCell = graph.cells[segment.toCellIndex] + if (!(doorOpening && fromCell && toCell)) { + continue + } + + earliestTransitionByOpeningId.set(openingId, { + doorIds: doorOpening.doorIds, + 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/robot/src/lib/pascal-truck.ts b/packages/robot/src/lib/pascal-truck.ts new file mode 100644 index 000000000..d63a5531a --- /dev/null +++ b/packages/robot/src/lib/pascal-truck.ts @@ -0,0 +1,649 @@ +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 getPascalTruckAssetSrc() { + if (typeof window === 'undefined') { + return PASCAL_TRUCK_ASSET.src + } + + return new URL(PASCAL_TRUCK_ASSET.src, window.location.origin).toString() +} + +function getPascalTruckNodeAsset(): ItemNode['asset'] { + return { + ...PASCAL_TRUCK_NODE_ASSET, + src: getPascalTruckAssetSrc(), + } +} + +export function getPascalTruckLocalAsset(): ItemNode['asset'] { + return getPascalTruckNodeAsset() +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function hasPascalTruckManualPlacement(sourceTruckNode?: ItemNode | null) { + const metadata = isRecord(sourceTruckNode?.metadata) ? sourceTruckNode.metadata : null + return metadata?.manualPlacement === true +} + +function shouldPreservePascalTruckPlacement(sourceTruckNode?: ItemNode | null) { + return hasPascalTruckManualPlacement(sourceTruckNode) +} + +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 fallbackTargetCenter = getLevelGeometryCenter(sceneGraph, levelId) + const sitePolygon = getSitePolygonPoints(sceneGraph) + if (!sitePolygon) { + return fallbackTargetCenter + ? { + position: [fallbackTargetCenter[0], 0, fallbackTargetCenter[1]] as [ + number, + number, + number, + ], + rotation: [0, Math.PI, 0] as [number, number, number], + scale: PASCAL_TRUCK_SCENE_SCALE, + } + : fallback + } + + const targetCenter = fallbackTargetCenter ?? 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) && !hasPascalTruckManualPlacement(node)) + .map(([id]) => id), + ) + if (truckIds.size === 0) { + return { + sceneGraph, + truckNode: cloneValue(truckNode), + } + } + 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 truckAsset = getPascalTruckNodeAsset() + const node: ItemNode = sourceTruckNode + ? { + ...cloneValue(sourceTruckNode), + asset: truckAsset, + 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: true, + } + : { + asset: truckAsset, + 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/robot/src/lib/scene.ts b/packages/robot/src/lib/scene.ts new file mode 100644 index 000000000..991b3f65e --- /dev/null +++ b/packages/robot/src/lib/scene.ts @@ -0,0 +1,4 @@ +export type SceneGraph = { + nodes: Record + rootNodeIds: string[] +} diff --git a/packages/robot/src/lib/transient.ts b/packages/robot/src/lib/transient.ts new file mode 100644 index 000000000..fa1d09c0a --- /dev/null +++ b/packages/robot/src/lib/transient.ts @@ -0,0 +1,19 @@ +import { ITEM_MOVE_VISUAL_METADATA_KEY } from './item-move-visuals' + +const TRANSIENT_METADATA_KEYS = new Set([ + ITEM_MOVE_VISUAL_METADATA_KEY, + 'isTransient', + 'robotCopySourceId', +]) + +export function stripTransientMetadata(metadata: T): T { + if (!metadata || typeof metadata !== 'object') { + return metadata + } + + const cleaned = { ...(metadata as Record) } + for (const key of TRANSIENT_METADATA_KEYS) { + delete cleaned[key] + } + return cleaned as T +} diff --git a/packages/robot/src/lib/utils.ts b/packages/robot/src/lib/utils.ts new file mode 100644 index 000000000..d32b0fe65 --- /dev/null +++ b/packages/robot/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/packages/robot/src/lib/walkable-surface.ts b/packages/robot/src/lib/walkable-surface.ts new file mode 100644 index 000000000..772c719d1 --- /dev/null +++ b/packages/robot/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/robot/src/react-three-fiber.d.ts b/packages/robot/src/react-three-fiber.d.ts new file mode 100644 index 000000000..bcad821e7 --- /dev/null +++ b/packages/robot/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/robot/src/store/use-navigation-drafts.ts b/packages/robot/src/store/use-navigation-drafts.ts new file mode 100644 index 000000000..367bdbb67 --- /dev/null +++ b/packages/robot/src/store/use-navigation-drafts.ts @@ -0,0 +1,66 @@ +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 getNavigationDraftRobotCopySourceIdFromNode( + draft: Pick | null | undefined, +): ItemNode['id'] | null { + if (!draft) { + return null + } + + const stateSourceId = getNavigationDraftRobotCopySourceId(draft.id) + if (stateSourceId) { + return stateSourceId + } + + const metadata = + typeof draft.metadata === 'object' && draft.metadata !== null + ? (draft.metadata as Record) + : null + const metadataSourceId = metadata?.robotCopySourceId + return typeof metadataSourceId === 'string' ? (metadataSourceId as ItemNode['id']) : null +} + +export function setNavigationDraftRobotCopySourceId( + draftId: ItemNode['id'], + sourceId: ItemNode['id'] | null, +) { + useNavigationDraftState.getState().setRobotCopySourceId(draftId, sourceId) +} + +export default useNavigationDraftState diff --git a/packages/robot/src/store/use-navigation-visuals.ts b/packages/robot/src/store/use-navigation-visuals.ts new file mode 100644 index 000000000..d2830adfb --- /dev/null +++ b/packages/robot/src/store/use-navigation-visuals.ts @@ -0,0 +1,305 @@ +import type { BaseNode, ItemNode } from '@pascal-app/core' +import { useStore } from 'zustand' +import { createStore } from 'zustand/vanilla' +import type { ItemMoveVisualState } from '../lib/item-move-visuals' + +type NavigationItemDeleteActivation = { + fadeStartedAtMs: number | null + startedAtMs: number +} + +type NavigationItemMovePreview = { + id: ItemNode['id'] + sourceItemId: ItemNode['id'] +} + +type NavigationItemRepairActivation = { + startedAtMs: number +} + +type NavigationPostWarmupScope = + | ((run: () => void | Promise) => boolean | Promise) + | null + +type NavigationRuntimeVisualState = { + completeNavigationPostWarmup: (token: number) => void + itemDeleteActivations: Partial> + itemMovePreview: NavigationItemMovePreview | null + itemMoveVisualStates: Partial> + itemRepairActivations: Partial> + navigationPostWarmupCompletedToken: number + navigationPostWarmupRequestToken: number + navigationPostWarmupScope: NavigationPostWarmupScope + nodeVisibilityOverrides: Partial> + requestNavigationPostWarmup: () => number + setNavigationPostWarmupScope: (scope: NavigationPostWarmupScope) => void +} + +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 = NavigationRuntimeVisualState & { + activateItemDelete: (id: BaseNode['id']) => void + activateItemRepair: (id: BaseNode['id']) => void + beginItemDeleteFade: (id: BaseNode['id'], startedAtMs?: number) => void + clearItemDelete: (id?: BaseNode['id'] | null) => void + clearItemRepair: (id?: BaseNode['id'] | null) => void + registerTaskPreviewNode: (id: string) => void + resetRuntimeVisuals: (options?: { preserveToolConeOverlay?: boolean }) => void + resetTaskQueueVisuals: () => void + setItemMovePreview: (preview: NavigationItemMovePreview | 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()) + +function isTaskQueueMoveVisualState(visualState: ItemMoveVisualState) { + return ( + visualState === 'carried' || + visualState === 'copy-source-pending' || + visualState === 'destination-ghost' || + visualState === 'destination-preview' || + visualState === 'source-pending' + ) +} + +const navigationVisualsStore = createStore()((set) => ({ + activateItemDelete: (id) => + set((state) => ({ + itemDeleteActivations: { + ...state.itemDeleteActivations, + [id]: { + fadeStartedAtMs: null, + startedAtMs: now(), + }, + }, + })), + activateItemRepair: (id) => + set((state) => ({ + itemRepairActivations: { + ...state.itemRepairActivations, + [id]: { + 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 } + }), + clearItemRepair: (id) => + set((state) => { + if (!id) { + return Object.keys(state.itemRepairActivations).length === 0 + ? state + : { itemRepairActivations: {} } + } + + if (!state.itemRepairActivations[id]) { + return state + } + + const itemRepairActivations = { ...state.itemRepairActivations } + delete itemRepairActivations[id] + return { itemRepairActivations } + }), + registerTaskPreviewNode: (id) => + set((state) => + state.taskPreviewNodeIds[id] + ? state + : { + taskPreviewNodeIds: { + ...state.taskPreviewNodeIds, + [id]: true, + }, + }, + ), + resetRuntimeVisuals: (options) => + set((state) => ({ + itemDeleteActivations: {}, + itemMovePreview: null, + itemMoveVisualStates: {}, + itemRepairActivations: {}, + nodeVisibilityOverrides: {}, + taskPreviewNodeIds: {}, + toolConeIsolatedOverlay: null, + toolConeOverlayCamera: + options?.preserveToolConeOverlay === true ? state.toolConeOverlayCamera : null, + toolConeOverlayEnabled: + options?.preserveToolConeOverlay === true ? state.toolConeOverlayEnabled : false, + toolConeOverlayWarmupReady: + options?.preserveToolConeOverlay === true ? state.toolConeOverlayWarmupReady : false, + })), + resetTaskQueueVisuals: () => + set((state) => { + const nextItemMoveVisualStates: Partial> = {} + let removedMoveVisual = false + + for (const [itemId, visualState] of Object.entries(state.itemMoveVisualStates)) { + if (visualState && isTaskQueueMoveVisualState(visualState)) { + removedMoveVisual = true + continue + } + + nextItemMoveVisualStates[itemId as BaseNode['id']] = visualState + } + + const hasTaskQueueVisuals = + state.itemMovePreview !== null || + Object.keys(state.itemDeleteActivations).length > 0 || + Object.keys(state.itemRepairActivations).length > 0 || + Object.keys(state.taskPreviewNodeIds).length > 0 || + removedMoveVisual + + if (!hasTaskQueueVisuals) { + return state + } + + return { + itemDeleteActivations: {}, + itemMovePreview: null, + itemMoveVisualStates: nextItemMoveVisualStates, + itemRepairActivations: {}, + taskPreviewNodeIds: {}, + } + }), + completeNavigationPostWarmup: (token) => + set((state) => + token <= state.navigationPostWarmupCompletedToken + ? state + : { navigationPostWarmupCompletedToken: token }, + ), + itemDeleteActivations: {} as Partial>, + itemMovePreview: null, + itemMoveVisualStates: {} as Partial>, + itemRepairActivations: {} as Partial>, + navigationPostWarmupCompletedToken: 0, + navigationPostWarmupRequestToken: 0, + navigationPostWarmupScope: null as NavigationPostWarmupScope, + 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/robot/src/store/use-navigation.ts b/packages/robot/src/store/use-navigation.ts new file mode 100644 index 000000000..259ad2b20 --- /dev/null +++ b/packages/robot/src/store/use-navigation.ts @@ -0,0 +1,785 @@ +'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 { + isNavigationItemMoveCopyOperation, + normalizeNavigationItemMoveOperation, +} from '../lib/item-move-request' +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 + operation?: 'copy' | 'move' + 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 { + const normalizedRequest = normalizeNavigationItemMoveOperation(request) + return { + ...normalizedRequest, + finalUpdate: { + ...normalizedRequest.finalUpdate, + position: normalizedRequest.finalUpdate.position + ? ([...normalizedRequest.finalUpdate.position] as [number, number, number]) + : normalizedRequest.finalUpdate.position, + rotation: normalizedRequest.finalUpdate.rotation + ? ([...normalizedRequest.finalUpdate.rotation] as [number, number, number]) + : normalizedRequest.finalUpdate.rotation, + }, + itemDimensions: [...normalizedRequest.itemDimensions] as [number, number, number], + sourcePosition: [...normalizedRequest.sourcePosition] as [number, number, number], + sourceRotation: [...normalizedRequest.sourceRotation] as [number, number, number], + } +} + +function isNavigationItemMoveCopyRequest(request: NavigationItemMoveRequest) { + return isNavigationItemMoveCopyOperation(request) +} + +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 + allowDurableSceneSave: (durationMs?: number) => void + advanceTaskQueue: () => NavigationTaskAdvanceResult + beginTaskLoopReset: () => number + actorAvailable: boolean + actorWorldPosition: [number, number, number] | null + durableSceneSaveAllowedUntil: number + 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, + allowDurableSceneSave: (durationMs = 5000) => + set({ durableSceneSaveAllowedUntil: performance.now() + durationMs }), + 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, + durableSceneSaveAllowedUntil: 0, + 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 (state.robotMode !== 'task') { + return { + activeTaskId: null, + activeTaskIndex: 0, + itemDeleteRequest: itemDeleteRequest + ? cloneNavigationItemDeleteRequest(itemDeleteRequest) + : null, + itemMoveRequest: null, + itemRepairRequest: null, + taskQueue: [], + } + } + + 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 (state.robotMode !== 'task') { + return { + activeTaskId: null, + activeTaskIndex: 0, + itemDeleteRequest: null, + itemMoveRequest: itemMoveRequest ? cloneNavigationItemMoveRequest(itemMoveRequest) : null, + itemRepairRequest: null, + taskQueue: [], + } + } + + 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 (state.robotMode !== 'task') { + return { + activeTaskId: null, + activeTaskIndex: 0, + itemDeleteRequest: null, + itemMoveRequest: null, + itemRepairRequest: itemRepairRequest + ? cloneNavigationItemRepairRequest(itemRepairRequest) + : null, + taskQueue: [], + } + } + + 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 + } + + const nextState: Partial = { + enabled: robotMode !== null, + followRobotEnabled: robotMode !== null ? state.followRobotEnabled : false, + moveItemsEnabled: robotMode !== null, + robotMode, + } + if (robotMode !== 'task') { + nextState.activeTaskId = null + nextState.activeTaskIndex = 0 + nextState.itemDeleteRequest = null + nextState.itemMoveRequest = null + nextState.itemRepairRequest = null + nextState.taskQueue = [] + } + return nextState + }), + 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, +})) + +export function canUseRobotItemTask(node: ItemNode) { + const { enabled, moveItemsEnabled } = useNavigation.getState() + if (!enabled || !moveItemsEnabled || node.asset.attachTo) { + return false + } + const metadata = node.metadata as Record | null | undefined + if ( + metadata?.isTransient === true || + navigationVisualsStore.getState().taskPreviewNodeIds[node.id] + ) { + 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 taskAlreadyAssigned = + Boolean(navigationVisualsStore.getState().itemDeleteActivations[node.id]) || + Boolean(navigationVisualsStore.getState().itemRepairActivations[node.id]) || + navigationState.taskQueue.some((task) => task.request.itemId === node.id) + + if (taskAlreadyAssigned) { + viewerState.setHoveredId(null) + viewerState.setSelection({ selectedIds: [] }) + 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 taskAlreadyAssigned = + Boolean(navigationVisualsStore.getState().itemDeleteActivations[node.id]) || + Boolean(navigationVisualsStore.getState().itemRepairActivations[node.id]) || + navigationState.taskQueue.some((task) => task.request.itemId === node.id) + + if (taskAlreadyAssigned) { + viewerState.setHoveredId(null) + viewerState.setSelection({ selectedIds: [] }) + return true + } + + navigationVisualsStore.getState().activateItemRepair(node.id) + 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/robot/tsconfig.json b/packages/robot/tsconfig.json new file mode 100644 index 000000000..444f408cd --- /dev/null +++ b/packages/robot/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@pascal/typescript-config/react-library.json", + "compilerOptions": { + "noEmit": true, + "baseUrl": ".", + "paths": { + "@pascal-app/core": ["../core/src/index.ts"], + "@pascal-app/core/*": ["../core/src/*"], + "@pascal-app/editor/runtime": ["../editor/src/runtime.ts"], + "@pascal-app/viewer": ["../viewer/src/index.ts"] + } + }, + "include": ["src"] +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 7ed731a05..7715ac59d 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -105,7 +105,7 @@ function GPUDeviceWatcher() { useEffect(() => { const backend = (gl as any).backend - const device: GPUDevice | undefined = backend?.device + const device: WebGPUDeviceLike | undefined = backend?.device if (!device) { console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', { @@ -120,7 +120,7 @@ function GPUDeviceWatcher() { features: Array.from(device.features ?? []), }) - device.lost.then((info) => { + device.lost.then((info: WebGPUDeviceLossInfo) => { console.error( `[viewer] WebGPU device lost: reason="${info.reason}", message="${info.message}". ` + 'The page must be reloaded to recover the GPU context.', @@ -129,13 +129,14 @@ function GPUDeviceWatcher() { // Uncaptured errors are normally silent (only console-warned by Chrome at // best). Pipe them to console.error so silent mobile crashes show up. - const onUncapturedError = (event: GPUUncapturedErrorEvent) => { - console.error('[viewer] WebGPU uncaptured error:', event.error.message, event.error) + const onUncapturedError = (event: Event) => { + const error = (event as Event & { error?: { message?: string } }).error + console.error('[viewer] WebGPU uncaptured error:', error?.message, error) } - device.addEventListener('uncapturederror', onUncapturedError as EventListener) + device.addEventListener?.('uncapturederror', onUncapturedError) return () => { - device.removeEventListener('uncapturederror', onUncapturedError as EventListener) + device.removeEventListener?.('uncapturederror', onUncapturedError) } }, [gl])