From 8ed883554c37aa9103f59626b1b8e249bbf4e782 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 11 Nov 2026 10:49:03 -0500 Subject: [PATCH 1/3] viewer: live-scene walkthrough as LiveWalkthroughController Walking through an unbaked scene no longer needs the editor. The controller, collider world, ground holes, terrain collider and mechanism targets move to @pascal-app/viewer, driven by useViewer's walkthrough state; the first-person HUD store moves to the viewer entry (viewer/ui re-exports it). The editor's FirstPersonControls becomes a thin adapter adding capture mode and editor exit. scene-bounds moves to core. The packed fixture walks the fixture scene with renderPlugin only: movement, HUD, door sound and an unchanged document. Co-Authored-By: Claude Opus 5.5 --- fixtures/viewer-live/README.md | 13 +- fixtures/viewer-live/index.html | 4 + fixtures/viewer-live/main.tsx | 47 +- fixtures/viewer-live/scene.json | 15 +- packages/core/src/index.ts | 1 + .../src/utils}/scene-bounds.test.ts | 2 +- .../lib => core/src/utils}/scene-bounds.ts | 17 +- .../editor-2d/floorplan-group-move.tsx | 3 +- .../floorplan-registry-move-overlay.tsx | 3 +- .../editor/first-person-controls.tsx | 2003 +---------------- .../editor/floor-region-controls.tsx | 8 +- .../editor/floorplan-wall-typing-keys.ts | 2 +- .../src/components/editor/group-actions.ts | 3 +- .../src/components/editor/group-move-3d.ts | 3 +- .../components/editor/group-rotate-handle.tsx | 3 +- .../editor/handles/use-handle-drag.ts | 3 +- .../editor/src/components/editor/index.tsx | 2 +- .../src/components/editor/room-controls.tsx | 8 +- .../editor/snapshot-capture-overlay.tsx | 3 +- .../systems/roof/roof-edit-system.tsx | 2 +- .../tools/item/use-placement-coordinator.tsx | 3 +- .../paint-region/wall-paint-region-tool.tsx | 8 +- .../registry/move-registry-node-tool.tsx | 3 +- packages/editor/src/hooks/use-auto-frame.ts | 3 +- packages/editor/src/hooks/use-keyboard.ts | 3 +- packages/editor/src/index.tsx | 2 +- packages/editor/src/lib/paint-region-mode.ts | 2 +- packages/editor/src/lib/room-handle-drag.ts | 3 +- .../editor/src/lib/room-transform-session.ts | 3 +- .../use-wall-region-handles-selection.ts | 3 +- packages/viewer/README.md | 61 +- .../components/viewer/drone-controller.tsx | 9 +- .../first-person/build-collider-world.test.ts | 0 .../first-person/build-collider-world.ts | 2 +- .../viewer}/first-person/ground-holes.test.ts | 0 .../viewer}/first-person/ground-holes.ts | 0 .../first-person/mechanism-targets.test.ts | 0 .../viewer}/first-person/mechanism-targets.ts | 2 +- .../first-person/terrain-collider.test.ts | 0 .../viewer}/first-person/terrain-collider.ts | 0 .../viewer/glb-walkthrough-controller.tsx | 7 +- .../live-walkthrough-controller.test.tsx | 158 ++ .../viewer/live-walkthrough-controller.tsx | 1994 ++++++++++++++++ packages/viewer/src/index.ts | 10 + .../src/lib/typing-target.ts | 0 .../{ui => }/store/use-first-person-hud.ts | 0 .../src/ui/components/walkthrough-hud.tsx | 12 +- packages/viewer/src/ui/index.ts | 10 +- scripts/check-render-bundle.ts | 20 +- scripts/check-viewer-live-fixture.ts | 62 +- wiki/architecture/viewer-isolation.md | 34 +- 51 files changed, 2469 insertions(+), 2090 deletions(-) rename packages/{editor/src/lib => core/src/utils}/scene-bounds.test.ts (99%) rename packages/{editor/src/lib => core/src/utils}/scene-bounds.ts (92%) rename packages/{editor/src/components/editor => viewer/src/components/viewer}/first-person/build-collider-world.test.ts (100%) rename packages/{editor/src/components/editor => viewer/src/components/viewer}/first-person/build-collider-world.ts (99%) rename packages/{editor/src/components/editor => viewer/src/components/viewer}/first-person/ground-holes.test.ts (100%) rename packages/{editor/src/components/editor => viewer/src/components/viewer}/first-person/ground-holes.ts (100%) rename packages/{editor/src/components/editor => viewer/src/components/viewer}/first-person/mechanism-targets.test.ts (100%) rename packages/{editor/src/components/editor => viewer/src/components/viewer}/first-person/mechanism-targets.ts (97%) rename packages/{editor/src/components/editor => viewer/src/components/viewer}/first-person/terrain-collider.test.ts (100%) rename packages/{editor/src/components/editor => viewer/src/components/viewer}/first-person/terrain-collider.ts (100%) create mode 100644 packages/viewer/src/components/viewer/live-walkthrough-controller.test.tsx create mode 100644 packages/viewer/src/components/viewer/live-walkthrough-controller.tsx rename packages/{editor => viewer}/src/lib/typing-target.ts (100%) rename packages/viewer/src/{ui => }/store/use-first-person-hud.ts (100%) diff --git a/fixtures/viewer-live/README.md b/fixtures/viewer-live/README.md index 4a67323823..03ed89f8a6 100644 --- a/fixtures/viewer-live/README.md +++ b/fixtures/viewer-live/README.md @@ -16,16 +16,21 @@ SwiftShader, with normal and denied localStorage. It requires a local Chrome or `scene.json` was generated with core's node parsers and its recessed-downlight recipe fixture. It contains four walls, a door/window, a roof segment, a zone, a separator, -a measurement, a ceiling with a procedural downlight cut, and a GLTF item with a painted material slot. `public/cube.gltf` +a placed spawn facing the door, a measurement, a ceiling with a procedural downlight cut, and a GLTF item with a painted material slot. `public/cube.gltf` is a tiny self-contained cube with a `slot_body` material. There is no baked scene asset. The readiness probe requires the viewer's scene-ready callback, no pending build work, real node geometry and actual draw calls. It also rejects model failures, browser errors and a process polyfill. The page styles `@pascal-app/viewer/ui` with Tailwind and the shipped `theme.css`, as that entry's README describes, and mounts the viewer inside `ViewerStage` with the controls bar and -loader; the check requires their icons to load from `assetsBaseUrl` and a sound from `sfxBaseUrl`. +loader; the check requires their icons to load from `iconsBaseUrl` and a sound from `sfxBaseUrl`. It then mounts `ParametricViewerRooms` with only the level selected, requires the zone's room pill, -clicks it, and requires the room's highlight on the overlay layer and its callout. Last, it clicks -the stage switcher's 2D and Split buttons and requires the read-only plan to draw outlines for the +clicks it, and requires the room's highlight on the overlay layer and its callout. It clicks the +stage switcher's 2D and Split buttons and requires the read-only plan to draw outlines for the walls, door, window, roof, ceiling, zone and item from `renderPlugin` alone, beside the 3D view in Split. No Next configuration or editor package is used. + +The fixture then enters `LiveWalkthroughController` through viewer state with orbit controls +disabled, holds W until the camera moves, and requires the shared HUD/crosshair. E operates the +facing door: the check requires the controller's core `interaction:sound` cue and a request to +`/fixture-sfx/door_open.mp3`. It verifies that scene nodes remain unchanged and Escape exits. diff --git a/fixtures/viewer-live/index.html b/fixtures/viewer-live/index.html index 4e92042205..f82b991e45 100644 --- a/fixtures/viewer-live/index.html +++ b/fixtures/viewer-live/index.html @@ -8,6 +8,10 @@ diff --git a/fixtures/viewer-live/main.tsx b/fixtures/viewer-live/main.tsx index 8c958cff18..9f1329c7d6 100644 --- a/fixtures/viewer-live/main.tsx +++ b/fixtures/viewer-live/main.tsx @@ -1,6 +1,7 @@ import { acquireSceneReadOnlyLease, emitter, + type InteractionSound, loadPlugin, sceneRegistry, useScene, @@ -8,8 +9,10 @@ import { import { renderPlugin } from '@pascal-app/nodes/render' import { configureViewer, + LiveWalkthroughController, OVERLAY_LAYER, pendingSceneBuildCount, + useFirstPersonHud, useViewer, Viewer, } from '@pascal-app/viewer' @@ -19,10 +22,12 @@ import { SceneLoader, ViewerControlsBar, ViewerStage, + WalkthroughHud, } from '@pascal-app/viewer/ui' import { OrbitControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useState } from 'react' +import { flushSync } from 'react-dom' import { createRoot } from 'react-dom/client' import type { Mesh } from 'three' import type { WebGPURenderer } from 'three/webgpu' @@ -43,7 +48,8 @@ configureViewer({ }) // The packed sfx entry loads its files from the configured base; the check intercepts them. initInteractionSounds() -Object.assign(window, { playFixtureSound: () => emitter.emit('interaction:sound', 'door-open') }) +const interactionSounds: InteractionSound[] = [] +emitter.on('interaction:sound', (sound) => interactionSounds.push(sound)) await loadPlugin(renderPlugin) const releaseReadOnly = acquireSceneReadOnlyLease() useScene.getState().setScene(scene.nodes, scene.rootNodeIds, { @@ -63,6 +69,7 @@ useViewer.getState().setSelection({ function GeometryProbe() { const gl = useThree((state) => state.gl) as unknown as WebGPURenderer const root = useThree((state) => state.scene) + const camera = useThree((state) => state.camera) useFrame(() => { const geometry: Record = {} for (const id of Object.keys(scene.nodes)) { @@ -83,6 +90,10 @@ function GeometryProbe() { Object.assign(window, { viewerFixture: { geometry, + camera: camera.position.toArray(), + walking: useViewer.getState().walkthroughMode, + interactionSounds, + document: JSON.stringify(useScene.getState().nodes), pending: pendingSceneBuildCount(), overlay, itemFailures: useViewer.getState().itemLoadFailures, @@ -94,7 +105,32 @@ function GeometryProbe() { return null } +function FixtureHud() { + const floorLabel = useFirstPersonHud((state) => state.floorLabel) + const zoneLabel = useFirstPersonHud((state) => state.zoneLabel) + const interact = useFirstPersonHud((state) => state.interact) + const suspended = useViewer((state) => state.walkthroughSuspended) + return ( + useViewer.getState().setWalkthroughMode(false)} + /> + ) +} + function App() { + const walking = useViewer((state) => state.walkthroughMode) + const toggleWalkthrough = () => { + flushSync(() => useViewer.getState().setWalkthroughMode(!walking)) + if (!walking) { + const canvas = document.querySelector('canvas')! + const result = canvas.requestPointerLock?.() as Promise | undefined + result?.catch(() => {}) + } + } const [ready, setReady] = useState(false) // Mounted once the zone's own geometry is proven, as it hides zone volumes. With only the // level selected, the level shows its room pills. @@ -116,13 +152,18 @@ function App() { onSceneReadyChange={setReady} selectionManager="custom" > - + + {rooms && } {/* The packed UI entry mounts without the editor; its icons load from iconsBaseUrl. */} - {}} /> + + + {walking && } {!ready && } {ready ? 'Scene ready' : 'Building scene…'} diff --git a/fixtures/viewer-live/scene.json b/fixtures/viewer-live/scene.json index 79950ddb09..3625521a05 100644 --- a/fixtures/viewer-live/scene.json +++ b/fixtures/viewer-live/scene.json @@ -41,12 +41,23 @@ "measurement_fixture", "separator_fixture", "ceiling_fixture", - "item_fixture" + "item_fixture", + "spawn_fixture" ], "level": 0, "baseElevation": 0, "height": 3 }, + "spawn_fixture": { + "object": "node", + "id": "spawn_fixture", + "type": "spawn", + "parentId": "level_fixture", + "visible": true, + "metadata": {}, + "position": [-1, 0, -0.2], + "rotation": 0 + }, "wall_south": { "object": "node", "id": "wall_south", @@ -106,7 +117,7 @@ "parentId": "wall_south", "visible": true, "metadata": {}, - "position": [2, 0, 0], + "position": [2, 1.05, 0], "rotation": [0, 0, 0], "floorThresholdVersion": 1, "wallId": "wall_south", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5452bda425..8fd1a2064c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1356,6 +1356,7 @@ export { wallTrimPatch } from './systems/wall/wall-trim' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export * from './utils/measurement-format' +export { computeSceneBoundsXZ, type SceneBoundsXZ } from './utils/scene-bounds' export { areSemanticValuesEqual } from './utils/semantic-equal' export { isObject } from './utils/types' export { diff --git a/packages/editor/src/lib/scene-bounds.test.ts b/packages/core/src/utils/scene-bounds.test.ts similarity index 99% rename from packages/editor/src/lib/scene-bounds.test.ts rename to packages/core/src/utils/scene-bounds.test.ts index 9c30112f81..c8350989d7 100644 --- a/packages/editor/src/lib/scene-bounds.test.ts +++ b/packages/core/src/utils/scene-bounds.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' +import { computeSceneBoundsXZ } from '@pascal-app/core' import type { AnyNode } from '@pascal-app/core/schema' -import { computeSceneBoundsXZ } from './scene-bounds' function makeWall(start: [number, number], end: [number, number]): AnyNode { return { diff --git a/packages/editor/src/lib/scene-bounds.ts b/packages/core/src/utils/scene-bounds.ts similarity index 92% rename from packages/editor/src/lib/scene-bounds.ts rename to packages/core/src/utils/scene-bounds.ts index 71363fcdca..333986a42a 100644 --- a/packages/editor/src/lib/scene-bounds.ts +++ b/packages/core/src/utils/scene-bounds.ts @@ -1,18 +1,5 @@ -/** - * Scene bounds in the X/Z plane. - * - * Used by the auto-frame hook to fit the camera onto a freshly loaded scene - * (see `../hooks/use-auto-frame`). The hook subscribes to the core scene - * store and, when `nodes` transitions from empty → non-empty, fires a - * `camera-controls:fit-scene` event on the core event bus carrying the - * computed bounds. - * - * This module contains no rendering code: it only walks the flat-dict node - * tree and derives an axis-aligned bounding box on the XZ (plan) plane. - */ - -import { turnPlanPoint, yawOf } from '@pascal-app/core' -import type { AnyNode } from '@pascal-app/core/schema' +import { turnPlanPoint, yawOf } from '../lib/building-footprint' +import type { AnyNode } from '../schema' export type SceneBoundsXZ = { /** Min [x, z] in world units (meters). */ diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx index 384df1c19f..31f3cb45e4 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -17,7 +17,7 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { isTypingTarget, useViewer } from '@pascal-app/viewer' import { FloorplanDimensionRenderer } from '@pascal-app/viewer/ui' import { memo, type PointerEvent as ReactPointerEvent, useEffect, useMemo, useState } from 'react' import { create } from 'zustand' @@ -28,7 +28,6 @@ import { isHistoryShortcut } from '../../lib/history' import type { WallGroupPush } from '../../lib/room-handle-drag' import { useRoomHandleDrag } from '../../lib/room-handle-drag' import { sfxEmitter } from '../../lib/sfx-bus' -import { isTypingTarget } from '../../lib/typing-target' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor, { isAlignmentGuideActive, diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 30cde81b5b..775e0a2f75 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -26,7 +26,7 @@ import { useScene, } from '@pascal-app/core' import { nodeLevelFrame } from '@pascal-app/core/procedural-items' -import { useViewer } from '@pascal-app/viewer' +import { isTypingTarget, useViewer } from '@pascal-app/viewer' import { useEffect, useMemo } from 'react' import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement' import { isHistoryShortcut } from '../../lib/history' @@ -39,7 +39,6 @@ import { import { movementSfxStepKey } from '../../lib/sfx/movement-tick' import { sfxEmitter } from '../../lib/sfx-bus' import { surfaceAttachmentId } from '../../lib/surface-attachment' -import { isTypingTarget } from '../../lib/typing-target' import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor, { diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 3693357502..7feb3f397e 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1,1995 +1,28 @@ 'use client' -import { - type AnyNode, - type AnyNodeId, - type ElevatorDoorSide, - type ElevatorNode, - emitter, - getElevatorCabCenterZ, - getElevatorCabDepth, - getElevatorCabWidth, - getElevatorDoorLeafSides, - getElevatorDoorLeafWidth, - getElevatorDoorLeafX, - getElevatorShaftDepth, - getElevatorShaftWallThickness, - getElevatorShaftWidth, - getLevelDisplayName, - getLevelElevations, - getResolvedElevatorDoorStyle, - type InteractionSound, - itemPrompt, - nodeMechanism, - openElevatorDoor, - operateItem, - pointInPolygon2D, - requestElevatorLevel, - resolveElevatorDispatchTarget, - resolveElevatorLevels, - sceneRegistry, - toggleNodeMechanism, - useInteractive, - useScene, -} from '@pascal-app/core' -import { - operablePartFor, - operableParts, - type ProceduralItemNode, -} from '@pascal-app/core/procedural-items' -import { - applyWalkthroughCameraClipping, - BVHEcctrl, - type BVHEcctrlApi, - CROUCH_CAPSULE, - CROUCH_EYE_OFFSET, - CROUCH_FLOAT_HEIGHT, - CROUCH_RUN_SPEED, - CROUCH_WALK_SPEED, - createDroneInput, - DRONE_SLOW_MULTIPLIER, - type DroneLook, - EYE_LERP_SPEED, - type MovementInput, - openingSettled, - proceduralSettled, - STAND_CAPSULE, - STAND_CLEARANCE, - STAND_FLOAT_HEIGHT, - seedDroneLook, - setSurfaceRaycastLayers, - stepDroneFlight, - turnFirstPersonLook, - useViewer, - WALKTHROUGH_FOV, -} from '@pascal-app/viewer' -import { KeyboardControls } from '@react-three/drei' -import { useFrame, useThree } from '@react-three/fiber' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - Box3, - BoxGeometry, - Euler, - type Group, - Matrix4, - Mesh, - MeshBasicMaterial, - type Object3D, - type PerspectiveCamera, - Ray, - Raycaster, - Vector2, - Vector3, -} from 'three' -import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' -import '../../three-types' -import { - closeDoorOpenState, - closeWindowOpenState, - DOOR_SWING_OPEN_ANGLE, - getDisplayedDoorValue, - getDisplayedWindowValue, - isOperableWindowType, - isOperationDoorType, - toggleDoorOpenState, - toggleWindowOpenState, -} from '@pascal-app/core' -import { - DroneHud, - useFirstPersonHud, - WalkthroughHud, - type WalkthroughInteract, -} from '@pascal-app/viewer/ui' -import { isTypingTarget } from '../../lib/typing-target' +import { useScene } from '@pascal-app/core' +import { LiveWalkthroughController, useFirstPersonHud, useViewer } from '@pascal-app/viewer' +import { DroneHud, WalkthroughHud } from '@pascal-app/viewer/ui' +import { useCallback } from 'react' import useEditor from '../../store/use-editor' -import { - buildFirstPersonColliderWorldFromRegistry, - deriveFirstPersonSpawn, - FIRST_PERSON_SPAWN_EYE_HEIGHT, - type FirstPersonColliderWorld, - type FirstPersonSpawn, -} from './first-person/build-collider-world' -import { - hasNativeLeaf, - mechanismHudInteract, - mechanismTargetIds, - nativeOpeningIds, - watchScriptedDoors, -} from './first-person/mechanism-targets' -const CAMERA_EYE_OFFSET = 0.45 -const CONTROLLER_CENTER_FROM_EYE = 0.85 -const DOOR_INTERACTION_DISTANCE = 2.5 -const DOOR_LEAF_INTERACTION_DEPTH = 0.08 -const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18 -const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14 -const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 -const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 -const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 -const VOID_FALL_RESPAWN_DEPTH = 12 -const HUD_LABEL_SAMPLE_FRAMES = 10 - -type MovementKeyName = Exclude - -const movementKeyboardBindings: Array<{ name: MovementKeyName; keys: string[] }> = [ - { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, - { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, - { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] }, - { name: 'rightward', keys: ['ArrowRight', 'KeyD'] }, - { name: 'jump', keys: ['Space'] }, - { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, -] -const keyboardMap = movementKeyboardBindings -const movementKeyToName = new Map( - movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)), -) - -const inactiveMovementInput: MovementInput = { - backward: false, - forward: false, - jump: false, - leftward: false, - rightward: false, - run: false, -} - -function getMovementInputForKey(code: string, active: boolean): MovementInput | null { - const name = movementKeyToName.get(code) - return name ? ({ [name]: active } as MovementInput) : null -} - -function focusFirstPersonCanvas(canvas: HTMLCanvasElement) { - const activeElement = document.activeElement - if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) { - activeElement.blur() - } - - if (!canvas.hasAttribute('tabindex')) { - canvas.tabIndex = -1 - } - canvas.focus({ preventScroll: true }) -} - -const cameraOffset = new Vector3() -const cameraEuler = new Euler(0, 0, 0, 'YXZ') -const droneInput = createDroneInput() -const standClearanceRaycaster = new Raycaster() -const standClearanceUp = new Vector3(0, 1, 0) -const centerScreenPoint = new Vector2(0, 0) -const doorInteractionRaycaster = new Raycaster() -setSurfaceRaycastLayers(doorInteractionRaycaster.layers) -const proceduralInteractionRaycaster = new Raycaster() -setSurfaceRaycastLayers(proceduralInteractionRaycaster.layers) -const doorLeafBox = new Box3() -const doorLeafInverseMatrix = new Matrix4() -const doorLeafLocalHit = new Vector3() -const doorLeafLocalRay = new Ray() -const doorLeafMatrix = new Matrix4() -const doorLeafWorldHit = new Vector3() -const doorOpeningBox = new Box3() -const doorOpeningInverseMatrix = new Matrix4() -const doorOpeningLocalHit = new Vector3() -const doorOpeningLocalRay = new Ray() -const doorOpeningMatrix = new Matrix4() -const doorOpeningWorldHit = new Vector3() -const elevatorLocalControllerPosition = new Vector3() -const elevatorInteractionRaycaster = new Raycaster() -const elevatorColliderMatrix = new Matrix4() -const elevatorColliderLocalMatrix = new Matrix4() -const elevatorLocalEyePosition = new Vector3() -const elevatorWorldControllerPosition = new Vector3() -const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false }) -const spawnWorldPosition = new Vector3() -const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') -const windowInteractionRaycaster = new Raycaster() -setSurfaceRaycastLayers(windowInteractionRaycaster.layers) -const hudBuildingLocalEyePosition = new Vector3() -const hudWorldEyePosition = new Vector3() -const hudLevelBounds = new Box3() - -type ElevatorColliderKind = - | 'cab-back' - | 'cab-ceiling' - | 'cab-door-left' - | 'cab-door-right' - | 'cab-door-gate' - | 'cab-floor' - | 'cab-left' - | 'cab-right' - | 'landing-door-gate' - | 'landing-door-left' - | 'landing-door-right' - | 'shaft-back' - | 'shaft-front-header' - | 'shaft-front-left' - | 'shaft-front-right' - | 'shaft-left' - | 'shaft-right' - | 'shaft-top' - -type ElevatorColliderUserData = { - doorWidth?: number - dynamic?: boolean - elevatorId: AnyNodeId - kind: ElevatorColliderKind - levelId?: AnyNodeId - localPosition: [number, number, number] - matrixInitialized?: boolean - side?: ElevatorDoorSide -} - -type ElevatorColliderMesh = Mesh & { - userData: Mesh['userData'] & ElevatorColliderUserData -} - -type FirstPersonInteractableTarget = - | { - id: AnyNodeId - type: 'door' | 'window' - } - | { - id: AnyNodeId - type: 'item' - } - | { - id: AnyNodeId - partId?: string - kind?: 'hinge' | 'slide' | 'spin' - type: 'procedural' - } - | { - id: AnyNodeId - type: 'mechanism' - } - | { - action: 'open-door' | 'request-level' - buttonKind: 'cab' | 'landing' - id: AnyNodeId - levelId?: AnyNodeId - type: 'elevator' - } - -type ElevatorButtonTarget = { - action: 'open-door' | 'request-level' - buttonKind: 'cab' | 'landing' - elevatorId: AnyNodeId - levelId?: AnyNodeId -} - -function getLevelChildren( - level: Extract, - nodes: Record, -) { - const childIds = new Set(level.children) - return Object.values(nodes).filter((node) => node.parentId === level.id || childIds.has(node.id)) -} - -function pointIsInLevelFootprint( - point: [number, number], - worldPoint: Vector3, - level: Extract, - nodes: Record, -) { - const children = getLevelChildren(level, nodes) - const slabs = children.filter( - (node): node is Extract => - node.type === 'slab' && node.polygon.length >= 3, - ) - const zones = children.filter( - (node): node is Extract => - node.type === 'zone' && node.polygon.length >= 3, - ) - - if (slabs.length > 0) { - return slabs.some( - (slab) => - pointInPolygon2D(point, slab.polygon) && - !slab.holes.some((hole) => pointInPolygon2D(point, hole)), - ) - } - - if (zones.length > 0) { - if (zones.some((zone) => pointInPolygon2D(point, zone.polygon))) return true - } - - const levelObject = sceneRegistry.nodes.get(level.id) - if (!levelObject) return false - hudLevelBounds.setFromObject(levelObject) - return ( - !hudLevelBounds.isEmpty() && - worldPoint.x >= hudLevelBounds.min.x && - worldPoint.x <= hudLevelBounds.max.x && - worldPoint.z >= hudLevelBounds.min.z && - worldPoint.z <= hudLevelBounds.max.z - ) -} - -function resolveFirstPersonHudLabels(worldPoint: Vector3) { - const nodes = useScene.getState().nodes - const levelElevations = getLevelElevations(nodes as Record) - - for (const building of Object.values(nodes)) { - if (building.type !== 'building') continue - const buildingObject = sceneRegistry.nodes.get(building.id) - if (!buildingObject) continue - - buildingObject.updateWorldMatrix(true, true) - hudBuildingLocalEyePosition.copy(worldPoint) - buildingObject.worldToLocal(hudBuildingLocalEyePosition) - - const levels = Object.values(nodes) - .filter((node) => node.type === 'level') - .filter((level) => levelElevations.get(level.id)?.buildingId === building.id) - .sort( - (left, right) => - (levelElevations.get(left.id)?.baseY ?? 0) - (levelElevations.get(right.id)?.baseY ?? 0), - ) - - let activeLevel: (typeof levels)[number] | null = null - for (const level of levels) { - const elevation = levelElevations.get(level.id) - if (!elevation) continue - if ( - hudBuildingLocalEyePosition.y >= elevation.baseY - 0.5 && - hudBuildingLocalEyePosition.y < elevation.baseY + elevation.height + 0.5 - ) { - activeLevel = level - } - } - if (!activeLevel) continue - - const point: [number, number] = [hudBuildingLocalEyePosition.x, hudBuildingLocalEyePosition.z] - if (!pointIsInLevelFootprint(point, worldPoint, activeLevel, nodes)) continue - - const zone = getLevelChildren(activeLevel, nodes).find( - (node) => - node.type === 'zone' && node.polygon.length >= 3 && pointInPolygon2D(point, node.polygon), - ) - - return { - floorLabel: getLevelDisplayName(activeLevel), - zoneLabel: zone?.type === 'zone' ? zone.name : null, - } - } - - return { floorLabel: null, zoneLabel: null } -} - -function resolveHudInteract(target: FirstPersonInteractableTarget | null): WalkthroughInteract { - if (!target) return null - if (target.type === 'elevator') { - return { - label: target.action === 'open-door' ? 'door button' : 'elevator button', - verb: 'press', - } - } - - const node = useScene.getState().nodes[target.id] - if (target.type === 'mechanism') return node ? mechanismHudInteract(node) : null - if (target.type === 'item') { - if (node?.type !== 'item' || !node.asset.interactive) return null - const { label, verb } = itemPrompt( - target.id, - node.name ?? node.asset.name, - node.asset.interactive, - ) - return { label, verb } - } - if (target.type === 'procedural') { - if (node?.type !== 'procedural-item') return null - const procedural = node as ProceduralItemNode - const parts = operableParts(procedural.recipe) - if (parts.length === 0 && procedural.recipe.parts.some((part) => part.light)) { - const state = useInteractive.getState() - const isOn = state.procedural[target.id]?.lightsOn ?? state.lampDefault - return { label: procedural.name ?? 'Lights', verb: isOn ? 'turn off' : 'turn on' } - } - const part = target.partId ? parts.find((entry) => entry.id === target.partId) : undefined - const active = useInteractive.getState().procedural[target.id]?.parts - const isOn = part ? Boolean(active?.[part.id]) : parts.some((entry) => active?.[entry.id]) - const kind = part?.kind ?? (parts.every((entry) => entry.kind === 'spin') ? 'spin' : 'hinge') - return { - label: part?.label ?? procedural.name ?? 'item', - verb: kind === 'spin' ? (isOn ? 'turn off' : 'turn on') : isOn ? 'close' : 'open', - } - } - if (target.type === 'window') { - if (node?.type !== 'window') return null - const isOpen = getDisplayedWindowValue(target.id, node.operationState) > 0 - return { label: node.name || 'window', verb: isOpen ? 'close' : 'open' } - } - - if (node?.type !== 'door') return null - const isOpen = isOperationDoorType(node.doorType) - ? getDisplayedDoorValue(target.id, 'operationState', node.operationState) > 0 - : getDisplayedDoorValue(target.id, 'swingAngle', node.swingAngle) > 0 - return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' } -} - -/** E plays what the viewer's callout plays for the same door, window or switch. */ -function playTargetSound(target: FirstPersonInteractableTarget) { - const verb = resolveHudInteract(target)?.verb - if (!verb || verb === 'press') return - const node = useScene.getState().nodes[target.id] - // A recipe's door or lid swings and claps shut, its drawer slides; both land on the motion's end. - if (target.type === 'procedural' && (verb === 'open' || verb === 'close')) { - if (verb === 'open') { - emitter.emit('interaction:sound', 'panel-open') - return - } - if (target.kind !== 'slide') emitter.emit('interaction:sound', 'panel-swing') - void proceduralSettled(target.id).then(() => emitter.emit('interaction:sound', 'panel-close')) - return - } - let sound: InteractionSound - if (verb !== 'open' && verb !== 'close') - sound = verb === 'turn on' || verb === 'play' ? 'switch-on' : 'switch-off' - else if (node?.type === 'door' && node.doorType?.startsWith('garage-')) sound = 'garage' - else if (node?.type === 'window') sound = verb === 'open' ? 'window-open' : 'window-close' - else sound = verb === 'open' ? 'door-open' : 'door-close' - // A built-in leaf or sash swings shut, then latches on the frame it lands. - if ( - (sound === 'door-close' || sound === 'window-close') && - (target.type === 'door' || target.type === 'window') && - node - ) { - emitter.emit('interaction:sound', sound === 'door-close' ? 'door-swing' : 'window-swing') - void openingSettled(node).then(() => emitter.emit('interaction:sound', sound)) - return - } - emitter.emit('interaction:sound', sound) -} - -function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null { - let current: Object3D | null = object - - while (current) { - const candidate = ( - current.userData as { - elevatorButton?: { - action?: unknown - disabled?: unknown - elevatorId?: unknown - kind?: unknown - levelId?: unknown - } - } - ).elevatorButton - - if (candidate?.disabled === true) { - return null - } - - if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') { - const action = candidate.action === 'open-door' ? 'open-door' : 'request-level' - if (action === 'open-door') { - return { - action, - buttonKind: candidate.kind, - elevatorId: candidate.elevatorId as AnyNodeId, - } - } - } - - if ( - typeof candidate?.elevatorId === 'string' && - typeof candidate.levelId === 'string' && - (candidate.kind === 'cab' || candidate.kind === 'landing') - ) { - return { - action: 'request-level', - buttonKind: candidate.kind, - elevatorId: candidate.elevatorId as AnyNodeId, - levelId: candidate.levelId as AnyNodeId, - } - } - - current = current.parent - } - - return null -} - -function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) { - if (!target) return null - return target.type === 'elevator' - ? `${target.type}:${target.id}:${target.levelId}` - : target.type === 'procedural' - ? `${target.type}:${target.id}:${target.partId ?? 'all'}` - : `${target.type}:${target.id}` -} - -function isDynamicElevatorCollider(kind: ElevatorColliderKind) { - return kind.startsWith('cab-') || kind.startsWith('landing-door') -} - -function isInsideElevatorCab( - elevator: ElevatorNode, - runtime: NonNullable['elevators'][AnyNodeId]>, - localEyePosition: Vector3, -) { - const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING - const halfDepth = getElevatorCabDepth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING - const cabCenterZ = getElevatorCabCenterZ(elevator) - const cabHeight = Math.max(elevator.cabHeight, 1.4) - - return ( - Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) && - Math.abs(localEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) && - localEyePosition.y >= runtime.carY + 0.35 && - localEyePosition.y <= runtime.carY + cabHeight + 0.7 - ) -} - -function createElevatorColliderMesh( - elevatorId: AnyNodeId, - kind: ElevatorColliderKind, - size: [number, number, number], - localPosition: [number, number, number], - userData: Partial = {}, -) { - const geometry = new BoxGeometry(size[0], size[1], size[2]) - - const bvhGeometry = geometry as typeof geometry & { - computeBoundsTree?: typeof computeBoundsTree - disposeBoundsTree?: typeof disposeBoundsTree - } - ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree - ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree - bvhGeometry.computeBoundsTree?.({ - targetLeafSize: 12, - strategy: 0, - } as never) - bvhGeometry.computeBoundingBox() - - const mesh = new Mesh(bvhGeometry, elevatorColliderMaterial) as unknown as ElevatorColliderMesh - mesh.raycast = acceleratedRaycast - mesh.matrixAutoUpdate = false - mesh.visible = true - mesh.userData = { - ...userData, - dynamic: isDynamicElevatorCollider(kind), - elevatorId, - excludeCollisionCheck: false, - excludeFloatHit: false, - friction: 0.8, - kind, - localPosition, - matrixInitialized: false, - restitution: 0.03, - type: 'ELEVATOR_COLLIDER', - } - return mesh -} - -function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { - const nodes = useScene.getState().nodes - const meshes: ElevatorColliderMesh[] = [] - - for (const elevatorId of sceneRegistry.byType.elevator!) { - const typedElevatorId = elevatorId as AnyNodeId - const node = nodes[typedElevatorId] - if (node?.type !== 'elevator' || node.visible === false) continue - - const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorLevels(node, nodes) - const cabWidth = getElevatorCabWidth(node) - const cabDepth = getElevatorCabDepth(node) - const shaftWidth = getElevatorShaftWidth(node, cabWidth) - const shaftDepth = getElevatorShaftDepth(node, cabDepth) - const cabHeight = Math.max(node.cabHeight, 1.4) - const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18) - const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1) - const doorStyle = getResolvedElevatorDoorStyle(node.doorStyle) - const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) - const wallThickness = getElevatorShaftWallThickness(node) - const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) - const cabFloorDepth = Math.max(cabDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) - const frontWallZ = -shaftDepth / 2 - wallThickness / 2 - const frontZ = frontWallZ - wallThickness / 2 - 0.018 - const cabCenterZ = -shaftDepth / 2 + cabDepth / 2 - const leafWidth = getElevatorDoorLeafWidth(doorWidth, doorStyle) - const doorLeafSides = getElevatorDoorLeafSides(doorStyle) - const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight) - - meshes.push( - createElevatorColliderMesh( - typedElevatorId, - 'shaft-back', - [shaftWidth + wallThickness * 2, shaftHeight, wallThickness], - [0, shaftBaseY + shaftHeight / 2, shaftDepth / 2 + wallThickness / 2], - ), - createElevatorColliderMesh( - typedElevatorId, - 'shaft-left', - [wallThickness, shaftHeight, shaftDepth + wallThickness * 2], - [-shaftWidth / 2 - wallThickness / 2, shaftBaseY + shaftHeight / 2, 0], - ), - createElevatorColliderMesh( - typedElevatorId, - 'shaft-right', - [wallThickness, shaftHeight, shaftDepth + wallThickness * 2], - [shaftWidth / 2 + wallThickness / 2, shaftBaseY + shaftHeight / 2, 0], - ), - createElevatorColliderMesh( - typedElevatorId, - 'shaft-top', - [shaftWidth + wallThickness * 2, wallThickness, shaftDepth + wallThickness * 2], - [0, shaftBaseY + shaftHeight - wallThickness / 2, 0], - ), - createElevatorColliderMesh( - typedElevatorId, - 'cab-floor', - [cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth], - [0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, cabCenterZ], - ), - createElevatorColliderMesh( - typedElevatorId, - 'cab-ceiling', - [cabWidth, wallThickness, cabDepth], - [0, cabHeight - wallThickness / 2, cabCenterZ], - ), - createElevatorColliderMesh( - typedElevatorId, - 'cab-back', - [cabWidth, cabHeight, wallThickness], - [0, cabHeight / 2, cabCenterZ + cabDepth / 2 - wallThickness / 2], - ), - createElevatorColliderMesh( - typedElevatorId, - 'cab-left', - [wallThickness, cabHeight, cabDepth], - [-cabWidth / 2 + wallThickness / 2, cabHeight / 2, cabCenterZ], - ), - createElevatorColliderMesh( - typedElevatorId, - 'cab-right', - [wallThickness, cabHeight, cabDepth], - [cabWidth / 2 - wallThickness / 2, cabHeight / 2, cabCenterZ], - ), - createElevatorColliderMesh( - typedElevatorId, - 'cab-door-gate', - [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], - [0, doorHeight / 2, frontZ], - { doorWidth }, - ), - ...doorLeafSides.map((side) => - createElevatorColliderMesh( - typedElevatorId, - side === 'left' ? 'cab-door-left' : 'cab-door-right', - [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], - [0, doorHeight / 2, frontZ], - { doorWidth, side }, - ), - ), - ) - - const entrySpans = entries.map((entry, index) => { - const nextEntry = entries[index + 1] - return { - entry, - levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24), - } - }) - - for (const { entry, levelTopY } of entrySpans) { - const wallDepth = wallThickness - const levelHeight = Math.max(levelTopY - entry.baseY, doorHeight + 0.24) - const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08) - const jambCenterOffset = doorWidth / 2 + jambWidth / 2 - const headerHeight = Math.max(levelHeight - doorHeight, 0.14) - - meshes.push( - createElevatorColliderMesh( - typedElevatorId, - 'shaft-front-left', - [jambWidth, levelHeight, wallDepth], - [-jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ], - ), - createElevatorColliderMesh( - typedElevatorId, - 'shaft-front-right', - [jambWidth, levelHeight, wallDepth], - [jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ], - ), - createElevatorColliderMesh( - typedElevatorId, - 'shaft-front-header', - [shaftWidth, headerHeight, wallDepth], - [0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ], - ), - createElevatorColliderMesh( - typedElevatorId, - 'landing-door-gate', - [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], - [0, entry.baseY + doorHeight / 2, frontZ - 0.02], - { doorWidth, levelId: entry.id }, - ), - ...doorLeafSides.map((side) => - createElevatorColliderMesh( - typedElevatorId, - side === 'left' ? 'landing-door-left' : 'landing-door-right', - [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], - [0, entry.baseY + doorHeight / 2, frontZ - 0.02], - { doorWidth, levelId: entry.id, side }, - ), - ), - ) - } - } - - return meshes -} - -function disposeElevatorColliderMeshes(meshes: ElevatorColliderMesh[]) { - for (const mesh of meshes) { - const geometry = mesh.geometry as typeof mesh.geometry & { - disposeBoundsTree?: typeof disposeBoundsTree - } - geometry.disposeBoundsTree?.() - geometry.dispose() - } -} - -const resolvePlacedSpawnNode = ( - nodes: ReturnType['nodes'], - _levelId: string | null, -) => { - const candidates = Object.values(nodes).filter((node) => node.type === 'spawn') - if (candidates.length === 0) return null - - return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null -} - -export const FirstPersonControls = () => { - const { camera, gl } = useThree() - const selectedLevelId = useViewer((state) => state.selection.levelId) - const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId)) - const isDroneMode = useEditor((state) => state.firstPersonMovementMode === 'drone') - const controllerRef = useRef(null) - const movementInputRef = useRef({ ...inactiveMovementInput }) - const hadPointerLockRef = useRef(false) - const lookRef = useRef({ yaw: 0, pitch: 0 }) - const interactableTargetRef = useRef(null) - const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1) - const crouchKeyRef = useRef(false) - const droneAscendKeyRef = useRef(false) - const droneSlowKeyRef = useRef(false) - const droneDescendKeyRef = useRef(false) - const droneVelocityRef = useRef(new Vector3()) - const suspendRef = useRef(false) - const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET) - const [crouched, setCrouched] = useState(false) - const captureShutterHold = useEditor((state) => state.captureShutterHold) - const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false) - const ridingElevatorRef = useRef<{ - elevatorId: AnyNodeId - localControllerY: number | null - previousCarY: number - } | null>(null) - const rideLockedRef = useRef(false) - const worldRef = useRef(null) - const elevatorColliderMeshesRef = useRef([]) - const [world, setWorld] = useState(null) - const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState([]) - const [controllerStart, setControllerStart] = useState<{ - position: [number, number, number] - yaw: number - } | null>(null) - - useEffect(() => { - if (!(camera as PerspectiveCamera).isPerspectiveCamera) return - return applyWalkthroughCameraClipping(camera as PerspectiveCamera) - }, [camera]) - - useEffect(() => { - const previousCameraMode = useViewer.getState().cameraMode - if (previousCameraMode === 'orthographic') { - useViewer.getState().setCameraMode('perspective') - } - return () => { - if (previousCameraMode === 'orthographic') { - useViewer.getState().setCameraMode('orthographic') - } - } - }, []) - - // While a snapshot is being framed the capture rig owns the fov (the user is - // driving it from the overlay's slider), so walkthrough neither applies its - // own nor restores one underneath it. Read imperatively: this must not re-run - // — and therefore restore — when capture mode toggles mid-walkthrough. - useEffect(() => { - const perspectiveCamera = camera as PerspectiveCamera - if (!perspectiveCamera.isPerspectiveCamera) return - if (useEditor.getState().isCaptureMode) return - const previousFov = perspectiveCamera.fov - perspectiveCamera.fov = WALKTHROUGH_FOV - perspectiveCamera.updateProjectionMatrix() - return () => { - if (useEditor.getState().isCaptureMode) return - perspectiveCamera.fov = previousFov - perspectiveCamera.updateProjectionMatrix() - } - }, [camera]) - - useEffect(() => { - useFirstPersonHud.getState().reset() - return () => useFirstPersonHud.getState().reset() - }, []) - - const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => { - worldRef.current?.dispose() - worldRef.current = nextWorld - setWorld(nextWorld) - }, []) - - const replaceElevatorColliderMeshes = useCallback((nextMeshes: ElevatorColliderMesh[]) => { - disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current) - elevatorColliderMeshesRef.current = nextMeshes - setElevatorColliderMeshes(nextMeshes) +export function FirstPersonControls() { + const capture = useEditor((state) => state.isCaptureMode) + const paused = useEditor((state) => state.captureShutterHold) + const onExit = useCallback(() => { + const editor = useEditor.getState() + if (editor.isCaptureMode) editor.setCaptureMode(false) + else editor.setFirstPersonMode(false) }, []) - const rebuildColliderWorld = useCallback(() => { - replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry()) - replaceElevatorColliderMeshes(buildElevatorColliderMeshes()) - }, [replaceColliderWorld, replaceElevatorColliderMeshes]) - - const setElevatorRideLocked = useCallback((locked: boolean) => { - if (rideLockedRef.current === locked) return - rideLockedRef.current = locked - setIsElevatorRideLocked(locked) - }, []) - - const setControllerApi = useCallback((api: BVHEcctrlApi | null) => { - controllerRef.current = api - if (api) { - api.setMovement(movementInputRef.current) - } - }, []) - - const resolveInteractableDoorId = useCallback((): AnyNodeId | null => { - const nodes = useScene.getState().nodes - camera.updateMatrixWorld(true) - doorInteractionRaycaster.setFromCamera(centerScreenPoint, camera) - - let closestDoorId: AnyNodeId | null = null - let closestDistance = DOOR_INTERACTION_DISTANCE - - for (const doorId of sceneRegistry.byType.door!) { - const node = nodes[doorId as AnyNodeId] - if (node?.type !== 'door') continue - if (node.openingKind === 'opening') continue - if (!hasNativeLeaf(node)) continue - if (node.segments.every((segment) => segment.type === 'empty')) continue - - const object = sceneRegistry.nodes.get(doorId) - if (!object) continue - - object.updateWorldMatrix(true, true) - - const placementHit = doorInteractionRaycaster - .intersectObject(object, true) - .find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE) - if (placementHit && placementHit.distance < closestDistance) { - closestDoorId = doorId as AnyNodeId - closestDistance = placementHit.distance - } - - const leafW = node.width - 2 * node.frameThickness - const leafH = node.height - node.frameThickness - if (leafW <= 0 || leafH <= 0) continue - - const leafCenterY = -node.frameThickness / 2 - - if (isOperationDoorType(node.doorType)) { - doorOpeningMatrix - .copy(object.matrixWorld) - .multiply(new Matrix4().makeTranslation(0, leafCenterY, 0)) - doorOpeningInverseMatrix.copy(doorOpeningMatrix).invert() - doorOpeningBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2) - doorOpeningBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2) - doorOpeningLocalRay - .copy(doorInteractionRaycaster.ray) - .applyMatrix4(doorOpeningInverseMatrix) - - const localOpeningHit = doorOpeningLocalRay.intersectBox( - doorOpeningBox, - doorOpeningLocalHit, - ) - if (!localOpeningHit) continue - - doorOpeningWorldHit.copy(localOpeningHit).applyMatrix4(doorOpeningMatrix) - const openingHitDistance = doorOpeningWorldHit.distanceTo( - doorInteractionRaycaster.ray.origin, - ) - - if ( - openingHitDistance <= DOOR_INTERACTION_DISTANCE && - openingHitDistance < closestDistance - ) { - closestDoorId = doorId as AnyNodeId - closestDistance = openingHitDistance - } - continue - } - - const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2 - const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 - const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 - const currentSwingAngle = - useInteractive.getState().doors[doorId as AnyNodeId]?.swingAngle ?? node.swingAngle ?? 0 - const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, currentSwingAngle)) - const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign - - doorLeafMatrix - .copy(object.matrixWorld) - .multiply(new Matrix4().makeTranslation(hingeX, 0, 0)) - .multiply(new Matrix4().makeRotationY(leafSwingRotation)) - .multiply(new Matrix4().makeTranslation(-hingeX, leafCenterY, 0)) - doorLeafInverseMatrix.copy(doorLeafMatrix).invert() - doorLeafBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2) - doorLeafBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2) - doorLeafLocalRay.copy(doorInteractionRaycaster.ray).applyMatrix4(doorLeafInverseMatrix) - - const localHit = doorLeafLocalRay.intersectBox(doorLeafBox, doorLeafLocalHit) - if (!localHit) continue - - doorLeafWorldHit.copy(localHit).applyMatrix4(doorLeafMatrix) - const hitDistance = doorLeafWorldHit.distanceTo(doorInteractionRaycaster.ray.origin) - - if (hitDistance <= DOOR_INTERACTION_DISTANCE && hitDistance < closestDistance) { - closestDoorId = doorId as AnyNodeId - closestDistance = hitDistance - } - } - - return closestDoorId - }, [camera]) - - const resolveInteractableWindowId = useCallback((): AnyNodeId | null => { - const nodes = useScene.getState().nodes - camera.updateMatrixWorld(true) - windowInteractionRaycaster.setFromCamera(centerScreenPoint, camera) - - let closestWindowId: AnyNodeId | null = null - let closestDistance = DOOR_INTERACTION_DISTANCE - - for (const windowId of sceneRegistry.byType.window!) { - const node = nodes[windowId as AnyNodeId] - if (node?.type !== 'window') continue - if (node.openingKind === 'opening') continue - if (!hasNativeLeaf(node)) continue - if (!isOperableWindowType(node.windowType)) continue - - const object = sceneRegistry.nodes.get(windowId) - if (!object) continue - - const hit = windowInteractionRaycaster - .intersectObject(object, true) - .find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE) - if (!(hit && hit.distance < closestDistance)) continue - - closestWindowId = windowId as AnyNodeId - closestDistance = hit.distance - } - - return closestWindowId - }, [camera]) - - const resolveInteractableElevatorTarget = - useCallback((): FirstPersonInteractableTarget | null => { - const nodes = useScene.getState().nodes - camera.updateMatrixWorld(true) - elevatorInteractionRaycaster.setFromCamera(centerScreenPoint, camera) - - let closestTarget: FirstPersonInteractableTarget | null = null - let closestDistance = DOOR_INTERACTION_DISTANCE - - for (const elevatorId of sceneRegistry.byType.elevator!) { - const typedElevatorId = elevatorId as AnyNodeId - const node = nodes[typedElevatorId] - if (node?.type !== 'elevator') continue - - const object = sceneRegistry.nodes.get(typedElevatorId) - if (!object) continue - - const runtime = useInteractive.getState().elevators[typedElevatorId] - object.updateWorldMatrix(true, true) - if (runtime) { - elevatorLocalEyePosition.copy(camera.position) - object.worldToLocal(elevatorLocalEyePosition) - } - const canUseCabButtons = - runtime && isInsideElevatorCab(node, runtime, elevatorLocalEyePosition) - - const intersections = elevatorInteractionRaycaster.intersectObject(object, true) - for (const intersection of intersections) { - if (intersection.distance > closestDistance) continue - - const target = resolveElevatorButtonTarget(intersection.object) - if (!target || target.elevatorId !== elevatorId) continue - if (target.action === 'request-level') { - if (!target.levelId || nodes[target.levelId]?.type !== 'level') continue - } - if (target.buttonKind === 'cab' && !canUseCabButtons) continue - - closestTarget = { - action: target.action, - buttonKind: target.buttonKind, - id: target.elevatorId, - levelId: target.levelId, - type: 'elevator', - } - closestDistance = intersection.distance - } - } - - return closestTarget - }, [camera]) - - const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => { - const elevatorTarget = resolveInteractableElevatorTarget() - if (elevatorTarget) return elevatorTarget - - const doorId = resolveInteractableDoorId() - if (doorId) return { id: doorId, type: 'door' } - - const windowId = resolveInteractableWindowId() - if (windowId) return { id: windowId, type: 'window' } - - camera.updateMatrixWorld(true) - proceduralInteractionRaycaster.setFromCamera(centerScreenPoint, camera) - proceduralInteractionRaycaster.far = DOOR_INTERACTION_DISTANCE - let closest: FirstPersonInteractableTarget | null = null - let closestDistance = DOOR_INTERACTION_DISTANCE - const nodes = useScene.getState().nodes - const covered = new Set() - for (const rawId of sceneRegistry.byType['procedural-item'] ?? []) { - covered.add(rawId) - const id = rawId as AnyNodeId - const node = nodes[id] - if (node?.type !== 'procedural-item') continue - const procedural = node as ProceduralItemNode - if ( - !operableParts(procedural.recipe).length && - !procedural.recipe.parts.some((part) => part.light) - ) - continue - const object = sceneRegistry.nodes.get(id) - if (!object) continue - for (const hit of proceduralInteractionRaycaster.intersectObject(object, true)) { - if (hit.distance >= closestDistance) break - // Skip what is not drawn: a joint tree's inactive rest or joint meshes. - let drawn = true - for (let at: Object3D | null = hit.object; at && at !== object; at = at.parent) - if (!at.visible) drawn = false - if (!drawn) continue - let ancestor: Object3D | null = hit.object - while (ancestor && ancestor !== object && !ancestor.userData.proceduralMotion) - ancestor = ancestor.parent - let motion = ancestor?.userData.proceduralMotion as - | { nodeId?: string; partId?: string; kind?: 'hinge' | 'slide' | 'spin' } - | undefined - // An idle joint tree draws one merged mesh per slot; its face ranges name the part. - const ranges = hit.object.userData.proceduralRanges as - | { end: number; partId: string }[] - | undefined - if (!motion && ranges && hit.faceIndex != null) { - const face = hit.faceIndex - const partId = ranges.find((range) => face < range.end)?.partId - const part = partId ? operablePartFor(procedural.recipe, partId) : undefined - if (part) motion = { nodeId: id, partId: part.id, kind: part.kind } - } - closest = - motion?.nodeId === id && motion.partId - ? { id, partId: motion.partId, kind: motion.kind, type: 'procedural' } - : { id, type: 'procedural' } - closestDistance = hit.distance - break - } - } - for (const rawId of sceneRegistry.byType.item ?? []) { - covered.add(rawId) - const id = rawId as AnyNodeId - const node = nodes[id] - if ( - node?.type !== 'item' || - !node.asset.interactive?.controls.some((control) => control.kind === 'toggle') - ) - continue - const object = sceneRegistry.nodes.get(id) - if (!object) continue - const hit = proceduralInteractionRaycaster.intersectObject(object, true)[0] - if (hit && hit.distance < closestDistance) { - closest = { id, type: 'item' } - closestDistance = hit.distance - } - } - // Doors and windows have their own walkthrough targets (resolveInteractable*Id), except those - // built from a script: their clips run through the mechanism target below. - for (const id of nativeOpeningIds()) covered.add(id) - for (const id of mechanismTargetIds(covered)) { - const object = sceneRegistry.nodes.get(id) - const hit = object && proceduralInteractionRaycaster.intersectObject(object, true)[0] - if (hit && hit.distance < closestDistance) { - closest = { id, type: 'mechanism' } - closestDistance = hit.distance - } - } - if (!closest) { - const selectedId = useViewer.getState().selection.selectedIds.at(-1) as AnyNodeId | undefined - const selected = selectedId ? nodes[selectedId] : undefined - if ( - selected?.type === 'item' && - selected.asset.interactive?.controls.some((control) => control.kind === 'toggle') - ) - return { id: selected.id, type: 'item' } - } - if (closest) return closest - - return null - }, [ - camera, - resolveInteractableDoorId, - resolveInteractableElevatorTarget, - resolveInteractableWindowId, - ]) - - const toggleInteractableTarget = useCallback(() => { - // Drone is a camera, not an avatar: the click that re-acquires pointer lock - // must not swing a door open under the shot being framed. (In capture - // mode's walk camera the CLICK path is gated at handleMouseDown — there a - // locked-pointer click is the shutter — but E/R still open doors, so the - // photographer can stage the shot.) - if (isDroneMode) return - - const target = interactableTargetRef.current ?? resolveInteractableTarget() - if (!target) return - playTargetSound(target) - if (target.type === 'mechanism') { - const node = useScene.getState().nodes[target.id] - if (node) toggleNodeMechanism(node) - return - } - if (target.type === 'item') { - const node = useScene.getState().nodes[target.id] - if (node?.type === 'item' && node.asset.interactive) - operateItem(target.id, node.asset.interactive) - return - } - - if (target.type === 'procedural') { - const node = useScene.getState().nodes[target.id] - if (node?.type !== 'procedural-item') return - const parts = operableParts((node as ProceduralItemNode).recipe).map((part) => part.id) - const state = useInteractive.getState() - if (parts.length === 0) { - state.toggleProceduralLights(target.id) - return - } - if (target.partId) state.toggleProceduralPart(target.id, target.partId) - else - state.setProceduralParts( - target.id, - parts, - !parts.some((id) => state.procedural[target.id]?.parts[id]), - ) - return - } - - if (target.type === 'elevator') { - if (target.buttonKind === 'cab') { - const state = useInteractive.getState().elevators[target.id] - if (state) { - ridingElevatorRef.current = { - elevatorId: target.id, - localControllerY: null, - previousCarY: state.carY, - } - } - } - if (target.action === 'open-door') { - openElevatorDoor(target.id) - return - } - if (target.levelId) { - const targetElevatorId = - target.buttonKind === 'landing' - ? resolveElevatorDispatchTarget({ - elevators: useInteractive.getState().elevators, - levelId: target.levelId, - nodes: useScene.getState().nodes, - requestedElevatorId: target.id, - }) - : target.id - requestElevatorLevel(targetElevatorId, target.levelId) - } - return - } - - if (target.type === 'window') { - const node = useScene.getState().nodes[target.id] - if ( - node?.type !== 'window' || - node.openingKind === 'opening' || - !isOperableWindowType(node.windowType) - ) { - return - } - - toggleWindowOpenState(target.id, { persist: false }) - return - } - - const doorId = target.id - - const node = useScene.getState().nodes[doorId] - if (node?.type !== 'door' || node.openingKind === 'opening') return - - toggleDoorOpenState(doorId, { persist: false }) - }, [isDroneMode, resolveInteractableTarget]) - - const closeInteractableTarget = useCallback(() => { - if (isDroneMode) return - - const target = interactableTargetRef.current ?? resolveInteractableTarget() - if (!target) return - - if (target.type === 'mechanism') { - const node = useScene.getState().nodes[target.id] - if (node) nodeMechanism(node)?.set(node, false) - return - } - - if (target.type === 'procedural') { - const node = useScene.getState().nodes[target.id] - if (node?.type !== 'procedural-item') return - const parts = target.partId - ? [target.partId] - : operableParts((node as ProceduralItemNode).recipe).map((part) => part.id) - useInteractive.getState().setProceduralParts(target.id, parts, false) - return - } - - if (target.type === 'elevator') return - - if (target.type === 'window') { - const node = useScene.getState().nodes[target.id] - if ( - node?.type !== 'window' || - node.openingKind === 'opening' || - !isOperableWindowType(node.windowType) - ) { - return - } - - closeWindowOpenState(target.id, { persist: false }) - return - } - - const node = useScene.getState().nodes[target.id] - if (node?.type !== 'door' || node.openingKind === 'opening') return - - closeDoorOpenState(target.id, { persist: false }) - }, [isDroneMode, resolveInteractableTarget]) - - const placedSpawn = useMemo(() => { - if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null - - const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id) - if (spawnObject) { - spawnObject.updateWorldMatrix(true, false) - spawnObject.getWorldPosition(spawnWorldPosition) - spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ') - - return { - position: [ - spawnWorldPosition.x, - spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT, - spawnWorldPosition.z, - ], - yaw: spawnWorldEuler.y, - } - } - - return { - position: [ - placedSpawnNode.position[0], - placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT, - placedSpawnNode.position[2], - ], - yaw: placedSpawnNode.rotation, - } - }, [placedSpawnNode]) - - useEffect(() => { - // Drone has no gravity, no floor and no collision, so the BVH collider world - // (a full-scene traversal, rebuilt on every door/window animation) is pure - // cost there. Switching modes disposes it and rebuilds on the way back. - if (!isDroneMode) rebuildColliderWorld() - - return () => { - worldRef.current?.dispose() - worldRef.current = null - disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current) - elevatorColliderMeshesRef.current = [] - setElevatorColliderMeshes([]) - setWorld(null) - } - }, [isDroneMode, rebuildColliderWorld]) - - useEffect(() => { - if (isDroneMode) return - emitter.on('door:animation-completed', rebuildColliderWorld) - emitter.on('window:animation-completed', rebuildColliderWorld) - // A door built from a script opens by its model's clip, which reports no animation end. - const stopWatching = watchScriptedDoors(rebuildColliderWorld) - return () => { - emitter.off('door:animation-completed', rebuildColliderWorld) - emitter.off('window:animation-completed', rebuildColliderWorld) - stopWatching() - } - }, [isDroneMode, rebuildColliderWorld]) - - // A walk session started before a drone detour would otherwise resume at its - // original spawn; drop it so the next walk re-derives one. - useEffect(() => { - if (isDroneMode) setControllerStart(null) - }, [isDroneMode]) - - // Drone picks up wherever the camera currently is — orbit pose or walk eye. - useEffect(() => { - if (!isDroneMode) return - seedDroneLook(lookRef.current, camera.quaternion) - droneVelocityRef.current.set(0, 0, 0) - - // Interaction prompts belong to walk; drop whatever its frame left behind. - if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) { - useViewer.getState().setHoveredId(null) - } - interactableTargetRef.current = null - useFirstPersonHud.getState().reset() - }, [camera, isDroneMode]) - - useEffect(() => { - if (!world) return - if (controllerStart) return - - const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world) - const [x, y, z] = spawn.position - lookRef.current.yaw = spawn.yaw - lookRef.current.pitch = 0 - setControllerStart({ - position: [x, y - CONTROLLER_CENTER_FROM_EYE, z], - yaw: spawn.yaw, - }) - }, [camera, controllerStart, placedSpawn, world]) - - useEffect(() => { - const canvas = gl.domElement - focusFirstPersonCanvas(canvas) - - const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas)) - return () => window.cancelAnimationFrame(frame) - }, [gl]) - - // The pointer-lock effect below must be mount-stable. Its cleanup exits - // pointer lock, and `toggleInteractableTarget` is recreated whenever the - // camera object changes — which happens right after entry when the - // persisted orthographic mode swaps to perspective. If the async lock - // grant lands before that re-run, the cleanup's exitPointerLock fires an - // unlock the handler reads as "user left walkthrough", instantly - // cancelling a fresh entry (and arming the browser's ~1.25s re-lock - // cooldown, so the next presses fail too). Route the callback through a - // ref so the effect deps stay `[gl]`. - const toggleInteractableTargetRef = useRef(toggleInteractableTarget) - useEffect(() => { - toggleInteractableTargetRef.current = toggleInteractableTarget - }, [toggleInteractableTarget]) - - useEffect(() => { - const canvas = gl.domElement - const handleMouseMove = (e: MouseEvent) => { - if (document.pointerLockElement !== canvas) return - // Shutter hold: the shot is rendering — a mouse twitch must not pan it. - if (useEditor.getState().captureShutterHold) return - - turnFirstPersonLook( - lookRef.current, - e.movementX, - e.movementY, - useEditor.getState().firstPersonMovementMode === 'drone' && droneSlowKeyRef.current - ? DRONE_SLOW_MULTIPLIER - : 1, - ) - } - - const handleClick = (event: MouseEvent) => { - const target = event.target - if (!(target instanceof HTMLElement)) return - if (!canvas.contains(target)) return - if (document.pointerLockElement !== canvas) { - canvas.requestPointerLock?.() - } - } - - const handleMouseDown = (event: MouseEvent) => { - if (document.pointerLockElement !== canvas) return - if (event.button !== 0) return - - // Capture mode: the locked-pointer click is the SHUTTER (the snapshot - // overlay's window-capture listener already fired); doors stay on E/R. - if (useEditor.getState().isCaptureMode) return - - event.preventDefault() - event.stopPropagation() - toggleInteractableTargetRef.current() - } - - const handlePointerLockChange = () => { - const isLocked = document.pointerLockElement === canvas - if (isLocked) { - hadPointerLockRef.current = true - suspendRef.current = false - useViewer.getState().setWalkthroughSuspended(false) - return - } - - // Deliberately released (screenshot pause) — stay in first person; - // clicking the canvas re-locks. - if (suspendRef.current) return - - // Capture mode: Esc (the browser's own unlock — no keydown reaches us) - // acts like P. Dropping back to orbit would throw away the framed pose, - // which reads as a crash to anyone who never noticed P. - if ( - hadPointerLockRef.current && - useEditor.getState().isCaptureMode && - useEditor.getState().isFirstPersonMode - ) { - suspendRef.current = true - useViewer.getState().setWalkthroughSuspended(true) - return - } - - if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) { - useEditor.getState().setFirstPersonMode(false) - } - } - - handlePointerLockChange() - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('click', handleClick) - document.addEventListener('mousedown', handleMouseDown, true) - document.addEventListener('pointerlockchange', handlePointerLockChange) - - return () => { - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('click', handleClick) - document.removeEventListener('mousedown', handleMouseDown, true) - document.removeEventListener('pointerlockchange', handlePointerLockChange) - useViewer.getState().setWalkthroughSuspended(false) - if (document.pointerLockElement === canvas) { - document.exitPointerLock() - } - } - }, [gl]) - - useEffect(() => { - const canvas = gl.domElement - - const applyMovementKey = (event: KeyboardEvent, active: boolean) => { - if (isTypingTarget(event.target)) return false - - const movement = getMovementInputForKey(event.code, active) - if (!movement) return false - - event.preventDefault() - Object.assign(movementInputRef.current, movement) - controllerRef.current?.setMovement(movement) - return true - } - - const handleKeyDown = (event: KeyboardEvent) => { - const handledMovement = applyMovementKey(event, true) - if (handledMovement) return - - if (isTypingTarget(event.target)) return - - if (event.code === 'ControlLeft' || event.code === 'ControlRight') { - // While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard - // screenshot) must not toggle it under the user. - if (!suspendRef.current) crouchKeyRef.current = true - } else if (event.code === 'KeyQ') { - // Drone descend. Space (already bound to jump) and E are the matching ascend. - event.preventDefault() - event.stopPropagation() - if (!suspendRef.current) droneDescendKeyRef.current = true - } else if (event.code === 'KeyE' && isDroneMode) { - event.preventDefault() - event.stopPropagation() - if (!suspendRef.current) droneAscendKeyRef.current = true - } else if ((event.code === 'AltLeft' || event.code === 'AltRight') && isDroneMode) { - event.preventDefault() - event.stopPropagation() - if (!suspendRef.current) droneSlowKeyRef.current = true - } else if (event.code === 'Escape') { - event.preventDefault() - event.stopPropagation() - // Capture mode, first Esc frees the cursor (see handlePointerLockChange - // — while locked the browser usually unlocks without delivering the - // keydown); with the cursor already free, Esc cancels the snapshot - // (setCaptureMode(false) also lands the camera back on orbit). - if (useEditor.getState().isCaptureMode) { - if (document.pointerLockElement === canvas) { - suspendRef.current = true - useViewer.getState().setWalkthroughSuspended(true) - document.exitPointerLock() - } else { - useEditor.getState().setCaptureMode(false) - } - return - } - if (document.pointerLockElement === canvas) { - document.exitPointerLock() - } - useEditor.getState().setFirstPersonMode(false) - } else if (event.code === 'KeyE' || event.code === 'KeyR') { - event.preventDefault() - event.stopPropagation() - toggleInteractableTarget() - } else if (event.code === 'KeyT') { - event.preventDefault() - event.stopPropagation() - closeInteractableTarget() - } else if (event.code === 'KeyP') { - // P toggles a cursor pause (advertised in the HUD): frees the pointer - // without leaving first person — e.g. for an OS screenshot, which - // needs a movable cursor — and click or P resumes. - event.preventDefault() - event.stopPropagation() - if (document.pointerLockElement === canvas) { - suspendRef.current = true - useViewer.getState().setWalkthroughSuspended(true) - document.exitPointerLock() - } else if (suspendRef.current) { - const result = canvas.requestPointerLock?.() as Promise | undefined - if (result && typeof result.catch === 'function') result.catch(() => {}) - } - } - } - - const handleKeyUp = (event: KeyboardEvent) => { - if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { - crouchKeyRef.current = false - } - if (event.code === 'KeyQ' && !suspendRef.current) { - droneDescendKeyRef.current = false - } - if (event.code === 'KeyE' && !suspendRef.current) { - droneAscendKeyRef.current = false - } - if ((event.code === 'AltLeft' || event.code === 'AltRight') && !suspendRef.current) { - droneSlowKeyRef.current = false - } - applyMovementKey(event, false) - } - - const handleBlur = () => { - if (!suspendRef.current) { - crouchKeyRef.current = false - droneAscendKeyRef.current = false - droneDescendKeyRef.current = false - droneSlowKeyRef.current = false - } - } - - document.addEventListener('keydown', handleKeyDown, true) - document.addEventListener('keyup', handleKeyUp, true) - window.addEventListener('blur', handleBlur) - return () => { - document.removeEventListener('keydown', handleKeyDown, true) - document.removeEventListener('keyup', handleKeyUp, true) - window.removeEventListener('blur', handleBlur) - } - }, [closeInteractableTarget, gl, isDroneMode, toggleInteractableTarget]) - - const syncElevatorColliderMeshes = useCallback(() => { - const nodes = useScene.getState().nodes - const interactive = useInteractive.getState() - - for (const mesh of elevatorColliderMeshesRef.current) { - const { doorWidth, dynamic, elevatorId, kind, levelId, localPosition, side } = mesh.userData - const node = nodes[elevatorId] - const runtime = interactive.elevators[elevatorId] - const object = sceneRegistry.nodes.get(elevatorId) - if (!(node?.type === 'elevator' && object && node.visible !== false)) { - mesh.visible = false - continue - } - - if (!dynamic && mesh.userData.matrixInitialized && mesh.visible) { - continue - } - - let [localX, localY, localZ] = localPosition - const isCabCollider = kind.startsWith('cab-') - const isDoorCollider = kind === 'landing-door-left' || kind === 'landing-door-right' - const isCabDoorGate = kind === 'cab-door-gate' - const isLandingDoorGate = kind === 'landing-door-gate' - - if (isCabCollider) { - localY += runtime?.carY ?? 0 - } - - if (kind === 'cab-door-left' || kind === 'cab-door-right') { - if (!runtime) { - mesh.visible = false - continue - } - localX = getElevatorDoorLeafX( - side ?? 'left', - doorWidth ?? node.doorWidth, - runtime.doorOpen, - node.doorStyle, - ) - mesh.visible = true - } else if (isCabDoorGate) { - if (!runtime) { - mesh.visible = false - continue - } - mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD - } else if (isDoorCollider) { - const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0 - localX = getElevatorDoorLeafX( - side ?? 'left', - doorWidth ?? node.doorWidth, - doorOpen, - node.doorStyle, - ) - mesh.visible = true - } else if (isLandingDoorGate) { - const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0 - mesh.visible = doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD - } else { - mesh.visible = true - } - - object.updateWorldMatrix(true, false) - elevatorColliderMatrix.copy(object.matrixWorld) - elevatorColliderMatrix.multiply( - elevatorColliderLocalMatrix.makeTranslation(localX, localY, localZ), - ) - mesh.matrix.copy(elevatorColliderMatrix) - mesh.matrixWorld.copy(elevatorColliderMatrix) - mesh.userData.matrixInitialized = true - } - }, []) - - useFrame(() => { - if (isDroneMode) return - syncElevatorColliderMeshes() - }, -1) - - const syncElevatorRide = useCallback( - (group: Group) => { - const nodes = useScene.getState().nodes - const interactive = useInteractive.getState() - const activeRide = ridingElevatorRef.current - let nextRide: { - cabHeight: number - cabCenterZ: number - carY: number - doorOpen: number - elevatorId: AnyNodeId - halfDepth: number - halfWidth: number - object: Object3D - phase: NonNullable<(typeof interactive.elevators)[AnyNodeId]>['phase'] - } | null = null - - const elevatorIds = activeRide - ? [ - activeRide.elevatorId, - ...Array.from(sceneRegistry.byType.elevator!).filter( - (elevatorId) => elevatorId !== activeRide.elevatorId, - ), - ] - : Array.from(sceneRegistry.byType.elevator!) - - for (const elevatorId of elevatorIds) { - const typedElevatorId = elevatorId as AnyNodeId - const node = nodes[typedElevatorId] - if (node?.type !== 'elevator') continue - - const runtime = interactive.elevators[typedElevatorId] - const object = sceneRegistry.nodes.get(typedElevatorId) - if (!(runtime && object)) continue - - object.updateWorldMatrix(true, true) - elevatorLocalEyePosition.copy(camera.position) - object.worldToLocal(elevatorLocalEyePosition) - - const halfWidth = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING - const halfDepth = getElevatorCabDepth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING - const cabCenterZ = getElevatorCabCenterZ(node) - const cabHeight = Math.max(node.cabHeight, 1.4) - const insideFootprint = - Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) && - Math.abs(elevatorLocalEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) - const insideCabHeight = - elevatorLocalEyePosition.y >= runtime.carY + 0.35 && - elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7 - const continuingRide = - activeRide?.elevatorId === typedElevatorId && - insideFootprint && - elevatorLocalEyePosition.y >= runtime.carY - 0.2 && - elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 1.25 - - if ((insideFootprint && insideCabHeight) || continuingRide) { - nextRide = { - cabHeight, - cabCenterZ, - carY: runtime.carY, - doorOpen: runtime.doorOpen, - elevatorId: typedElevatorId, - halfDepth: Math.max(halfDepth, 0.24), - halfWidth: Math.max(halfWidth, 0.24), - object, - phase: runtime.phase, - } - break - } - } - - if (!nextRide) { - ridingElevatorRef.current = null - setElevatorRideLocked(false) - return - } - - const previousCarY = - activeRide?.elevatorId === nextRide.elevatorId ? activeRide.previousCarY : nextRide.carY - const deltaY = nextRide.carY - previousCarY - nextRide.object.updateWorldMatrix(true, true) - elevatorLocalControllerPosition.copy(group.position) - nextRide.object.worldToLocal(elevatorLocalControllerPosition) - const localControllerY = - activeRide?.elevatorId === nextRide.elevatorId && activeRide.localControllerY !== null - ? activeRide.localControllerY - : elevatorLocalControllerPosition.y - nextRide.carY - - if (Math.abs(deltaY) > 0.0001) { - group.position.y += deltaY - controllerRef.current?.resetLinVel() - } - - const shouldLockToCab = - nextRide.phase === 'closing' || - nextRide.phase === 'moving' || - (nextRide.phase === 'opening' && nextRide.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD) - if (shouldLockToCab) { - elevatorLocalControllerPosition.copy(group.position) - nextRide.object.worldToLocal(elevatorLocalControllerPosition) - const desiredLocalY = nextRide.carY + localControllerY - if (Math.abs(elevatorLocalControllerPosition.y - desiredLocalY) > 0.002) { - elevatorLocalControllerPosition.y = desiredLocalY - elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition) - nextRide.object.localToWorld(elevatorWorldControllerPosition) - group.position.y = elevatorWorldControllerPosition.y - controllerRef.current?.resetLinVel() - elevatorLocalControllerPosition.copy(group.position) - nextRide.object.worldToLocal(elevatorLocalControllerPosition) - } - - const clampedX = Math.max( - -nextRide.halfWidth, - Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x), - ) - const clampedZ = Math.max( - nextRide.cabCenterZ - nextRide.halfDepth, - Math.min(nextRide.cabCenterZ + nextRide.halfDepth, elevatorLocalControllerPosition.z), - ) - - if ( - Math.abs(clampedX - elevatorLocalControllerPosition.x) > 0.0001 || - Math.abs(clampedZ - elevatorLocalControllerPosition.z) > 0.0001 - ) { - elevatorLocalControllerPosition.x = clampedX - elevatorLocalControllerPosition.z = clampedZ - elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition) - nextRide.object.localToWorld(elevatorWorldControllerPosition) - group.position.x = elevatorWorldControllerPosition.x - group.position.z = elevatorWorldControllerPosition.z - controllerRef.current?.resetLinVel() - } - } - - setElevatorRideLocked(shouldLockToCab) - - ridingElevatorRef.current = { - elevatorId: nextRide.elevatorId, - localControllerY, - previousCarY: nextRide.carY, - } - }, - [camera, setElevatorRideLocked], - ) - - const hasStandingClearance = useCallback((position: Vector3) => { - standClearanceRaycaster.set(position, standClearanceUp) - standClearanceRaycaster.far = STAND_CLEARANCE - const meshes: Mesh[] = [] - if (worldRef.current) meshes.push(worldRef.current.mesh) - for (const mesh of elevatorColliderMeshesRef.current) { - if (mesh.visible) meshes.push(mesh) - } - return standClearanceRaycaster.intersectObjects(meshes, false).length === 0 - }, []) - - // Drone: the viewer's free flight (`stepDroneFlight`, also behind the viewer's - // `DroneController`) fed from this controller's keys — WASD along the view - // axes, Space or E rises, Q (or Ctrl) sinks, Shift boosts, Alt slows. - useFrame((_, delta) => { - if (!isDroneMode) return - // Shutter hold: freeze the drone mid-air while the shot renders. - if (useEditor.getState().captureShutterHold) return - - const movement = movementInputRef.current - droneInput.forward = movement.forward === true - droneInput.backward = movement.backward === true - droneInput.leftward = movement.leftward === true - droneInput.rightward = movement.rightward === true - droneInput.up = movement.jump === true || droneAscendKeyRef.current - droneInput.down = droneDescendKeyRef.current || crouchKeyRef.current - droneInput.boost = movement.run === true - droneInput.slow = droneSlowKeyRef.current - stepDroneFlight(camera, lookRef.current, droneInput, droneVelocityRef.current, delta) - }, 2.5) - - useFrame((_, delta) => { - if (isDroneMode) return - if (!controllerRef.current?.group) return - - const group = controllerRef.current.group - - // Crouch follows the held key; standing back up waits for headroom. - // Frozen while the cursor pause is active. - if (!suspendRef.current && crouchKeyRef.current !== crouched) { - if (crouchKeyRef.current) setCrouched(true) - else if (hasStandingClearance(group.position)) setCrouched(false) - } - const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET - eyeOffsetRef.current += - (targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED) - cameraOffset.set(0, eyeOffsetRef.current, 0) - - // The site ground collider is effectively unbounded, but scenes without a - // site node only have finite fallback floors — if the controller still ends - // up below every collider it can never land, so put it back at the spawn. - // Prefer the live spawn node over the mount-time start position so a spawn - // moved mid-walkthrough doesn't respawn the player at stale coordinates. - const worldBounds = worldRef.current?.bounds - if (worldBounds && group.position.y < worldBounds.min.y - VOID_FALL_RESPAWN_DEPTH) { - const respawnPosition = placedSpawn - ? [ - placedSpawn.position[0], - placedSpawn.position[1] - CONTROLLER_CENTER_FROM_EYE, - placedSpawn.position[2], - ] - : controllerStart?.position - if (respawnPosition) { - group.position.set(respawnPosition[0]!, respawnPosition[1]!, respawnPosition[2]!) - controllerRef.current.resetLinVel() - ridingElevatorRef.current = null - setElevatorRideLocked(false) - } - } - - group.rotation.y = 0 - camera.position.copy(group.position).add(cameraOffset) - cameraEuler.set(lookRef.current.pitch, lookRef.current.yaw, 0, 'YXZ') - camera.quaternion.setFromEuler(cameraEuler) - camera.updateMatrixWorld(true) - syncElevatorRide(group) - camera.position.copy(group.position).add(cameraOffset) - camera.updateMatrixWorld(true) - - const nextInteractableTarget = resolveInteractableTarget() - const previousInteractableTarget = interactableTargetRef.current - if ( - getInteractableTargetKey(previousInteractableTarget) !== - getInteractableTargetKey(nextInteractableTarget) - ) { - interactableTargetRef.current = nextInteractableTarget - useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null) - } - - useFirstPersonHud.getState().setHud({ - interact: resolveHudInteract(nextInteractableTarget), - }) - - hudLabelFrameRef.current += 1 - if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) { - hudLabelFrameRef.current = 0 - camera.getWorldPosition(hudWorldEyePosition) - useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition)) - } - }, 2.5) - - useEffect(() => { - return () => { - if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) { - useViewer.getState().setHoveredId(null) - } - } - }, []) - - const firstPersonColliderMeshes = useMemo( - () => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes), - [world, elevatorColliderMeshes], - ) - - if (isDroneMode || !world) { - return null - } - return ( - <> - {controllerStart && ( - - - - )} - + ) } diff --git a/packages/editor/src/components/editor/floor-region-controls.tsx b/packages/editor/src/components/editor/floor-region-controls.tsx index f698c5edc4..31ffc4f1c9 100644 --- a/packages/editor/src/components/editor/floor-region-controls.tsx +++ b/packages/editor/src/components/editor/floor-region-controls.tsx @@ -10,7 +10,12 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { getSceneTheme, setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer' +import { + getSceneTheme, + isTypingTarget, + setSurfaceRaycastLayers, + useViewer, +} from '@pascal-app/viewer' import { useFrame, useThree } from '@react-three/fiber' import { Pentagon, Square } from 'lucide-react' import { useEffect, useMemo, useRef } from 'react' @@ -74,7 +79,6 @@ import { toggleTerraceShape, useTerraceDraft, } from '../../lib/terrace-draft' -import { isTypingTarget } from '../../lib/typing-target' import useEditor from '../../store/use-editor' import { useFloorplanRender } from '../editor-2d/floorplan-render-context' import { usePaintTint } from '../tools/paint-region/use-paint-tint' diff --git a/packages/editor/src/components/editor/floorplan-wall-typing-keys.ts b/packages/editor/src/components/editor/floorplan-wall-typing-keys.ts index 63eae1c374..321fe3a3df 100644 --- a/packages/editor/src/components/editor/floorplan-wall-typing-keys.ts +++ b/packages/editor/src/components/editor/floorplan-wall-typing-keys.ts @@ -1,6 +1,6 @@ +import { isTypingTarget } from '@pascal-app/viewer' import { markToolCancelConsumed } from '../../hooks/use-keyboard' import type { ParseMeasurementOptions } from '../../lib/measurement-parser' -import { isTypingTarget } from '../../lib/typing-target' import useEditor from '../../store/use-editor' import { PLACEMENT_TYPING_LENGTH_ANGLE, diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index 3f3200f575..7ddcf6a7a9 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -14,7 +14,7 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { markPerfAction, useViewer } from '@pascal-app/viewer' +import { isTypingTarget, markPerfAction, useViewer } from '@pascal-app/viewer' import { Plane, Vector2, Vector3 } from 'three' import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help' import { clientToPlan } from '../../lib/floorplan/plan-coords' @@ -28,7 +28,6 @@ import { pasteSystemEditorClipboardToLevel, } from '../../lib/scene-clipboard' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' -import { isTypingTarget } from '../../lib/typing-target' import useAlignmentGuides from '../../store/use-alignment-guides' import useDeleteConfirmation from '../../store/use-delete-confirmation' import useEditor, { diff --git a/packages/editor/src/components/editor/group-move-3d.ts b/packages/editor/src/components/editor/group-move-3d.ts index 81e5da6002..4ed1e49917 100644 --- a/packages/editor/src/components/editor/group-move-3d.ts +++ b/packages/editor/src/components/editor/group-move-3d.ts @@ -12,13 +12,12 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { isTypingTarget, useViewer } from '@pascal-app/viewer' import { type Camera, Plane, type Raycaster, Vector2, Vector3 } from 'three' import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help' import { isHistoryShortcut } from '../../lib/history' import type { WallGroupPush } from '../../lib/room-handle-drag' import { sfxEmitter } from '../../lib/sfx-bus' -import { isTypingTarget } from '../../lib/typing-target' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor, { isAlignmentGuideActive, diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index 0d2e55046a..bf798c0745 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -10,7 +10,7 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { isTypingTarget, useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { OrthographicCamera, Plane, type Ray, Vector2, Vector3 } from 'three' @@ -19,7 +19,6 @@ import { isHistoryShortcut } from '../../lib/history' import { sfxEmitter } from '../../lib/sfx-bus' import { intersectSpatialDragPlane } from '../../lib/spatial-drag-plane' import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input' -import { isTypingTarget } from '../../lib/typing-target' import useEditor, { isAngleSnapActive } from '../../store/use-editor' import useInteractionScope, { useActiveHandleDrag, diff --git a/packages/editor/src/components/editor/handles/use-handle-drag.ts b/packages/editor/src/components/editor/handles/use-handle-drag.ts index 0e1995e198..316a9090f8 100644 --- a/packages/editor/src/components/editor/handles/use-handle-drag.ts +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -11,7 +11,7 @@ import { useLiveNodeOverrides, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { isTypingTarget, useViewer } from '@pascal-app/viewer' import { type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { @@ -28,7 +28,6 @@ import { isHistoryShortcut } from '../../../lib/history' import { sfxEmitter } from '../../../lib/sfx-bus' import { intersectSpatialDragPlane } from '../../../lib/spatial-drag-plane' import { getSpatialPointerId, spatialPointerInput } from '../../../lib/spatial-pointer-input' -import { isTypingTarget } from '../../../lib/typing-target' import useInteractionScope from '../../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { commitHandleDragPatch } from './handle-drag-history' diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index e07da79d44..8353b41948 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -3,6 +3,7 @@ import { Icon } from '@iconify/react' import { acquireSceneReadOnlyLease, + computeSceneBoundsXZ, createI18n, emitter, getCatalogMaterialById, @@ -86,7 +87,6 @@ import { type SceneGraph, writePersistedSelection, } from '../../lib/scene' -import { computeSceneBoundsXZ } from '../../lib/scene-bounds' import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus' import { useUnitFocusRules } from '../../lib/units' import { cn } from '../../lib/utils' diff --git a/packages/editor/src/components/editor/room-controls.tsx b/packages/editor/src/components/editor/room-controls.tsx index d361f9cc00..750de1fd08 100644 --- a/packages/editor/src/components/editor/room-controls.tsx +++ b/packages/editor/src/components/editor/room-controls.tsx @@ -13,7 +13,12 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { getSceneTheme, setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer' +import { + getSceneTheme, + isTypingTarget, + setSurfaceRaycastLayers, + useViewer, +} from '@pascal-app/viewer' import { LocalizedHtml as Html, type RoomSelectionRecord } from '@pascal-app/viewer/ui' import { useFrame, useThree } from '@react-three/fiber' import { SquareSplitHorizontal } from 'lucide-react' @@ -57,7 +62,6 @@ import { startRoomTransform, useRoomTransform, } from '../../lib/room-transform-session' -import { isTypingTarget } from '../../lib/typing-target' import useEditor from '../../store/use-editor' import useInteractionScope from '../../store/use-interaction-scope' import { isViewVisible, VIEW_2D } from '../../store/view-layout' diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index 27441eb07d..95b30d1f08 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -1,7 +1,7 @@ 'use client' import { emitter, type SnapshotSavedEvent } from '@pascal-app/core' -import { SNAPSHOT_MAX_EDGE } from '@pascal-app/viewer' +import { isTypingTarget, SNAPSHOT_MAX_EDGE } from '@pascal-app/viewer' import { useFirstPersonHud, useIsMobile, WalkthroughCrosshair } from '@pascal-app/viewer/ui' import { Check, @@ -18,7 +18,6 @@ import { import { useCallback, useEffect, useRef, useState } from 'react' import { flushSync } from 'react-dom' import { triggerSFX } from '../../lib/sfx-bus' -import { isTypingTarget } from '../../lib/typing-target' import { requestWalkthroughPointerLock } from '../../lib/walkthrough-pointer-lock' import useEditor, { CAPTURE_FOV_MAX, diff --git a/packages/editor/src/components/systems/roof/roof-edit-system.tsx b/packages/editor/src/components/systems/roof/roof-edit-system.tsx index bbf8f4da42..b7209e7ae9 100644 --- a/packages/editor/src/components/systems/roof/roof-edit-system.tsx +++ b/packages/editor/src/components/systems/roof/roof-edit-system.tsx @@ -22,6 +22,7 @@ import { csgEvaluator, generateRoofSegmentGeometry, INTERSECTION, + isTypingTarget, markPureRaycast, prepareBrushForCSG, useViewer, @@ -34,7 +35,6 @@ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' import { isHistoryShortcut } from '../../../lib/history' import { getHoveredRoofSegmentOutlineProxyName } from '../../../lib/roof-hover-outline-proxy' -import { isTypingTarget } from '../../../lib/typing-target' import useInteractionScope, { useMovingNode } from '../../../store/use-interaction-scope' import { swallowNextClick } from '../../editor/handles/use-handle-drag' 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 847f1308fd..14d2a587e6 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -30,7 +30,7 @@ import { useSpatialQuery, type WallEvent, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { isTypingTarget, useViewer } from '@pascal-app/viewer' import { LocalizedHtml as Html } from '@pascal-app/viewer/ui' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -59,7 +59,6 @@ import { surfaceFramePose, updateSurfaceNode as writeSurfaceNode, } from '../../../lib/surface-attachment' -import { isTypingTarget } from '../../../lib/typing-target' import { createWallPointerTracker } from '../../../lib/wall-placement-pointer' import { projectAlignmentGuidesWorldToActiveBuildingLocal, diff --git a/packages/editor/src/components/tools/paint-region/wall-paint-region-tool.tsx b/packages/editor/src/components/tools/paint-region/wall-paint-region-tool.tsx index 84cf99c4fb..0398c0dfd8 100644 --- a/packages/editor/src/components/tools/paint-region/wall-paint-region-tool.tsx +++ b/packages/editor/src/components/tools/paint-region/wall-paint-region-tool.tsx @@ -10,7 +10,12 @@ import { type WallFace, type WallNode, } from '@pascal-app/core' -import { getSceneTheme, setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer' +import { + getSceneTheme, + isTypingTarget, + setSurfaceRaycastLayers, + useViewer, +} from '@pascal-app/viewer' import { useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import { @@ -29,7 +34,6 @@ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { paintRegionTargets, usePaintRegionMode } from '../../../lib/paint-region-mode' -import { isTypingTarget } from '../../../lib/typing-target' import { activeWallRegionGesture, activeWallRegionTarget, diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index fa8c28555f..51e6c8cfe8 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -38,7 +38,7 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { isTypingTarget, useViewer } from '@pascal-app/viewer' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { type Group, Vector3 } from 'three' @@ -61,7 +61,6 @@ import { surfaceAttachmentUpdates, updateSurfaceNode, } from '../../../lib/surface-attachment' -import { isTypingTarget } from '../../../lib/typing-target' import useAlignmentGuides from '../../../store/use-alignment-guides' import useEditor, { getActiveSnappingMode, diff --git a/packages/editor/src/hooks/use-auto-frame.ts b/packages/editor/src/hooks/use-auto-frame.ts index 6b20238fb9..a7f19a6226 100644 --- a/packages/editor/src/hooks/use-auto-frame.ts +++ b/packages/editor/src/hooks/use-auto-frame.ts @@ -1,8 +1,7 @@ 'use client' -import { emitter, useScene } from '@pascal-app/core' +import { computeSceneBoundsXZ, emitter, useScene } from '@pascal-app/core' import { useEffect, useRef } from 'react' -import { computeSceneBoundsXZ } from '../lib/scene-bounds' /** * Auto-frame the camera onto a freshly loaded scene. diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 4db6f8fc2f..6362f5939c 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -9,7 +9,7 @@ import { toggleNodeMechanism, useScene, } from '@pascal-app/core' -import { cancelPerfAction, markPerfAction, useViewer } from '@pascal-app/viewer' +import { cancelPerfAction, isTypingTarget, markPerfAction, useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' import { cutSelectionToEditorClipboard, @@ -40,7 +40,6 @@ import { sfxEmitter } from '../lib/sfx-bus' import { openSidebarPanel } from '../lib/sidebar-panel' import { activeSiteNode, clampBrushRadius } from '../lib/terrain-sculpt' import { armToolShortcut, toolShortcutForKey } from '../lib/tool-shortcuts' -import { isTypingTarget } from '../lib/typing-target' import { leaveUnitFocus } from '../lib/units' import { subscribeWallKindReset } from '../lib/wall-draw-variant' import { isWallNudgeKey, nudgeSelectedWall } from '../lib/wall-nudge' diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 304d442050..1a0f0a66bd 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -126,6 +126,7 @@ export { withFloorplanGeometryMetadata, } from '@pascal-app/core' export { + isTypingTarget, MEASUREMENT_ACTIVE_COLOR, MEASUREMENT_DANGLING_COLOR, MEASUREMENT_FLOORPLAN_COLOR, @@ -966,7 +967,6 @@ export { terrainPointInsideSite, } from './lib/terrain-sculpt' export { withToolShortcut } from './lib/tool-shortcuts' -export { isTypingTarget } from './lib/typing-target' export { enterUnitFocus } from './lib/units' export { exportSceneToUsdz, type UsdzExportOptions } from './lib/usdz-export' export { useLinearDisplay } from './lib/use-linear-display' diff --git a/packages/editor/src/lib/paint-region-mode.ts b/packages/editor/src/lib/paint-region-mode.ts index 4df54e5a34..8a8cdc4b65 100644 --- a/packages/editor/src/lib/paint-region-mode.ts +++ b/packages/editor/src/lib/paint-region-mode.ts @@ -1,6 +1,6 @@ +import { isTypingTarget } from '@pascal-app/viewer' import { create } from 'zustand' import type { ActivePaintMaterial } from './material-paint' -import { isTypingTarget } from './typing-target' /** * What a paint click does: paint the whole surface under it, draw part of a diff --git a/packages/editor/src/lib/room-handle-drag.ts b/packages/editor/src/lib/room-handle-drag.ts index 391c44dda7..8652bad8b3 100644 --- a/packages/editor/src/lib/room-handle-drag.ts +++ b/packages/editor/src/lib/room-handle-drag.ts @@ -36,7 +36,7 @@ import { type WallNode, type WallPushArrowPayload, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { isTypingTarget, useViewer } from '@pascal-app/viewer' import type { Ray } from 'three' import { create } from 'zustand' import { ARROW_COLOR } from '../components/editor/handles/handle-arrow' @@ -65,7 +65,6 @@ import { applyRoomPlan } from './room-structure-commands' import { levelSeparatorLines, separatorWallLine } from './separator-lines' import { sfxEmitter } from './sfx-bus' import { type SpatialPointerId, spatialPointerInput } from './spatial-pointer-input' -import { isTypingTarget } from './typing-target' import { nearestWallParallelTarget, publishWallParallelGuide, diff --git a/packages/editor/src/lib/room-transform-session.ts b/packages/editor/src/lib/room-transform-session.ts index 6172deceb5..56e0ee5134 100644 --- a/packages/editor/src/lib/room-transform-session.ts +++ b/packages/editor/src/lib/room-transform-session.ts @@ -10,7 +10,7 @@ import { useScene, type ZoneTransformPlan, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { isTypingTarget, useViewer } from '@pascal-app/viewer' import { Plane, Vector2, Vector3 } from 'three' import { create } from 'zustand' import { levelFrame } from '../components/editor/group-transform-shared' @@ -23,7 +23,6 @@ import { beginGesture, type GestureHandle } from './gesture-lifecycle' import { isHistoryShortcut } from './history' import { mezzanineConflictMessage } from './mezzanine-messages' import { sfxEmitter } from './sfx-bus' -import { isTypingTarget } from './typing-target' export const ROOM_MOVE_DRAG_LABEL = 'room-move' diff --git a/packages/editor/src/store/use-wall-region-handles-selection.ts b/packages/editor/src/store/use-wall-region-handles-selection.ts index 2f88edd66d..46202bc9d2 100644 --- a/packages/editor/src/store/use-wall-region-handles-selection.ts +++ b/packages/editor/src/store/use-wall-region-handles-selection.ts @@ -1,10 +1,9 @@ import { type AnyNodeId, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { isTypingTarget, useViewer } from '@pascal-app/viewer' import { create } from 'zustand' import { hasLiveGestures } from '../lib/gesture-lifecycle' import { removeWallRegion } from '../lib/paint-regions' import { sfxEmitter } from '../lib/sfx-bus' -import { isTypingTarget } from '../lib/typing-target' import useDeleteConfirmation from './use-delete-confirmation' import useInteractionScope from './use-interaction-scope' diff --git a/packages/viewer/README.md b/packages/viewer/README.md index 2b1be88bf1..0fff73b43d 100644 --- a/packages/viewer/README.md +++ b/packages/viewer/README.md @@ -86,6 +86,59 @@ rendering. See [`fixtures/viewer-live`](../../fixtures/viewer-live) for a complete small graph and the isolated packed-package dev/production smoke test. +## Walkthrough for live scenes + +`LiveWalkthroughController` is a 3D controller from the main `@pascal-app/viewer` entry. +Mount it inside `Viewer` alongside your live graph rendered with `renderPlugin`. It is dormant +until `useViewer.walkthroughMode` is true. `walkthroughMovement` chooses `walk` (gravity, +BVH collision, crouch, jump and door/window/item/elevator interaction) or `drone` (free flight). +No editor state, editing plugin or baked asset is required. + +```tsx +import { LiveWalkthroughController, useFirstPersonHud, useViewer, Viewer } from '@pascal-app/viewer' +import { WalkthroughHud } from '@pascal-app/viewer/ui' +import { OrbitControls } from '@react-three/drei' + +function LiveViewer() { + const walking = useViewer(s => s.walkthroughMode) + const suspended = useViewer(s => s.walkthroughSuspended) + const floorLabel = useFirstPersonHud(s => s.floorLabel) + const zoneLabel = useFirstPersonHud(s => s.zoneLabel) + const interact = useFirstPersonHud(s => s.interact) + const exit = () => useViewer.getState().setWalkthroughMode(false) + return ( +
+ + + + + + {walking && } +
+ ) +} +``` + +Use the UI styling setup below for the HUD. Click the canvas to lock the pointer; WASD/arrows move, +Space jumps, Shift runs, Ctrl crouches, E/R or a locked-pointer click operates the targeted object, +and T closes it. P frees the cursor and click/P resumes. Escape or losing pointer lock exits. +A placed spawn controls entry position and heading; otherwise the controller derives a grounded +spawn from the camera and live collider world. Disable other camera controls while walking. + +The controller publishes floor, room and interaction prompts to `useFirstPersonHud`. Its main and +`viewer/ui` exports reference the same store. Mount the HUD outside the canvas and pass those +values explicitly. Door and drawer cues emit core `interaction:sound`; opt into playback with +`initInteractionSounds` from `viewer/sfx`. Movement and interaction do not persist document edits. + +Optional `LiveWalkthroughControllerProps` let a host own framing: `paused` freezes movement/look, +`externalFov` leaves FOV application/restoration to the host, `pointerInteraction={false}` reserves +locked-pointer clicks for the host, and `suspendOnUnlock` keeps walkthrough active when pointer lock +is lost (Escape with a free cursor exits). `onExit` runs after the controller clears +`walkthroughMode`. Props remain current during a session without releasing pointer lock. +For baked scenes, continue using `GlbWalkthroughController`; `DroneController` also works with +either scene source when only free flight is needed. + ## Custom Camera Controls ```typescript @@ -296,7 +349,7 @@ themselves with a `dark` class. Control icons load from `configureViewer({ icons `https://editor.pascal.app/icons`); button sounds play through `@pascal-app/viewer/sfx`. ```tsx -import { useViewer, Viewer } from '@pascal-app/viewer' +import { LiveWalkthroughController, useViewer, Viewer } from '@pascal-app/viewer' import { ParametricViewerRooms, SceneLoader, @@ -314,6 +367,7 @@ function ProjectViewer({ ready, name }: { ready: boolean; name: string }) {
+ @@ -341,8 +395,9 @@ level in the viewer. Room names show as the plan's drawn tags. Control the mode `linkComponent` renders the header's links (a plain `` by default); pass your router's link, e.g. Next's `Link`. `ParametricViewerRooms` is `ViewerRooms` over the live scene store; a host with another node source (a baked scene) passes ``. While -mounted, zones draw no tinted volume: a room shows through its pill and highlight instead. The walkthrough toggle only flips `walkthroughMode`; mount your own -first-person controls inside `` to move the camera. +mounted, zones draw no tinted volume: a room shows through its pill and highlight instead. The walkthrough toggle flips `walkthroughMode`; the mounted +`LiveWalkthroughController` moves the camera. Read `useFirstPersonHud` for the HUD's room and +interaction props, as in the live walkthrough example above. ## Asset CDN Helpers diff --git a/packages/viewer/src/components/viewer/drone-controller.tsx b/packages/viewer/src/components/viewer/drone-controller.tsx index 0600b05902..d68d9b8254 100644 --- a/packages/viewer/src/components/viewer/drone-controller.tsx +++ b/packages/viewer/src/components/viewer/drone-controller.tsx @@ -13,18 +13,11 @@ import { stepDroneFlight, turnFirstPersonLook, } from '../../lib/drone-flight' +import { isTypingTarget } from '../../lib/typing-target' import useViewer from '../../store/use-viewer' import { WALKTHROUGH_FOV } from './glb-walkthrough-controller' import { applyWalkthroughCameraClipping } from './viewer-camera' -const TYPING_TARGET = - 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="combobox"], [role="textbox"], [role="dialog"], [role="alertdialog"]' - -function isTypingTarget(target: EventTarget | null) { - const element = target as { closest?: (selector: string) => unknown; isContentEditable?: boolean } - return !!element && (element.isContentEditable === true || !!element.closest?.(TYPING_TARGET)) -} - function requestLock(canvas: HTMLCanvasElement) { // The request can reject asynchronously (the browser's re-lock cooldown); // clicking the canvas asks again. diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts b/packages/viewer/src/components/viewer/first-person/build-collider-world.test.ts similarity index 100% rename from packages/editor/src/components/editor/first-person/build-collider-world.test.ts rename to packages/viewer/src/components/viewer/first-person/build-collider-world.test.ts diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/viewer/src/components/viewer/first-person/build-collider-world.ts similarity index 99% rename from packages/editor/src/components/editor/first-person/build-collider-world.ts rename to packages/viewer/src/components/viewer/first-person/build-collider-world.ts index 2de7b8b19a..27b6e08f68 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/viewer/src/components/viewer/first-person/build-collider-world.ts @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + computeSceneBoundsXZ, coveredBelowGradeFootprints, type DoorNode, getGarageVisibleOpeningRatio, @@ -16,7 +17,6 @@ import { import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' -import { computeSceneBoundsXZ } from '../../../lib/scene-bounds' import { cutGroundCollider } from './ground-holes' import { createTerrainColliderGeometry } from './terrain-collider' diff --git a/packages/editor/src/components/editor/first-person/ground-holes.test.ts b/packages/viewer/src/components/viewer/first-person/ground-holes.test.ts similarity index 100% rename from packages/editor/src/components/editor/first-person/ground-holes.test.ts rename to packages/viewer/src/components/viewer/first-person/ground-holes.test.ts diff --git a/packages/editor/src/components/editor/first-person/ground-holes.ts b/packages/viewer/src/components/viewer/first-person/ground-holes.ts similarity index 100% rename from packages/editor/src/components/editor/first-person/ground-holes.ts rename to packages/viewer/src/components/viewer/first-person/ground-holes.ts diff --git a/packages/editor/src/components/editor/first-person/mechanism-targets.test.ts b/packages/viewer/src/components/viewer/first-person/mechanism-targets.test.ts similarity index 100% rename from packages/editor/src/components/editor/first-person/mechanism-targets.test.ts rename to packages/viewer/src/components/viewer/first-person/mechanism-targets.test.ts diff --git a/packages/editor/src/components/editor/first-person/mechanism-targets.ts b/packages/viewer/src/components/viewer/first-person/mechanism-targets.ts similarity index 97% rename from packages/editor/src/components/editor/first-person/mechanism-targets.ts rename to packages/viewer/src/components/viewer/first-person/mechanism-targets.ts index cbe6eef1bb..e2785917d1 100644 --- a/packages/editor/src/components/editor/first-person/mechanism-targets.ts +++ b/packages/viewer/src/components/viewer/first-person/mechanism-targets.ts @@ -8,7 +8,7 @@ import { useInteractive, useScene, } from '@pascal-app/core' -import type { WalkthroughInteract } from '@pascal-app/viewer/ui' +import type { WalkthroughInteract } from '../../../store/use-first-person-hud' /** * Whether the walkthrough swings this door or window itself, through the native `operationState` diff --git a/packages/editor/src/components/editor/first-person/terrain-collider.test.ts b/packages/viewer/src/components/viewer/first-person/terrain-collider.test.ts similarity index 100% rename from packages/editor/src/components/editor/first-person/terrain-collider.test.ts rename to packages/viewer/src/components/viewer/first-person/terrain-collider.test.ts diff --git a/packages/editor/src/components/editor/first-person/terrain-collider.ts b/packages/viewer/src/components/viewer/first-person/terrain-collider.ts similarity index 100% rename from packages/editor/src/components/editor/first-person/terrain-collider.ts rename to packages/viewer/src/components/viewer/first-person/terrain-collider.ts diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx index 63bfb77564..67ac18d953 100644 --- a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -34,8 +34,7 @@ import { applyWalkthroughCameraClipping } from './viewer-camera' // walkthrough controllers read this and restore the orbit FOV on exit. export const WALKTHROUGH_FOV = 60 -// Eye/capsule geometry mirrors the editor's first-person controller so the -// baked walkthrough feels identical. The capsule centre sits below the eye; the +// Shared with the live walkthrough so baked and parametric scenes feel identical. The capsule centre sits below the eye; the // camera rides the capsule with a small offset and the controller floats it to // the ground. const CAMERA_EYE_OFFSET = 0.45 @@ -145,7 +144,7 @@ function boxFloorGeometry( // Fallback ground so the player never falls into the void: a single large // ground plane at the lowest level (level 0). Upper levels rely on their own // baked slabs — a slab-less upper floor lets you fall down to the ground, which -// the ground plane catches. (Mirrors the editor walkthrough's site ground.) +// the ground plane catches. (Mirrors the live walkthrough's site ground.) function addFallbackFloors(scene: Object3D, geometries: BufferGeometry[]) { const sceneBounds = new Box3() for (const geometry of geometries) { @@ -257,7 +256,7 @@ function resolveGlbSpawn( } /** - * First-person walkthrough controller for the baked GLB. Reuses the editor's + * First-person walkthrough controller for the baked GLB. Uses the shared * `BVHEcctrl` capsule character controller (gravity, jump, sprint, ground-float, * mesh collision) fed a collider built from the artifact's own geometry — so the * baked viewer walks the building with the same physics as the editor, without diff --git a/packages/viewer/src/components/viewer/live-walkthrough-controller.test.tsx b/packages/viewer/src/components/viewer/live-walkthrough-controller.test.tsx new file mode 100644 index 0000000000..a5b394f89b --- /dev/null +++ b/packages/viewer/src/components/viewer/live-walkthrough-controller.test.tsx @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { + LiveWalkthroughController, + type LiveWalkthroughControllerProps, + useFirstPersonHud, + useViewer, +} from '@pascal-app/viewer' +import { useFirstPersonHud as uiHud } from '@pascal-app/viewer/ui' +import { act, create } from '@react-three/test-renderer' +import { PerspectiveCamera } from 'three' + +// Exercise the real controller with deterministic frames and pointer/key events, without a browser. +const originalGlobals = new Map( + ['document', 'window', 'HTMLElement', 'requestAnimationFrame', 'cancelAnimationFrame'].map( + (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)], + ), +) +let documentEvents: EventTarget & { + activeElement: unknown + pointerLockElement: unknown + exitPointerLock: () => void +} +let canvas: HTMLCanvasElement +let renderer: Awaited> | undefined + +beforeEach(() => { + documentEvents = Object.assign(new EventTarget(), { + activeElement: null, + pointerLockElement: null, + exitPointerLock: () => { + documentEvents.pointerLockElement = null + documentEvents.dispatchEvent(new Event('pointerlockchange')) + }, + }) + const requestAnimationFrame = () => 0 + const cancelAnimationFrame = () => {} + Object.assign(globalThis, { + document: documentEvents, + window: Object.assign(new EventTarget(), { requestAnimationFrame, cancelAnimationFrame }), + HTMLElement: class extends EventTarget {}, + requestAnimationFrame, + cancelAnimationFrame, + }) + useViewer.getState().setWalkthroughMode(false) + useFirstPersonHud.getState().reset() +}) + +afterEach(async () => { + await renderer?.unmount() + renderer = undefined + useViewer.getState().setWalkthroughMode(false) + for (const [key, descriptor] of originalGlobals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + } +}) + +function key(code: string, type = 'keydown') { + documentEvents.dispatchEvent(Object.assign(new Event(type, { cancelable: true }), { code })) +} + +function lock() { + documentEvents.pointerLockElement = canvas + documentEvents.dispatchEvent(new Event('pointerlockchange')) +} + +async function mount(props: LiveWalkthroughControllerProps = {}) { + const camera = new PerspectiveCamera(47) + renderer = await create(, { + camera, + beforeReturn: (element) => { + canvas = element as HTMLCanvasElement + Object.assign(canvas, { hasAttribute: () => true, focus: () => {} }) + }, + }) + return camera +} + +async function enterDrone() { + await act(async () => { + useViewer.getState().setWalkthroughMovement('drone') + useViewer.getState().setWalkthroughMode(true) + }) +} + +test('viewer state activates movement, paused freezes it, and deactivation releases the session', async () => { + const camera = await mount() + expect(camera.fov).toBe(47) + await enterDrone() + expect(camera.fov).toBe(60) + key('KeyW') + await renderer!.advanceFrames(20, 1 / 60) + expect(camera.position.z).toBeLessThan(-0.1) + await renderer!.update() + const heldPosition = camera.position.clone() + await renderer!.advanceFrames(20, 1 / 60) + expect(camera.position.equals(heldPosition)).toBe(true) + await act(async () => useViewer.getState().setWalkthroughMode(false)) + expect(camera.fov).toBe(47) + await renderer!.advanceFrames(10, 1 / 60) + expect(camera.position.equals(heldPosition)).toBe(true) +}) + +test('unlock exits by default and calls the host once with walkthrough cleared', async () => { + const exits: boolean[] = [] + await mount({ onExit: () => exits.push(useViewer.getState().walkthroughMode) }) + await enterDrone() + lock() + await act(async () => documentEvents.exitPointerLock()) + expect(exits).toEqual([false]) + expect(useViewer.getState().walkthroughMode).toBe(false) +}) + +test('Escape while locked exits once even when unlock is delivered immediately', async () => { + let exits = 0 + await mount({ + onExit: () => { + exits += 1 + }, + }) + await enterDrone() + lock() + await act(async () => key('Escape')) + expect(exits).toBe(1) + expect(useViewer.getState().walkthroughMode).toBe(false) +}) + +test('host FOV ownership and suspended unlock preserve framing until Escape exits', async () => { + const exits: boolean[] = [] + const camera = await mount({ + externalFov: true, + suspendOnUnlock: true, + onExit: () => exits.push(useViewer.getState().walkthroughMode), + }) + await enterDrone() + expect(camera.fov).toBe(47) + lock() + await act(async () => documentEvents.exitPointerLock()) + expect(useViewer.getState().walkthroughMode).toBe(true) + expect(useViewer.getState().walkthroughSuspended).toBe(true) + camera.fov = 32 + await act(async () => key('Escape')) + expect(exits).toEqual([false]) + expect(camera.fov).toBe(32) +}) + +test('main and UI exports share one HUD store and suppress equivalent updates', () => { + expect(uiHud).toBe(useFirstPersonHud) + useFirstPersonHud + .getState() + .setHud({ floorLabel: 'Ground', interact: { label: 'door', verb: 'open' } }) + expect(uiHud.getState().floorLabel).toBe('Ground') + const previous = useFirstPersonHud.getState() + uiHud.getState().setHud({ interact: { label: 'door', verb: 'open' } }) + expect(useFirstPersonHud.getState()).toBe(previous) + uiHud.getState().reset() + expect(useFirstPersonHud.getState().interact).toBeNull() +}) diff --git a/packages/viewer/src/components/viewer/live-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/live-walkthrough-controller.tsx new file mode 100644 index 0000000000..4949164c35 --- /dev/null +++ b/packages/viewer/src/components/viewer/live-walkthrough-controller.tsx @@ -0,0 +1,1994 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + closeDoorOpenState, + closeWindowOpenState, + DOOR_SWING_OPEN_ANGLE, + type ElevatorDoorSide, + type ElevatorNode, + emitter, + getDisplayedDoorValue, + getDisplayedWindowValue, + getElevatorCabCenterZ, + getElevatorCabDepth, + getElevatorCabWidth, + getElevatorDoorLeafSides, + getElevatorDoorLeafWidth, + getElevatorDoorLeafX, + getElevatorShaftDepth, + getElevatorShaftWallThickness, + getElevatorShaftWidth, + getLevelDisplayName, + getLevelElevations, + getResolvedElevatorDoorStyle, + type InteractionSound, + isOperableWindowType, + isOperationDoorType, + itemPrompt, + nodeMechanism, + openElevatorDoor, + operateItem, + pointInPolygon2D, + requestElevatorLevel, + resolveElevatorDispatchTarget, + resolveElevatorLevels, + sceneRegistry, + toggleDoorOpenState, + toggleNodeMechanism, + toggleWindowOpenState, + useInteractive, + useScene, +} from '@pascal-app/core' +import { + operablePartFor, + operableParts, + type ProceduralItemNode, +} from '@pascal-app/core/procedural-items' +import { KeyboardControls } from '@react-three/drei' +import { useFrame, useThree } from '@react-three/fiber' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + Box3, + BoxGeometry, + Euler, + type Group, + Matrix4, + Mesh, + MeshBasicMaterial, + type Object3D, + type PerspectiveCamera, + Ray, + Raycaster, + Vector2, + Vector3, +} from 'three' +import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' +import { + createDroneInput, + DRONE_SLOW_MULTIPLIER, + type DroneLook, + seedDroneLook, + stepDroneFlight, + turnFirstPersonLook, +} from '../../lib/drone-flight' +import { setSurfaceRaycastLayers } from '../../lib/layers' +import { isTypingTarget } from '../../lib/typing-target' +import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud' +import useViewer from '../../store/use-viewer' +import { openingSettled, proceduralSettled } from '../../systems/interactive/opening-settled' +import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl' +import { + buildFirstPersonColliderWorldFromRegistry, + deriveFirstPersonSpawn, + FIRST_PERSON_SPAWN_EYE_HEIGHT, + type FirstPersonColliderWorld, + type FirstPersonSpawn, +} from './first-person/build-collider-world' +import { + hasNativeLeaf, + mechanismHudInteract, + mechanismTargetIds, + nativeOpeningIds, + watchScriptedDoors, +} from './first-person/mechanism-targets' +import { + CROUCH_CAPSULE, + CROUCH_EYE_OFFSET, + CROUCH_FLOAT_HEIGHT, + CROUCH_RUN_SPEED, + CROUCH_WALK_SPEED, + EYE_LERP_SPEED, + STAND_CAPSULE, + STAND_CLEARANCE, + STAND_FLOAT_HEIGHT, + WALKTHROUGH_FOV, +} from './glb-walkthrough-controller' +import { applyWalkthroughCameraClipping } from './viewer-camera' + +const CAMERA_EYE_OFFSET = 0.45 +const CONTROLLER_CENTER_FROM_EYE = 0.85 +const DOOR_INTERACTION_DISTANCE = 2.5 +const DOOR_LEAF_INTERACTION_DEPTH = 0.08 +const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18 +const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14 +const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 +const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 +const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 +const VOID_FALL_RESPAWN_DEPTH = 12 +const HUD_LABEL_SAMPLE_FRAMES = 10 + +type MovementKeyName = Exclude + +const movementKeyboardBindings: Array<{ name: MovementKeyName; keys: string[] }> = [ + { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, + { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, + { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] }, + { name: 'rightward', keys: ['ArrowRight', 'KeyD'] }, + { name: 'jump', keys: ['Space'] }, + { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, +] +const keyboardMap = movementKeyboardBindings +const movementKeyToName = new Map( + movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)), +) + +const inactiveMovementInput: MovementInput = { + backward: false, + forward: false, + jump: false, + leftward: false, + rightward: false, + run: false, +} + +function getMovementInputForKey(code: string, active: boolean): MovementInput | null { + const name = movementKeyToName.get(code) + return name ? ({ [name]: active } as MovementInput) : null +} + +function focusFirstPersonCanvas(canvas: HTMLCanvasElement) { + const activeElement = document.activeElement + if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) { + activeElement.blur() + } + + if (!canvas.hasAttribute('tabindex')) { + canvas.tabIndex = -1 + } + canvas.focus({ preventScroll: true }) +} + +const cameraOffset = new Vector3() +const cameraEuler = new Euler(0, 0, 0, 'YXZ') +const droneInput = createDroneInput() +const standClearanceRaycaster = new Raycaster() +const standClearanceUp = new Vector3(0, 1, 0) +const centerScreenPoint = new Vector2(0, 0) +const doorInteractionRaycaster = new Raycaster() +setSurfaceRaycastLayers(doorInteractionRaycaster.layers) +const proceduralInteractionRaycaster = new Raycaster() +setSurfaceRaycastLayers(proceduralInteractionRaycaster.layers) +const doorLeafBox = new Box3() +const doorLeafInverseMatrix = new Matrix4() +const doorLeafLocalHit = new Vector3() +const doorLeafLocalRay = new Ray() +const doorLeafMatrix = new Matrix4() +const doorLeafWorldHit = new Vector3() +const doorOpeningBox = new Box3() +const doorOpeningInverseMatrix = new Matrix4() +const doorOpeningLocalHit = new Vector3() +const doorOpeningLocalRay = new Ray() +const doorOpeningMatrix = new Matrix4() +const doorOpeningWorldHit = new Vector3() +const elevatorLocalControllerPosition = new Vector3() +const elevatorInteractionRaycaster = new Raycaster() +const elevatorColliderMatrix = new Matrix4() +const elevatorColliderLocalMatrix = new Matrix4() +const elevatorLocalEyePosition = new Vector3() +const elevatorWorldControllerPosition = new Vector3() +const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false }) +const spawnWorldPosition = new Vector3() +const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') +const windowInteractionRaycaster = new Raycaster() +setSurfaceRaycastLayers(windowInteractionRaycaster.layers) +const hudBuildingLocalEyePosition = new Vector3() +const hudWorldEyePosition = new Vector3() +const hudLevelBounds = new Box3() + +type ElevatorColliderKind = + | 'cab-back' + | 'cab-ceiling' + | 'cab-door-left' + | 'cab-door-right' + | 'cab-door-gate' + | 'cab-floor' + | 'cab-left' + | 'cab-right' + | 'landing-door-gate' + | 'landing-door-left' + | 'landing-door-right' + | 'shaft-back' + | 'shaft-front-header' + | 'shaft-front-left' + | 'shaft-front-right' + | 'shaft-left' + | 'shaft-right' + | 'shaft-top' + +type ElevatorColliderUserData = { + doorWidth?: number + dynamic?: boolean + elevatorId: AnyNodeId + kind: ElevatorColliderKind + levelId?: AnyNodeId + localPosition: [number, number, number] + matrixInitialized?: boolean + side?: ElevatorDoorSide +} + +type ElevatorColliderMesh = Mesh & { + userData: Mesh['userData'] & ElevatorColliderUserData +} + +type FirstPersonInteractableTarget = + | { + id: AnyNodeId + type: 'door' | 'window' + } + | { + id: AnyNodeId + type: 'item' + } + | { + id: AnyNodeId + partId?: string + kind?: 'hinge' | 'slide' | 'spin' + type: 'procedural' + } + | { + id: AnyNodeId + type: 'mechanism' + } + | { + action: 'open-door' | 'request-level' + buttonKind: 'cab' | 'landing' + id: AnyNodeId + levelId?: AnyNodeId + type: 'elevator' + } + +type ElevatorButtonTarget = { + action: 'open-door' | 'request-level' + buttonKind: 'cab' | 'landing' + elevatorId: AnyNodeId + levelId?: AnyNodeId +} + +function getLevelChildren( + level: Extract, + nodes: Record, +) { + const childIds = new Set(level.children) + return Object.values(nodes).filter((node) => node.parentId === level.id || childIds.has(node.id)) +} + +function pointIsInLevelFootprint( + point: [number, number], + worldPoint: Vector3, + level: Extract, + nodes: Record, +) { + const children = getLevelChildren(level, nodes) + const slabs = children.filter( + (node): node is Extract => + node.type === 'slab' && node.polygon.length >= 3, + ) + const zones = children.filter( + (node): node is Extract => + node.type === 'zone' && node.polygon.length >= 3, + ) + + if (slabs.length > 0) { + return slabs.some( + (slab) => + pointInPolygon2D(point, slab.polygon) && + !slab.holes.some((hole) => pointInPolygon2D(point, hole)), + ) + } + + if (zones.length > 0) { + if (zones.some((zone) => pointInPolygon2D(point, zone.polygon))) return true + } + + const levelObject = sceneRegistry.nodes.get(level.id) + if (!levelObject) return false + hudLevelBounds.setFromObject(levelObject) + return ( + !hudLevelBounds.isEmpty() && + worldPoint.x >= hudLevelBounds.min.x && + worldPoint.x <= hudLevelBounds.max.x && + worldPoint.z >= hudLevelBounds.min.z && + worldPoint.z <= hudLevelBounds.max.z + ) +} + +function resolveFirstPersonHudLabels(worldPoint: Vector3) { + const nodes = useScene.getState().nodes + const levelElevations = getLevelElevations(nodes as Record) + + for (const building of Object.values(nodes)) { + if (building.type !== 'building') continue + const buildingObject = sceneRegistry.nodes.get(building.id) + if (!buildingObject) continue + + buildingObject.updateWorldMatrix(true, true) + hudBuildingLocalEyePosition.copy(worldPoint) + buildingObject.worldToLocal(hudBuildingLocalEyePosition) + + const levels = Object.values(nodes) + .filter((node) => node.type === 'level') + .filter((level) => levelElevations.get(level.id)?.buildingId === building.id) + .sort( + (left, right) => + (levelElevations.get(left.id)?.baseY ?? 0) - (levelElevations.get(right.id)?.baseY ?? 0), + ) + + let activeLevel: (typeof levels)[number] | null = null + for (const level of levels) { + const elevation = levelElevations.get(level.id) + if (!elevation) continue + if ( + hudBuildingLocalEyePosition.y >= elevation.baseY - 0.5 && + hudBuildingLocalEyePosition.y < elevation.baseY + elevation.height + 0.5 + ) { + activeLevel = level + } + } + if (!activeLevel) continue + + const point: [number, number] = [hudBuildingLocalEyePosition.x, hudBuildingLocalEyePosition.z] + if (!pointIsInLevelFootprint(point, worldPoint, activeLevel, nodes)) continue + + const zone = getLevelChildren(activeLevel, nodes).find( + (node) => + node.type === 'zone' && node.polygon.length >= 3 && pointInPolygon2D(point, node.polygon), + ) + + return { + floorLabel: getLevelDisplayName(activeLevel), + zoneLabel: zone?.type === 'zone' ? zone.name : null, + } + } + + return { floorLabel: null, zoneLabel: null } +} + +function resolveHudInteract(target: FirstPersonInteractableTarget | null): WalkthroughInteract { + if (!target) return null + if (target.type === 'elevator') { + return { + label: target.action === 'open-door' ? 'door button' : 'elevator button', + verb: 'press', + } + } + + const node = useScene.getState().nodes[target.id] + if (target.type === 'mechanism') return node ? mechanismHudInteract(node) : null + if (target.type === 'item') { + if (node?.type !== 'item' || !node.asset.interactive) return null + const { label, verb } = itemPrompt( + target.id, + node.name ?? node.asset.name, + node.asset.interactive, + ) + return { label, verb } + } + if (target.type === 'procedural') { + if (node?.type !== 'procedural-item') return null + const procedural = node as ProceduralItemNode + const parts = operableParts(procedural.recipe) + if (parts.length === 0 && procedural.recipe.parts.some((part) => part.light)) { + const state = useInteractive.getState() + const isOn = state.procedural[target.id]?.lightsOn ?? state.lampDefault + return { label: procedural.name ?? 'Lights', verb: isOn ? 'turn off' : 'turn on' } + } + const part = target.partId ? parts.find((entry) => entry.id === target.partId) : undefined + const active = useInteractive.getState().procedural[target.id]?.parts + const isOn = part ? Boolean(active?.[part.id]) : parts.some((entry) => active?.[entry.id]) + const kind = part?.kind ?? (parts.every((entry) => entry.kind === 'spin') ? 'spin' : 'hinge') + return { + label: part?.label ?? procedural.name ?? 'item', + verb: kind === 'spin' ? (isOn ? 'turn off' : 'turn on') : isOn ? 'close' : 'open', + } + } + if (target.type === 'window') { + if (node?.type !== 'window') return null + const isOpen = getDisplayedWindowValue(target.id, node.operationState) > 0 + return { label: node.name || 'window', verb: isOpen ? 'close' : 'open' } + } + + if (node?.type !== 'door') return null + const isOpen = isOperationDoorType(node.doorType) + ? getDisplayedDoorValue(target.id, 'operationState', node.operationState) > 0 + : getDisplayedDoorValue(target.id, 'swingAngle', node.swingAngle) > 0 + return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' } +} + +/** E plays what the viewer's callout plays for the same door, window or switch. */ +function playTargetSound(target: FirstPersonInteractableTarget) { + const verb = resolveHudInteract(target)?.verb + if (!verb || verb === 'press') return + const node = useScene.getState().nodes[target.id] + // A recipe's door or lid swings and claps shut, its drawer slides; both land on the motion's end. + if (target.type === 'procedural' && (verb === 'open' || verb === 'close')) { + if (verb === 'open') { + emitter.emit('interaction:sound', 'panel-open') + return + } + if (target.kind !== 'slide') emitter.emit('interaction:sound', 'panel-swing') + void proceduralSettled(target.id).then(() => emitter.emit('interaction:sound', 'panel-close')) + return + } + let sound: InteractionSound + if (verb !== 'open' && verb !== 'close') + sound = verb === 'turn on' || verb === 'play' ? 'switch-on' : 'switch-off' + else if (node?.type === 'door' && node.doorType?.startsWith('garage-')) sound = 'garage' + else if (node?.type === 'window') sound = verb === 'open' ? 'window-open' : 'window-close' + else sound = verb === 'open' ? 'door-open' : 'door-close' + // A built-in leaf or sash swings shut, then latches on the frame it lands. + if ( + (sound === 'door-close' || sound === 'window-close') && + (target.type === 'door' || target.type === 'window') && + node + ) { + emitter.emit('interaction:sound', sound === 'door-close' ? 'door-swing' : 'window-swing') + void openingSettled(node).then(() => emitter.emit('interaction:sound', sound)) + return + } + emitter.emit('interaction:sound', sound) +} + +function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null { + let current: Object3D | null = object + + while (current) { + const candidate = ( + current.userData as { + elevatorButton?: { + action?: unknown + disabled?: unknown + elevatorId?: unknown + kind?: unknown + levelId?: unknown + } + } + ).elevatorButton + + if (candidate?.disabled === true) { + return null + } + + if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') { + const action = candidate.action === 'open-door' ? 'open-door' : 'request-level' + if (action === 'open-door') { + return { + action, + buttonKind: candidate.kind, + elevatorId: candidate.elevatorId as AnyNodeId, + } + } + } + + if ( + typeof candidate?.elevatorId === 'string' && + typeof candidate.levelId === 'string' && + (candidate.kind === 'cab' || candidate.kind === 'landing') + ) { + return { + action: 'request-level', + buttonKind: candidate.kind, + elevatorId: candidate.elevatorId as AnyNodeId, + levelId: candidate.levelId as AnyNodeId, + } + } + + current = current.parent + } + + return null +} + +function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) { + if (!target) return null + return target.type === 'elevator' + ? `${target.type}:${target.id}:${target.levelId}` + : target.type === 'procedural' + ? `${target.type}:${target.id}:${target.partId ?? 'all'}` + : `${target.type}:${target.id}` +} + +function isDynamicElevatorCollider(kind: ElevatorColliderKind) { + return kind.startsWith('cab-') || kind.startsWith('landing-door') +} + +function isInsideElevatorCab( + elevator: ElevatorNode, + runtime: NonNullable['elevators'][AnyNodeId]>, + localEyePosition: Vector3, +) { + const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfDepth = getElevatorCabDepth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const cabCenterZ = getElevatorCabCenterZ(elevator) + const cabHeight = Math.max(elevator.cabHeight, 1.4) + + return ( + Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) && + Math.abs(localEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) && + localEyePosition.y >= runtime.carY + 0.35 && + localEyePosition.y <= runtime.carY + cabHeight + 0.7 + ) +} + +function createElevatorColliderMesh( + elevatorId: AnyNodeId, + kind: ElevatorColliderKind, + size: [number, number, number], + localPosition: [number, number, number], + userData: Partial = {}, +) { + const geometry = new BoxGeometry(size[0], size[1], size[2]) + + const bvhGeometry = geometry as typeof geometry & { + computeBoundsTree?: typeof computeBoundsTree + disposeBoundsTree?: typeof disposeBoundsTree + } + ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree + ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree + bvhGeometry.computeBoundsTree?.({ + targetLeafSize: 12, + strategy: 0, + } as never) + bvhGeometry.computeBoundingBox() + + const mesh = new Mesh(bvhGeometry, elevatorColliderMaterial) as unknown as ElevatorColliderMesh + mesh.raycast = acceleratedRaycast + mesh.matrixAutoUpdate = false + mesh.visible = true + mesh.userData = { + ...userData, + dynamic: isDynamicElevatorCollider(kind), + elevatorId, + excludeCollisionCheck: false, + excludeFloatHit: false, + friction: 0.8, + kind, + localPosition, + matrixInitialized: false, + restitution: 0.03, + type: 'ELEVATOR_COLLIDER', + } + return mesh +} + +function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { + const nodes = useScene.getState().nodes + const meshes: ElevatorColliderMesh[] = [] + + for (const elevatorId of sceneRegistry.byType.elevator!) { + const typedElevatorId = elevatorId as AnyNodeId + const node = nodes[typedElevatorId] + if (node?.type !== 'elevator' || node.visible === false) continue + + const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorLevels(node, nodes) + const cabWidth = getElevatorCabWidth(node) + const cabDepth = getElevatorCabDepth(node) + const shaftWidth = getElevatorShaftWidth(node, cabWidth) + const shaftDepth = getElevatorShaftDepth(node, cabDepth) + const cabHeight = Math.max(node.cabHeight, 1.4) + const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18) + const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1) + const doorStyle = getResolvedElevatorDoorStyle(node.doorStyle) + const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) + const wallThickness = getElevatorShaftWallThickness(node) + const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) + const cabFloorDepth = Math.max(cabDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48) + const frontWallZ = -shaftDepth / 2 - wallThickness / 2 + const frontZ = frontWallZ - wallThickness / 2 - 0.018 + const cabCenterZ = -shaftDepth / 2 + cabDepth / 2 + const leafWidth = getElevatorDoorLeafWidth(doorWidth, doorStyle) + const doorLeafSides = getElevatorDoorLeafSides(doorStyle) + const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight) + + meshes.push( + createElevatorColliderMesh( + typedElevatorId, + 'shaft-back', + [shaftWidth + wallThickness * 2, shaftHeight, wallThickness], + [0, shaftBaseY + shaftHeight / 2, shaftDepth / 2 + wallThickness / 2], + ), + createElevatorColliderMesh( + typedElevatorId, + 'shaft-left', + [wallThickness, shaftHeight, shaftDepth + wallThickness * 2], + [-shaftWidth / 2 - wallThickness / 2, shaftBaseY + shaftHeight / 2, 0], + ), + createElevatorColliderMesh( + typedElevatorId, + 'shaft-right', + [wallThickness, shaftHeight, shaftDepth + wallThickness * 2], + [shaftWidth / 2 + wallThickness / 2, shaftBaseY + shaftHeight / 2, 0], + ), + createElevatorColliderMesh( + typedElevatorId, + 'shaft-top', + [shaftWidth + wallThickness * 2, wallThickness, shaftDepth + wallThickness * 2], + [0, shaftBaseY + shaftHeight - wallThickness / 2, 0], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-floor', + [cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth], + [0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, cabCenterZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-ceiling', + [cabWidth, wallThickness, cabDepth], + [0, cabHeight - wallThickness / 2, cabCenterZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-back', + [cabWidth, cabHeight, wallThickness], + [0, cabHeight / 2, cabCenterZ + cabDepth / 2 - wallThickness / 2], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-left', + [wallThickness, cabHeight, cabDepth], + [-cabWidth / 2 + wallThickness / 2, cabHeight / 2, cabCenterZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-right', + [wallThickness, cabHeight, cabDepth], + [cabWidth / 2 - wallThickness / 2, cabHeight / 2, cabCenterZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'cab-door-gate', + [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, doorHeight / 2, frontZ], + { doorWidth }, + ), + ...doorLeafSides.map((side) => + createElevatorColliderMesh( + typedElevatorId, + side === 'left' ? 'cab-door-left' : 'cab-door-right', + [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, doorHeight / 2, frontZ], + { doorWidth, side }, + ), + ), + ) + + const entrySpans = entries.map((entry, index) => { + const nextEntry = entries[index + 1] + return { + entry, + levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24), + } + }) + + for (const { entry, levelTopY } of entrySpans) { + const wallDepth = wallThickness + const levelHeight = Math.max(levelTopY - entry.baseY, doorHeight + 0.24) + const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08) + const jambCenterOffset = doorWidth / 2 + jambWidth / 2 + const headerHeight = Math.max(levelHeight - doorHeight, 0.14) + + meshes.push( + createElevatorColliderMesh( + typedElevatorId, + 'shaft-front-left', + [jambWidth, levelHeight, wallDepth], + [-jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'shaft-front-right', + [jambWidth, levelHeight, wallDepth], + [jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'shaft-front-header', + [shaftWidth, headerHeight, wallDepth], + [0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ], + ), + createElevatorColliderMesh( + typedElevatorId, + 'landing-door-gate', + [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, entry.baseY + doorHeight / 2, frontZ - 0.02], + { doorWidth, levelId: entry.id }, + ), + ...doorLeafSides.map((side) => + createElevatorColliderMesh( + typedElevatorId, + side === 'left' ? 'landing-door-left' : 'landing-door-right', + [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH], + [0, entry.baseY + doorHeight / 2, frontZ - 0.02], + { doorWidth, levelId: entry.id, side }, + ), + ), + ) + } + } + + return meshes +} + +function disposeElevatorColliderMeshes(meshes: ElevatorColliderMesh[]) { + for (const mesh of meshes) { + const geometry = mesh.geometry as typeof mesh.geometry & { + disposeBoundsTree?: typeof disposeBoundsTree + } + geometry.disposeBoundsTree?.() + geometry.dispose() + } +} + +const resolvePlacedSpawnNode = (nodes: ReturnType['nodes']) => { + const candidates = Object.values(nodes).filter((node) => node.type === 'spawn') + if (candidates.length === 0) return null + + return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null +} + +export type LiveWalkthroughControllerProps = { + /** Freeze movement and look while a host renders a still. */ + paused?: boolean + /** The host owns camera FOV, including restoration when this controller unmounts. */ + externalFov?: boolean + /** Allow locked-pointer clicks to operate the targeted object (E/R still work). */ + pointerInteraction?: boolean + /** Keep walkthrough active when pointer lock is lost; Escape with a free cursor exits. */ + suspendOnUnlock?: boolean + /** Called after walkthroughMode is cleared, including on Escape or pointer unlock. */ + onExit?: () => void +} + +/** Live scene walking, collision and interaction. Mount inside Viewer; useViewer owns activation. */ +export function LiveWalkthroughController(props: LiveWalkthroughControllerProps) { + const active = useViewer((state) => state.walkthroughMode) + return active ? : null +} + +function LiveWalkthroughSession({ + paused = false, + externalFov = false, + pointerInteraction = true, + suspendOnUnlock = false, + onExit, +}: LiveWalkthroughControllerProps) { + const hostRef = useRef({ paused, externalFov, pointerInteraction, suspendOnUnlock, onExit }) + hostRef.current = { paused, externalFov, pointerInteraction, suspendOnUnlock, onExit } + const exit = useCallback(() => { + if (!useViewer.getState().walkthroughMode) return + useViewer.getState().setWalkthroughMode(false) + hostRef.current.onExit?.() + }, []) + const { camera, gl } = useThree() + const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes)) + const isDroneMode = useViewer((state) => state.walkthroughMovement === 'drone') + const controllerRef = useRef(null) + const movementInputRef = useRef({ ...inactiveMovementInput }) + const hadPointerLockRef = useRef(false) + const lookRef = useRef({ yaw: 0, pitch: 0 }) + const interactableTargetRef = useRef(null) + const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1) + const crouchKeyRef = useRef(false) + const droneAscendKeyRef = useRef(false) + const droneSlowKeyRef = useRef(false) + const droneDescendKeyRef = useRef(false) + const droneVelocityRef = useRef(new Vector3()) + const suspendRef = useRef(false) + const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET) + const [crouched, setCrouched] = useState(false) + const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false) + const ridingElevatorRef = useRef<{ + elevatorId: AnyNodeId + localControllerY: number | null + previousCarY: number + } | null>(null) + const rideLockedRef = useRef(false) + const worldRef = useRef(null) + const elevatorColliderMeshesRef = useRef([]) + const [world, setWorld] = useState(null) + const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState([]) + const [controllerStart, setControllerStart] = useState<{ + position: [number, number, number] + yaw: number + } | null>(null) + + useEffect(() => { + if (!(camera as PerspectiveCamera).isPerspectiveCamera) return + return applyWalkthroughCameraClipping(camera as PerspectiveCamera) + }, [camera]) + + useEffect(() => { + const previousCameraMode = useViewer.getState().cameraMode + if (previousCameraMode === 'orthographic') { + useViewer.getState().setCameraMode('perspective') + } + return () => { + if (previousCameraMode === 'orthographic') { + useViewer.getState().setCameraMode('orthographic') + } + } + }, []) + + // Read the host's current FOV ownership at entry and teardown: changing it + // mid-session must not restore the orbit FOV underneath a host's framing rig. + useEffect(() => { + const perspectiveCamera = camera as PerspectiveCamera + if (!perspectiveCamera.isPerspectiveCamera) return + if (hostRef.current.externalFov) return + const previousFov = perspectiveCamera.fov + perspectiveCamera.fov = WALKTHROUGH_FOV + perspectiveCamera.updateProjectionMatrix() + return () => { + if (hostRef.current.externalFov) return + perspectiveCamera.fov = previousFov + perspectiveCamera.updateProjectionMatrix() + } + }, [camera]) + + useEffect(() => { + useFirstPersonHud.getState().reset() + return () => useFirstPersonHud.getState().reset() + }, []) + + const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => { + worldRef.current?.dispose() + worldRef.current = nextWorld + setWorld(nextWorld) + }, []) + + const replaceElevatorColliderMeshes = useCallback((nextMeshes: ElevatorColliderMesh[]) => { + disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current) + elevatorColliderMeshesRef.current = nextMeshes + setElevatorColliderMeshes(nextMeshes) + }, []) + + const rebuildColliderWorld = useCallback(() => { + replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry()) + replaceElevatorColliderMeshes(buildElevatorColliderMeshes()) + }, [replaceColliderWorld, replaceElevatorColliderMeshes]) + + const setElevatorRideLocked = useCallback((locked: boolean) => { + if (rideLockedRef.current === locked) return + rideLockedRef.current = locked + setIsElevatorRideLocked(locked) + }, []) + + const setControllerApi = useCallback((api: BVHEcctrlApi | null) => { + controllerRef.current = api + if (api) { + api.setMovement(movementInputRef.current) + } + }, []) + + const resolveInteractableDoorId = useCallback((): AnyNodeId | null => { + const nodes = useScene.getState().nodes + camera.updateMatrixWorld(true) + doorInteractionRaycaster.setFromCamera(centerScreenPoint, camera) + + let closestDoorId: AnyNodeId | null = null + let closestDistance = DOOR_INTERACTION_DISTANCE + + for (const doorId of sceneRegistry.byType.door!) { + const node = nodes[doorId as AnyNodeId] + if (node?.type !== 'door') continue + if (node.openingKind === 'opening') continue + if (!hasNativeLeaf(node)) continue + if (node.segments.every((segment) => segment.type === 'empty')) continue + + const object = sceneRegistry.nodes.get(doorId) + if (!object) continue + + object.updateWorldMatrix(true, true) + + const placementHit = doorInteractionRaycaster + .intersectObject(object, true) + .find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE) + if (placementHit && placementHit.distance < closestDistance) { + closestDoorId = doorId as AnyNodeId + closestDistance = placementHit.distance + } + + const leafW = node.width - 2 * node.frameThickness + const leafH = node.height - node.frameThickness + if (leafW <= 0 || leafH <= 0) continue + + const leafCenterY = -node.frameThickness / 2 + + if (isOperationDoorType(node.doorType)) { + doorOpeningMatrix + .copy(object.matrixWorld) + .multiply(new Matrix4().makeTranslation(0, leafCenterY, 0)) + doorOpeningInverseMatrix.copy(doorOpeningMatrix).invert() + doorOpeningBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2) + doorOpeningBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2) + doorOpeningLocalRay + .copy(doorInteractionRaycaster.ray) + .applyMatrix4(doorOpeningInverseMatrix) + + const localOpeningHit = doorOpeningLocalRay.intersectBox( + doorOpeningBox, + doorOpeningLocalHit, + ) + if (!localOpeningHit) continue + + doorOpeningWorldHit.copy(localOpeningHit).applyMatrix4(doorOpeningMatrix) + const openingHitDistance = doorOpeningWorldHit.distanceTo( + doorInteractionRaycaster.ray.origin, + ) + + if ( + openingHitDistance <= DOOR_INTERACTION_DISTANCE && + openingHitDistance < closestDistance + ) { + closestDoorId = doorId as AnyNodeId + closestDistance = openingHitDistance + } + continue + } + + const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2 + const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 + const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 + const currentSwingAngle = + useInteractive.getState().doors[doorId as AnyNodeId]?.swingAngle ?? node.swingAngle ?? 0 + const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, currentSwingAngle)) + const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign + + doorLeafMatrix + .copy(object.matrixWorld) + .multiply(new Matrix4().makeTranslation(hingeX, 0, 0)) + .multiply(new Matrix4().makeRotationY(leafSwingRotation)) + .multiply(new Matrix4().makeTranslation(-hingeX, leafCenterY, 0)) + doorLeafInverseMatrix.copy(doorLeafMatrix).invert() + doorLeafBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2) + doorLeafBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2) + doorLeafLocalRay.copy(doorInteractionRaycaster.ray).applyMatrix4(doorLeafInverseMatrix) + + const localHit = doorLeafLocalRay.intersectBox(doorLeafBox, doorLeafLocalHit) + if (!localHit) continue + + doorLeafWorldHit.copy(localHit).applyMatrix4(doorLeafMatrix) + const hitDistance = doorLeafWorldHit.distanceTo(doorInteractionRaycaster.ray.origin) + + if (hitDistance <= DOOR_INTERACTION_DISTANCE && hitDistance < closestDistance) { + closestDoorId = doorId as AnyNodeId + closestDistance = hitDistance + } + } + + return closestDoorId + }, [camera]) + + const resolveInteractableWindowId = useCallback((): AnyNodeId | null => { + const nodes = useScene.getState().nodes + camera.updateMatrixWorld(true) + windowInteractionRaycaster.setFromCamera(centerScreenPoint, camera) + + let closestWindowId: AnyNodeId | null = null + let closestDistance = DOOR_INTERACTION_DISTANCE + + for (const windowId of sceneRegistry.byType.window!) { + const node = nodes[windowId as AnyNodeId] + if (node?.type !== 'window') continue + if (node.openingKind === 'opening') continue + if (!hasNativeLeaf(node)) continue + if (!isOperableWindowType(node.windowType)) continue + + const object = sceneRegistry.nodes.get(windowId) + if (!object) continue + + const hit = windowInteractionRaycaster + .intersectObject(object, true) + .find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE) + if (!(hit && hit.distance < closestDistance)) continue + + closestWindowId = windowId as AnyNodeId + closestDistance = hit.distance + } + + return closestWindowId + }, [camera]) + + const resolveInteractableElevatorTarget = + useCallback((): FirstPersonInteractableTarget | null => { + const nodes = useScene.getState().nodes + camera.updateMatrixWorld(true) + elevatorInteractionRaycaster.setFromCamera(centerScreenPoint, camera) + + let closestTarget: FirstPersonInteractableTarget | null = null + let closestDistance = DOOR_INTERACTION_DISTANCE + + for (const elevatorId of sceneRegistry.byType.elevator!) { + const typedElevatorId = elevatorId as AnyNodeId + const node = nodes[typedElevatorId] + if (node?.type !== 'elevator') continue + + const object = sceneRegistry.nodes.get(typedElevatorId) + if (!object) continue + + const runtime = useInteractive.getState().elevators[typedElevatorId] + object.updateWorldMatrix(true, true) + if (runtime) { + elevatorLocalEyePosition.copy(camera.position) + object.worldToLocal(elevatorLocalEyePosition) + } + const canUseCabButtons = + runtime && isInsideElevatorCab(node, runtime, elevatorLocalEyePosition) + + const intersections = elevatorInteractionRaycaster.intersectObject(object, true) + for (const intersection of intersections) { + if (intersection.distance > closestDistance) continue + + const target = resolveElevatorButtonTarget(intersection.object) + if (!target || target.elevatorId !== elevatorId) continue + if (target.action === 'request-level') { + if (!target.levelId || nodes[target.levelId]?.type !== 'level') continue + } + if (target.buttonKind === 'cab' && !canUseCabButtons) continue + + closestTarget = { + action: target.action, + buttonKind: target.buttonKind, + id: target.elevatorId, + levelId: target.levelId, + type: 'elevator', + } + closestDistance = intersection.distance + } + } + + return closestTarget + }, [camera]) + + const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => { + const elevatorTarget = resolveInteractableElevatorTarget() + if (elevatorTarget) return elevatorTarget + + const doorId = resolveInteractableDoorId() + if (doorId) return { id: doorId, type: 'door' } + + const windowId = resolveInteractableWindowId() + if (windowId) return { id: windowId, type: 'window' } + + camera.updateMatrixWorld(true) + proceduralInteractionRaycaster.setFromCamera(centerScreenPoint, camera) + proceduralInteractionRaycaster.far = DOOR_INTERACTION_DISTANCE + let closest: FirstPersonInteractableTarget | null = null + let closestDistance = DOOR_INTERACTION_DISTANCE + const nodes = useScene.getState().nodes + const covered = new Set() + for (const rawId of sceneRegistry.byType['procedural-item'] ?? []) { + covered.add(rawId) + const id = rawId as AnyNodeId + const node = nodes[id] + if (node?.type !== 'procedural-item') continue + const procedural = node as ProceduralItemNode + if ( + !operableParts(procedural.recipe).length && + !procedural.recipe.parts.some((part) => part.light) + ) + continue + const object = sceneRegistry.nodes.get(id) + if (!object) continue + for (const hit of proceduralInteractionRaycaster.intersectObject(object, true)) { + if (hit.distance >= closestDistance) break + // Skip what is not drawn: a joint tree's inactive rest or joint meshes. + let drawn = true + for (let at: Object3D | null = hit.object; at && at !== object; at = at.parent) + if (!at.visible) drawn = false + if (!drawn) continue + let ancestor: Object3D | null = hit.object + while (ancestor && ancestor !== object && !ancestor.userData.proceduralMotion) + ancestor = ancestor.parent + let motion = ancestor?.userData.proceduralMotion as + | { nodeId?: string; partId?: string; kind?: 'hinge' | 'slide' | 'spin' } + | undefined + // An idle joint tree draws one merged mesh per slot; its face ranges name the part. + const ranges = hit.object.userData.proceduralRanges as + | { end: number; partId: string }[] + | undefined + if (!motion && ranges && hit.faceIndex != null) { + const face = hit.faceIndex + const partId = ranges.find((range) => face < range.end)?.partId + const part = partId ? operablePartFor(procedural.recipe, partId) : undefined + if (part) motion = { nodeId: id, partId: part.id, kind: part.kind } + } + closest = + motion?.nodeId === id && motion.partId + ? { id, partId: motion.partId, kind: motion.kind, type: 'procedural' } + : { id, type: 'procedural' } + closestDistance = hit.distance + break + } + } + for (const rawId of sceneRegistry.byType.item ?? []) { + covered.add(rawId) + const id = rawId as AnyNodeId + const node = nodes[id] + if ( + node?.type !== 'item' || + !node.asset.interactive?.controls.some((control) => control.kind === 'toggle') + ) + continue + const object = sceneRegistry.nodes.get(id) + if (!object) continue + const hit = proceduralInteractionRaycaster.intersectObject(object, true)[0] + if (hit && hit.distance < closestDistance) { + closest = { id, type: 'item' } + closestDistance = hit.distance + } + } + // Doors and windows have their own walkthrough targets (resolveInteractable*Id), except those + // built from a script: their clips run through the mechanism target below. + for (const id of nativeOpeningIds()) covered.add(id) + for (const id of mechanismTargetIds(covered)) { + const object = sceneRegistry.nodes.get(id) + const hit = object && proceduralInteractionRaycaster.intersectObject(object, true)[0] + if (hit && hit.distance < closestDistance) { + closest = { id, type: 'mechanism' } + closestDistance = hit.distance + } + } + if (!closest) { + const selectedId = useViewer.getState().selection.selectedIds.at(-1) as AnyNodeId | undefined + const selected = selectedId ? nodes[selectedId] : undefined + if ( + selected?.type === 'item' && + selected.asset.interactive?.controls.some((control) => control.kind === 'toggle') + ) + return { id: selected.id, type: 'item' } + } + if (closest) return closest + + return null + }, [ + camera, + resolveInteractableDoorId, + resolveInteractableElevatorTarget, + resolveInteractableWindowId, + ]) + + const toggleInteractableTarget = useCallback(() => { + if (isDroneMode) return + + const target = interactableTargetRef.current ?? resolveInteractableTarget() + if (!target) return + playTargetSound(target) + if (target.type === 'mechanism') { + const node = useScene.getState().nodes[target.id] + if (node) toggleNodeMechanism(node) + return + } + if (target.type === 'item') { + const node = useScene.getState().nodes[target.id] + if (node?.type === 'item' && node.asset.interactive) + operateItem(target.id, node.asset.interactive) + return + } + + if (target.type === 'procedural') { + const node = useScene.getState().nodes[target.id] + if (node?.type !== 'procedural-item') return + const parts = operableParts((node as ProceduralItemNode).recipe).map((part) => part.id) + const state = useInteractive.getState() + if (parts.length === 0) { + state.toggleProceduralLights(target.id) + return + } + if (target.partId) state.toggleProceduralPart(target.id, target.partId) + else + state.setProceduralParts( + target.id, + parts, + !parts.some((id) => state.procedural[target.id]?.parts[id]), + ) + return + } + + if (target.type === 'elevator') { + if (target.buttonKind === 'cab') { + const state = useInteractive.getState().elevators[target.id] + if (state) { + ridingElevatorRef.current = { + elevatorId: target.id, + localControllerY: null, + previousCarY: state.carY, + } + } + } + if (target.action === 'open-door') { + openElevatorDoor(target.id) + return + } + if (target.levelId) { + const targetElevatorId = + target.buttonKind === 'landing' + ? resolveElevatorDispatchTarget({ + elevators: useInteractive.getState().elevators, + levelId: target.levelId, + nodes: useScene.getState().nodes, + requestedElevatorId: target.id, + }) + : target.id + requestElevatorLevel(targetElevatorId, target.levelId) + } + return + } + + if (target.type === 'window') { + const node = useScene.getState().nodes[target.id] + if ( + node?.type !== 'window' || + node.openingKind === 'opening' || + !isOperableWindowType(node.windowType) + ) { + return + } + + toggleWindowOpenState(target.id, { persist: false }) + return + } + + const doorId = target.id + + const node = useScene.getState().nodes[doorId] + if (node?.type !== 'door' || node.openingKind === 'opening') return + + toggleDoorOpenState(doorId, { persist: false }) + }, [isDroneMode, resolveInteractableTarget]) + + const closeInteractableTarget = useCallback(() => { + if (isDroneMode) return + + const target = interactableTargetRef.current ?? resolveInteractableTarget() + if (!target) return + + if (target.type === 'mechanism') { + const node = useScene.getState().nodes[target.id] + if (node) nodeMechanism(node)?.set(node, false) + return + } + + if (target.type === 'procedural') { + const node = useScene.getState().nodes[target.id] + if (node?.type !== 'procedural-item') return + const parts = target.partId + ? [target.partId] + : operableParts((node as ProceduralItemNode).recipe).map((part) => part.id) + useInteractive.getState().setProceduralParts(target.id, parts, false) + return + } + + if (target.type === 'elevator') return + + if (target.type === 'window') { + const node = useScene.getState().nodes[target.id] + if ( + node?.type !== 'window' || + node.openingKind === 'opening' || + !isOperableWindowType(node.windowType) + ) { + return + } + + closeWindowOpenState(target.id, { persist: false }) + return + } + + const node = useScene.getState().nodes[target.id] + if (node?.type !== 'door' || node.openingKind === 'opening') return + + closeDoorOpenState(target.id, { persist: false }) + }, [isDroneMode, resolveInteractableTarget]) + + const placedSpawn = useMemo(() => { + if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null + + const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id) + if (spawnObject) { + spawnObject.updateWorldMatrix(true, false) + spawnObject.getWorldPosition(spawnWorldPosition) + spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ') + + return { + position: [ + spawnWorldPosition.x, + spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT, + spawnWorldPosition.z, + ], + yaw: spawnWorldEuler.y, + } + } + + return { + position: [ + placedSpawnNode.position[0], + placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT, + placedSpawnNode.position[2], + ], + yaw: placedSpawnNode.rotation, + } + }, [placedSpawnNode]) + + useEffect(() => { + // Drone has no gravity, no floor and no collision, so the BVH collider world + // (a full-scene traversal, rebuilt on every door/window animation) is pure + // cost there. Switching modes disposes it and rebuilds on the way back. + if (!isDroneMode) rebuildColliderWorld() + + return () => { + worldRef.current?.dispose() + worldRef.current = null + disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current) + elevatorColliderMeshesRef.current = [] + setElevatorColliderMeshes([]) + setWorld(null) + } + }, [isDroneMode, rebuildColliderWorld]) + + useEffect(() => { + if (isDroneMode) return + emitter.on('door:animation-completed', rebuildColliderWorld) + emitter.on('window:animation-completed', rebuildColliderWorld) + // A door built from a script opens by its model's clip, which reports no animation end. + const stopWatching = watchScriptedDoors(rebuildColliderWorld) + return () => { + emitter.off('door:animation-completed', rebuildColliderWorld) + emitter.off('window:animation-completed', rebuildColliderWorld) + stopWatching() + } + }, [isDroneMode, rebuildColliderWorld]) + + // A walk session started before a drone detour would otherwise resume at its + // original spawn; drop it so the next walk re-derives one. + useEffect(() => { + if (isDroneMode) setControllerStart(null) + }, [isDroneMode]) + + // Drone picks up wherever the camera currently is — orbit pose or walk eye. + useEffect(() => { + if (!isDroneMode) return + seedDroneLook(lookRef.current, camera.quaternion) + droneVelocityRef.current.set(0, 0, 0) + + // Interaction prompts belong to walk; drop whatever its frame left behind. + if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) { + useViewer.getState().setHoveredId(null) + } + interactableTargetRef.current = null + useFirstPersonHud.getState().reset() + }, [camera, isDroneMode]) + + useEffect(() => { + if (!world) return + if (controllerStart) return + + const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world) + const [x, y, z] = spawn.position + lookRef.current.yaw = spawn.yaw + lookRef.current.pitch = 0 + setControllerStart({ + position: [x, y - CONTROLLER_CENTER_FROM_EYE, z], + yaw: spawn.yaw, + }) + }, [camera, controllerStart, placedSpawn, world]) + + useEffect(() => { + const canvas = gl.domElement + focusFirstPersonCanvas(canvas) + + const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas)) + return () => window.cancelAnimationFrame(frame) + }, [gl]) + + // The pointer-lock effect below must be mount-stable. Its cleanup exits + // pointer lock, and `toggleInteractableTarget` is recreated whenever the + // camera object changes — which happens right after entry when the + // persisted orthographic mode swaps to perspective. If the async lock + // grant lands before that re-run, the cleanup's exitPointerLock fires an + // unlock the handler reads as "user left walkthrough", instantly + // cancelling a fresh entry (and arming the browser's ~1.25s re-lock + // cooldown, so the next presses fail too). Route the callback through a + // ref so the effect deps stay `[gl]`. + const toggleInteractableTargetRef = useRef(toggleInteractableTarget) + useEffect(() => { + toggleInteractableTargetRef.current = toggleInteractableTarget + }, [toggleInteractableTarget]) + + useEffect(() => { + const canvas = gl.domElement + const handleMouseMove = (e: MouseEvent) => { + if (document.pointerLockElement !== canvas) return + // A still render must retain the exact framed pose. + if (hostRef.current.paused) return + + turnFirstPersonLook( + lookRef.current, + e.movementX, + e.movementY, + useViewer.getState().walkthroughMovement === 'drone' && droneSlowKeyRef.current + ? DRONE_SLOW_MULTIPLIER + : 1, + ) + } + + const handleClick = (event: MouseEvent) => { + const target = event.target + if (!(target instanceof HTMLElement)) return + if (!canvas.contains(target)) return + if (document.pointerLockElement !== canvas) { + canvas.requestPointerLock?.() + } + } + + const handleMouseDown = (event: MouseEvent) => { + if (document.pointerLockElement !== canvas) return + if (event.button !== 0) return + + if (!hostRef.current.pointerInteraction) return + + event.preventDefault() + event.stopPropagation() + toggleInteractableTargetRef.current() + } + + const handlePointerLockChange = () => { + const isLocked = document.pointerLockElement === canvas + if (isLocked) { + hadPointerLockRef.current = true + suspendRef.current = false + useViewer.getState().setWalkthroughSuspended(false) + return + } + + // Deliberately released (screenshot pause) — stay in first person; + // clicking the canvas re-locks. + if (suspendRef.current) return + + if ( + hadPointerLockRef.current && + hostRef.current.suspendOnUnlock && + useViewer.getState().walkthroughMode + ) { + suspendRef.current = true + useViewer.getState().setWalkthroughSuspended(true) + return + } + + if (hadPointerLockRef.current && useViewer.getState().walkthroughMode) { + exit() + } + } + + handlePointerLockChange() + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('click', handleClick) + document.addEventListener('mousedown', handleMouseDown, true) + document.addEventListener('pointerlockchange', handlePointerLockChange) + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('click', handleClick) + document.removeEventListener('mousedown', handleMouseDown, true) + document.removeEventListener('pointerlockchange', handlePointerLockChange) + useViewer.getState().setWalkthroughSuspended(false) + if (document.pointerLockElement === canvas) { + document.exitPointerLock() + } + } + }, [exit, gl]) + + useEffect(() => { + const canvas = gl.domElement + + const applyMovementKey = (event: KeyboardEvent, active: boolean) => { + if (isTypingTarget(event.target)) return false + + const movement = getMovementInputForKey(event.code, active) + if (!movement) return false + + event.preventDefault() + Object.assign(movementInputRef.current, movement) + controllerRef.current?.setMovement(movement) + return true + } + + const handleKeyDown = (event: KeyboardEvent) => { + const handledMovement = applyMovementKey(event, true) + if (handledMovement) return + + if (isTypingTarget(event.target)) return + + if (event.code === 'ControlLeft' || event.code === 'ControlRight') { + // While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard + // screenshot) must not toggle it under the user. + if (!suspendRef.current) crouchKeyRef.current = true + } else if (event.code === 'KeyQ') { + // Drone descend. Space (already bound to jump) and E are the matching ascend. + event.preventDefault() + event.stopPropagation() + if (!suspendRef.current) droneDescendKeyRef.current = true + } else if (event.code === 'KeyE' && isDroneMode) { + event.preventDefault() + event.stopPropagation() + if (!suspendRef.current) droneAscendKeyRef.current = true + } else if ((event.code === 'AltLeft' || event.code === 'AltRight') && isDroneMode) { + event.preventDefault() + event.stopPropagation() + if (!suspendRef.current) droneSlowKeyRef.current = true + } else if (event.code === 'Escape') { + event.preventDefault() + event.stopPropagation() + if (hostRef.current.suspendOnUnlock) { + if (document.pointerLockElement === canvas) { + suspendRef.current = true + useViewer.getState().setWalkthroughSuspended(true) + document.exitPointerLock() + } else { + exit() + } + return + } + if (document.pointerLockElement === canvas) { + document.exitPointerLock() + } + exit() + } else if (event.code === 'KeyE' || event.code === 'KeyR') { + event.preventDefault() + event.stopPropagation() + toggleInteractableTarget() + } else if (event.code === 'KeyT') { + event.preventDefault() + event.stopPropagation() + closeInteractableTarget() + } else if (event.code === 'KeyP') { + // P toggles a cursor pause (advertised in the HUD): frees the pointer + // without leaving first person — e.g. for an OS screenshot, which + // needs a movable cursor — and click or P resumes. + event.preventDefault() + event.stopPropagation() + if (document.pointerLockElement === canvas) { + suspendRef.current = true + useViewer.getState().setWalkthroughSuspended(true) + document.exitPointerLock() + } else if (suspendRef.current) { + const result = canvas.requestPointerLock?.() as Promise | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) + } + } + } + + const handleKeyUp = (event: KeyboardEvent) => { + if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { + crouchKeyRef.current = false + } + if (event.code === 'KeyQ' && !suspendRef.current) { + droneDescendKeyRef.current = false + } + if (event.code === 'KeyE' && !suspendRef.current) { + droneAscendKeyRef.current = false + } + if ((event.code === 'AltLeft' || event.code === 'AltRight') && !suspendRef.current) { + droneSlowKeyRef.current = false + } + applyMovementKey(event, false) + } + + const handleBlur = () => { + if (!suspendRef.current) { + crouchKeyRef.current = false + droneAscendKeyRef.current = false + droneDescendKeyRef.current = false + droneSlowKeyRef.current = false + } + } + + document.addEventListener('keydown', handleKeyDown, true) + document.addEventListener('keyup', handleKeyUp, true) + window.addEventListener('blur', handleBlur) + return () => { + document.removeEventListener('keydown', handleKeyDown, true) + document.removeEventListener('keyup', handleKeyUp, true) + window.removeEventListener('blur', handleBlur) + } + }, [closeInteractableTarget, exit, gl, isDroneMode, toggleInteractableTarget]) + + const syncElevatorColliderMeshes = useCallback(() => { + const nodes = useScene.getState().nodes + const interactive = useInteractive.getState() + + for (const mesh of elevatorColliderMeshesRef.current) { + const { doorWidth, dynamic, elevatorId, kind, levelId, localPosition, side } = mesh.userData + const node = nodes[elevatorId] + const runtime = interactive.elevators[elevatorId] + const object = sceneRegistry.nodes.get(elevatorId) + if (!(node?.type === 'elevator' && object && node.visible !== false)) { + mesh.visible = false + continue + } + + if (!dynamic && mesh.userData.matrixInitialized && mesh.visible) { + continue + } + + let [localX, localY, localZ] = localPosition + const isCabCollider = kind.startsWith('cab-') + const isDoorCollider = kind === 'landing-door-left' || kind === 'landing-door-right' + const isCabDoorGate = kind === 'cab-door-gate' + const isLandingDoorGate = kind === 'landing-door-gate' + + if (isCabCollider) { + localY += runtime?.carY ?? 0 + } + + if (kind === 'cab-door-left' || kind === 'cab-door-right') { + if (!runtime) { + mesh.visible = false + continue + } + localX = getElevatorDoorLeafX( + side ?? 'left', + doorWidth ?? node.doorWidth, + runtime.doorOpen, + node.doorStyle, + ) + mesh.visible = true + } else if (isCabDoorGate) { + if (!runtime) { + mesh.visible = false + continue + } + mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD + } else if (isDoorCollider) { + const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0 + localX = getElevatorDoorLeafX( + side ?? 'left', + doorWidth ?? node.doorWidth, + doorOpen, + node.doorStyle, + ) + mesh.visible = true + } else if (isLandingDoorGate) { + const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0 + mesh.visible = doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD + } else { + mesh.visible = true + } + + object.updateWorldMatrix(true, false) + elevatorColliderMatrix.copy(object.matrixWorld) + elevatorColliderMatrix.multiply( + elevatorColliderLocalMatrix.makeTranslation(localX, localY, localZ), + ) + mesh.matrix.copy(elevatorColliderMatrix) + mesh.matrixWorld.copy(elevatorColliderMatrix) + mesh.userData.matrixInitialized = true + } + }, []) + + useFrame(() => { + if (isDroneMode) return + syncElevatorColliderMeshes() + }, -1) + + const syncElevatorRide = useCallback( + (group: Group) => { + const nodes = useScene.getState().nodes + const interactive = useInteractive.getState() + const activeRide = ridingElevatorRef.current + let nextRide: { + cabHeight: number + cabCenterZ: number + carY: number + doorOpen: number + elevatorId: AnyNodeId + halfDepth: number + halfWidth: number + object: Object3D + phase: NonNullable<(typeof interactive.elevators)[AnyNodeId]>['phase'] + } | null = null + + const elevatorIds = activeRide + ? [ + activeRide.elevatorId, + ...Array.from(sceneRegistry.byType.elevator!).filter( + (elevatorId) => elevatorId !== activeRide.elevatorId, + ), + ] + : Array.from(sceneRegistry.byType.elevator!) + + for (const elevatorId of elevatorIds) { + const typedElevatorId = elevatorId as AnyNodeId + const node = nodes[typedElevatorId] + if (node?.type !== 'elevator') continue + + const runtime = interactive.elevators[typedElevatorId] + const object = sceneRegistry.nodes.get(typedElevatorId) + if (!(runtime && object)) continue + + object.updateWorldMatrix(true, true) + elevatorLocalEyePosition.copy(camera.position) + object.worldToLocal(elevatorLocalEyePosition) + + const halfWidth = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const halfDepth = getElevatorCabDepth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING + const cabCenterZ = getElevatorCabCenterZ(node) + const cabHeight = Math.max(node.cabHeight, 1.4) + const insideFootprint = + Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) && + Math.abs(elevatorLocalEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) + const insideCabHeight = + elevatorLocalEyePosition.y >= runtime.carY + 0.35 && + elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7 + const continuingRide = + activeRide?.elevatorId === typedElevatorId && + insideFootprint && + elevatorLocalEyePosition.y >= runtime.carY - 0.2 && + elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 1.25 + + if ((insideFootprint && insideCabHeight) || continuingRide) { + nextRide = { + cabHeight, + cabCenterZ, + carY: runtime.carY, + doorOpen: runtime.doorOpen, + elevatorId: typedElevatorId, + halfDepth: Math.max(halfDepth, 0.24), + halfWidth: Math.max(halfWidth, 0.24), + object, + phase: runtime.phase, + } + break + } + } + + if (!nextRide) { + ridingElevatorRef.current = null + setElevatorRideLocked(false) + return + } + + const previousCarY = + activeRide?.elevatorId === nextRide.elevatorId ? activeRide.previousCarY : nextRide.carY + const deltaY = nextRide.carY - previousCarY + nextRide.object.updateWorldMatrix(true, true) + elevatorLocalControllerPosition.copy(group.position) + nextRide.object.worldToLocal(elevatorLocalControllerPosition) + const localControllerY = + activeRide?.elevatorId === nextRide.elevatorId && activeRide.localControllerY !== null + ? activeRide.localControllerY + : elevatorLocalControllerPosition.y - nextRide.carY + + if (Math.abs(deltaY) > 0.0001) { + group.position.y += deltaY + controllerRef.current?.resetLinVel() + } + + const shouldLockToCab = + nextRide.phase === 'closing' || + nextRide.phase === 'moving' || + (nextRide.phase === 'opening' && nextRide.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD) + if (shouldLockToCab) { + elevatorLocalControllerPosition.copy(group.position) + nextRide.object.worldToLocal(elevatorLocalControllerPosition) + const desiredLocalY = nextRide.carY + localControllerY + if (Math.abs(elevatorLocalControllerPosition.y - desiredLocalY) > 0.002) { + elevatorLocalControllerPosition.y = desiredLocalY + elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition) + nextRide.object.localToWorld(elevatorWorldControllerPosition) + group.position.y = elevatorWorldControllerPosition.y + controllerRef.current?.resetLinVel() + elevatorLocalControllerPosition.copy(group.position) + nextRide.object.worldToLocal(elevatorLocalControllerPosition) + } + + const clampedX = Math.max( + -nextRide.halfWidth, + Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x), + ) + const clampedZ = Math.max( + nextRide.cabCenterZ - nextRide.halfDepth, + Math.min(nextRide.cabCenterZ + nextRide.halfDepth, elevatorLocalControllerPosition.z), + ) + + if ( + Math.abs(clampedX - elevatorLocalControllerPosition.x) > 0.0001 || + Math.abs(clampedZ - elevatorLocalControllerPosition.z) > 0.0001 + ) { + elevatorLocalControllerPosition.x = clampedX + elevatorLocalControllerPosition.z = clampedZ + elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition) + nextRide.object.localToWorld(elevatorWorldControllerPosition) + group.position.x = elevatorWorldControllerPosition.x + group.position.z = elevatorWorldControllerPosition.z + controllerRef.current?.resetLinVel() + } + } + + setElevatorRideLocked(shouldLockToCab) + + ridingElevatorRef.current = { + elevatorId: nextRide.elevatorId, + localControllerY, + previousCarY: nextRide.carY, + } + }, + [camera, setElevatorRideLocked], + ) + + const hasStandingClearance = useCallback((position: Vector3) => { + standClearanceRaycaster.set(position, standClearanceUp) + standClearanceRaycaster.far = STAND_CLEARANCE + const meshes: Mesh[] = [] + if (worldRef.current) meshes.push(worldRef.current.mesh) + for (const mesh of elevatorColliderMeshesRef.current) { + if (mesh.visible) meshes.push(mesh) + } + return standClearanceRaycaster.intersectObjects(meshes, false).length === 0 + }, []) + + // Drone: the viewer's free flight (`stepDroneFlight`, also behind the viewer's + // `DroneController`) fed from this controller's keys — WASD along the view + // axes, Space or E rises, Q (or Ctrl) sinks, Shift boosts, Alt slows. + useFrame((_, delta) => { + if (!isDroneMode) return + if (hostRef.current.paused) return + + const movement = movementInputRef.current + droneInput.forward = movement.forward === true + droneInput.backward = movement.backward === true + droneInput.leftward = movement.leftward === true + droneInput.rightward = movement.rightward === true + droneInput.up = movement.jump === true || droneAscendKeyRef.current + droneInput.down = droneDescendKeyRef.current || crouchKeyRef.current + droneInput.boost = movement.run === true + droneInput.slow = droneSlowKeyRef.current + stepDroneFlight(camera, lookRef.current, droneInput, droneVelocityRef.current, delta) + }, 2.5) + + useFrame((_, delta) => { + if (isDroneMode) return + if (!controllerRef.current?.group) return + + const group = controllerRef.current.group + + // Crouch follows the held key; standing back up waits for headroom. + // Frozen while the cursor pause is active. + if (!suspendRef.current && crouchKeyRef.current !== crouched) { + if (crouchKeyRef.current) setCrouched(true) + else if (hasStandingClearance(group.position)) setCrouched(false) + } + const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET + eyeOffsetRef.current += + (targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED) + cameraOffset.set(0, eyeOffsetRef.current, 0) + + // The site ground collider is effectively unbounded, but scenes without a + // site node only have finite fallback floors — if the controller still ends + // up below every collider it can never land, so put it back at the spawn. + // Prefer the live spawn node over the mount-time start position so a spawn + // moved mid-walkthrough doesn't respawn the player at stale coordinates. + const worldBounds = worldRef.current?.bounds + if (worldBounds && group.position.y < worldBounds.min.y - VOID_FALL_RESPAWN_DEPTH) { + const respawnPosition = placedSpawn + ? [ + placedSpawn.position[0], + placedSpawn.position[1] - CONTROLLER_CENTER_FROM_EYE, + placedSpawn.position[2], + ] + : controllerStart?.position + if (respawnPosition) { + group.position.set(respawnPosition[0]!, respawnPosition[1]!, respawnPosition[2]!) + controllerRef.current.resetLinVel() + ridingElevatorRef.current = null + setElevatorRideLocked(false) + } + } + + group.rotation.y = 0 + camera.position.copy(group.position).add(cameraOffset) + cameraEuler.set(lookRef.current.pitch, lookRef.current.yaw, 0, 'YXZ') + camera.quaternion.setFromEuler(cameraEuler) + camera.updateMatrixWorld(true) + syncElevatorRide(group) + camera.position.copy(group.position).add(cameraOffset) + camera.updateMatrixWorld(true) + + const nextInteractableTarget = resolveInteractableTarget() + const previousInteractableTarget = interactableTargetRef.current + if ( + getInteractableTargetKey(previousInteractableTarget) !== + getInteractableTargetKey(nextInteractableTarget) + ) { + interactableTargetRef.current = nextInteractableTarget + useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null) + } + + useFirstPersonHud.getState().setHud({ + interact: resolveHudInteract(nextInteractableTarget), + }) + + hudLabelFrameRef.current += 1 + if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) { + hudLabelFrameRef.current = 0 + camera.getWorldPosition(hudWorldEyePosition) + useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition)) + } + }, 2.5) + + useEffect(() => { + return () => { + if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) { + useViewer.getState().setHoveredId(null) + } + } + }, []) + + const firstPersonColliderMeshes = useMemo( + () => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes), + [world, elevatorColliderMeshes], + ) + + if (isDroneMode || !world) { + return null + } + + return ( + <> + {controllerStart && ( + + + + )} + + ) +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 5d31f69c4b..ff35c9f087 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -58,6 +58,10 @@ export { STAND_FLOAT_HEIGHT, WALKTHROUGH_FOV, } from './components/viewer/glb-walkthrough-controller' +export { + LiveWalkthroughController, + type LiveWalkthroughControllerProps, +} from './components/viewer/live-walkthrough-controller' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { DEFAULT_HOVER_STYLES, @@ -329,6 +333,7 @@ export { textureMapForSlot, } from './lib/texture-reference' export { packNormalToRGB, unpackRGBToNormal } from './lib/tsl-compat' +export { isTypingTarget } from './lib/typing-target' export { AMBIANCE_SECONDS, ambianceNow, @@ -350,6 +355,11 @@ export { type ViewerConfig, } from './lib/viewer-config' export { createZoneShape, createZoneWallGeometry } from './lib/zone-geometry' +export { + type FirstPersonHudState, + useFirstPersonHud, + type WalkthroughInteract, +} from './store/use-first-person-hud' export type { LightSource } from './store/use-item-light-pool' export { catalogLightSource, useItemLightPool } from './store/use-item-light-pool' export { diff --git a/packages/editor/src/lib/typing-target.ts b/packages/viewer/src/lib/typing-target.ts similarity index 100% rename from packages/editor/src/lib/typing-target.ts rename to packages/viewer/src/lib/typing-target.ts diff --git a/packages/viewer/src/ui/store/use-first-person-hud.ts b/packages/viewer/src/store/use-first-person-hud.ts similarity index 100% rename from packages/viewer/src/ui/store/use-first-person-hud.ts rename to packages/viewer/src/store/use-first-person-hud.ts diff --git a/packages/viewer/src/ui/components/walkthrough-hud.tsx b/packages/viewer/src/ui/components/walkthrough-hud.tsx index 72cfee2436..5222c680f0 100644 --- a/packages/viewer/src/ui/components/walkthrough-hud.tsx +++ b/packages/viewer/src/ui/components/walkthrough-hud.tsx @@ -2,9 +2,9 @@ import { usePascalI18n } from '@pascal-app/core' import { type ReactNode, useMemo } from 'react' +import type { WalkthroughInteract } from '../../store/use-first-person-hud' import { cn } from '../lib/cn' import { displayRoomName } from '../lib/room-name-messages' -import type { WalkthroughInteract } from '../store/use-first-person-hud' /** A control shown in the HUD's key strip: its keys (as printed on the keycaps) and what they do. */ export type WalkthroughHint = { keys: readonly string[]; action: string } @@ -27,7 +27,10 @@ export type WalkthroughHudProps = { * capture overlay so walk / drone framing keeps the same E-to-open pointer. */ export function WalkthroughCrosshair({ interact }: { interact: WalkthroughInteract }) { return ( -
+
+
{floorLabel && (
diff --git a/packages/viewer/src/ui/index.ts b/packages/viewer/src/ui/index.ts index a96c6ace83..d6777c9c60 100644 --- a/packages/viewer/src/ui/index.ts +++ b/packages/viewer/src/ui/index.ts @@ -1,3 +1,8 @@ +export { + type FirstPersonHudState, + useFirstPersonHud, + type WalkthroughInteract, +} from '../store/use-first-person-hud' export { ActionButton } from './components/action-button' export { type ActionMenuPlacement, @@ -187,9 +192,4 @@ export { publishNavigationSyncPose, subscribeNavigationSyncPose, } from './store/navigation-sync-pose-store' -export { - type FirstPersonHudState, - useFirstPersonHud, - type WalkthroughInteract, -} from './store/use-first-person-hud' export { FOLLOW_PASCAL_STORAGE_KEY, useFollowPascal } from './store/use-follow-pascal' diff --git a/scripts/check-render-bundle.ts b/scripts/check-render-bundle.ts index cf6d314a58..e227e032bf 100644 --- a/scripts/check-render-bundle.ts +++ b/scripts/check-render-bundle.ts @@ -73,6 +73,7 @@ function rejectUninspectedLoads(path: string, contents: string) { for (const source of [ "const dep = globalThis.dep ?? '@pascal-app/editor'; export const lazy = () => import(dep)", + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text for the computed-import boundary probe 'export const lazy = (name) => import(`@pascal-app/${name}`)', "const dep = '@pascal-app/editor'; export const load = () => require(dep)", "export const modules = import.meta.glob('../editor/src/**/*.ts')", @@ -154,9 +155,9 @@ const result = await build({ 'render-consumer', ` import { loadPlugin, useScene } from '@pascal-app/core'; - import { Viewer } from '@pascal-app/viewer'; + import { LiveWalkthroughController, useFirstPersonHud, Viewer } from '@pascal-app/viewer'; import { renderPlugin } from '@pascal-app/nodes/render'; - export { Viewer, renderPlugin }; + export { LiveWalkthroughController, useFirstPersonHud, Viewer, renderPlugin }; export async function hydrate(scene) { await loadPlugin(renderPlugin); useScene.getState().setScene(scene); @@ -230,6 +231,9 @@ console.log(`Render bundle including all lazy chunks: ${bytes} bytes across ${fi const consumer = resolve(root, 'packages/nodes/render-consumer.ts') const consumerSource = ` import type { Plugin } from '@pascal-app/core'; + import { LiveWalkthroughController, useFirstPersonHud, type LiveWalkthroughControllerProps } from '@pascal-app/viewer'; + const walkProps: LiveWalkthroughControllerProps = { paused: false }; + export const walkthrough = { LiveWalkthroughController, useFirstPersonHud, walkProps }; import { renderPlugin } from '@pascal-app/nodes/render'; import { playSound, initInteractionSounds, useAudioSettings, type SoundName } from '@pascal-app/viewer/sfx'; const sound: SoundName = 'menuClick'; @@ -280,10 +284,20 @@ if (diagnostics.length) { }), ) } +if ( + !program + .getSourceFiles() + .some((file) => + file.fileName.endsWith('/viewer/dist/components/viewer/live-walkthrough-controller.d.ts'), + ) +) + throw new Error('Declaration consumer must resolve the built live walkthrough controller') if (!program.getSourceFiles().some((file) => file.fileName.endsWith('/nodes/dist/render.d.ts'))) throw new Error('Declaration consumer must resolve the built render export') if (!program.getSourceFiles().some((file) => file.fileName.endsWith('/viewer/dist/sfx/index.d.ts'))) throw new Error('Declaration consumer must resolve the built SFX export') if (!program.getSourceFiles().some((file) => file.fileName.endsWith('/viewer/dist/ui/index.d.ts'))) throw new Error('Declaration consumer must resolve the built UI export') -console.log('Render, SFX and UI declaration consumer: passed with editor/Next resolution forbidden') +console.log( + 'Render, live walkthrough, SFX and UI declaration consumer: passed with editor/Next resolution forbidden', +) diff --git a/scripts/check-viewer-live-fixture.ts b/scripts/check-viewer-live-fixture.ts index 2e0c60265a..279e602f08 100644 --- a/scripts/check-viewer-live-fixture.ts +++ b/scripts/check-viewer-live-fixture.ts @@ -214,13 +214,6 @@ try { if (errors.length) throw new Error(errors.join('\n')) if ((await page.evaluate(() => typeof (globalThis as any).process)) !== 'undefined') throw new Error('Fixture must run without a process polyfill') - await page.evaluate(() => (window as any).playFixtureSound()) - for (let i = 0; i < 50 && !soundRequests.includes('/fixture-sfx/door_open.mp3'); i++) - await page.waitForTimeout(100) - if (!soundRequests.includes('/fixture-sfx/door_open.mp3')) - throw new Error( - `Sound files did not load from sfxBaseUrl: ${soundRequests.join(', ')}`, - ) if (!iconRequests.includes('/icons/topview.webp')) throw new Error( `Viewer UI icons did not load from iconsBaseUrl: ${iconRequests.join(', ')}`, @@ -244,6 +237,57 @@ try { overlayBefore, { timeout: 10_000 }, ) + const documentBefore = await page.evaluate(() => (window as any).viewerFixture.document) + await page.click('[data-enter-walkthrough]') + await page.waitForFunction(() => { + const probe = (window as any).viewerFixture + return ( + probe?.walking && + probe.camera[1] > 1 && + probe.camera[1] < 2 && + document.pointerLockElement === document.querySelector('canvas') + ) + }) + await page.waitForSelector('[data-walkthrough-hud]', { state: 'visible' }) + await page.waitForSelector('[data-walkthrough-crosshair]', { state: 'visible' }) + // Wait for the initial capsule/eye placement before measuring keyboard movement. + const start: number[] = await page.evaluate(() => (window as any).viewerFixture.camera) + await page.keyboard.down('w') + try { + await page.waitForFunction( + (position) => { + const camera = (window as any).viewerFixture.camera + return Math.hypot(camera[0] - position[0], camera[2] - position[2]) > 0.3 + }, + start, + { timeout: 10_000 }, + ) + } finally { + await page.keyboard.up('w') + } + // The placed spawn faces the fixture's south-wall door. This must be the controller's + // own centre-ray target and E handler, not a synthetic sound request from the host. + await page.waitForFunction(() => + document.querySelector('[data-walkthrough-hud]')?.textContent?.includes('open door'), + ) + await page.keyboard.press('e') + await page.waitForFunction(() => + (window as any).viewerFixture.interactionSounds.includes('door-open'), + ) + for (let i = 0; i < 50 && !soundRequests.includes('/fixture-sfx/door_open.mp3'); i++) + await page.waitForTimeout(100) + if (!soundRequests.includes('/fixture-sfx/door_open.mp3')) + throw new Error( + `Door sound did not load from sfxBaseUrl: ${soundRequests.join(', ')}`, + ) + const documentAfter = await page.evaluate(() => (window as any).viewerFixture.document) + if (documentBefore !== documentAfter) + throw new Error( + 'Walkthrough movement/door interaction changed the read-only document', + ) + await page.keyboard.press('Escape') + await page.waitForFunction(() => !(window as any).viewerFixture.walking) + await page.waitForSelector('[data-walkthrough-hud]', { state: 'detached' }) // The packed stage: its switcher shows the read-only plan, drawn by renderPlugin's // floor-plan builders alone, then the plan beside the 3D view. const planShapes: Record = {} @@ -295,8 +339,8 @@ try { console.log( `${mode}, storage ${deniedStorage ? 'denied' : 'available'}: scene ready, ` + `non-empty node geometry, ${probe.drawCalls} draw calls, room pill, highlight ` + - `and callout, 2D and split plan (${JSON.stringify(planShapes)}), no errors ` + - `(${elapsed()})`, + `and callout, live walkthrough movement, HUD, door sound, unchanged document, ` + + `2D and split plan (${JSON.stringify(planShapes)}), no errors (${elapsed()})`, ) } catch (error) { console.error( diff --git a/wiki/architecture/viewer-isolation.md b/wiki/architecture/viewer-isolation.md index 45f9bbb317..1df031cd12 100644 --- a/wiki/architecture/viewer-isolation.md +++ b/wiki/architecture/viewer-isolation.md @@ -49,13 +49,39 @@ The read-only chrome (the 3D / 2D / Split stage and its floor plan, controls bar HUDs, loader, room pills and highlight, the viewer selection model and the primitives they use) is the `@pascal-app/viewer/ui` entry, styled with Tailwind and its shipped `theme.css`. It is editor-agnostic like the rest of the package: the editor imports its stores and controls from there -(one camera-pose, 2D↔3D navigation-pose, first-person HUD and Follow Pascal store), never the -reverse. The plan's SVG geometry renderer is the one the editor's floor plan draws with; the editor -layers its editing on top (hit targets, handles, drags, snapping) and turns on the typeable -dimension hit rects with `FloorplanDimensionHitsProvider`, which read-only plans leave off. +(one camera-pose, 2D↔3D navigation-pose and Follow Pascal store), never the reverse. The first-person +HUD store lives in the main viewer entry and UI re-exports it, so controllers never import UI chrome. +The plan's SVG geometry renderer is the one the editor's floor plan draws with; the editor layers +its editing on top (hit targets, handles, drags, snapping) and turns on the typeable dimension hit +rects with `FloorplanDimensionHitsProvider`, which read-only plans leave off. Router pieces come in as props (`linkComponent`); Biome bans `next` in the package and the bundle gate builds the entry with editor/Next resolution forbidden. +## Live walkthrough ownership + +`LiveWalkthroughController` belongs to the main `@pascal-app/viewer` entry alongside +`GlbWalkthroughController`: it is a 3D controller mounted inside `Viewer`, not UI chrome. It reads +`useViewer.walkthroughMode` and `walkthroughMovement`, builds collision geometry from the live +registry, and operates transient door/window/item/mechanism/elevator state. The render-only +`renderPlugin` supplies everything it needs. Walking never writes per-frame scene documents +(E-007) or changes their schema (E-003). + +The main entry owns `useFirstPersonHud`; `viewer/ui` re-exports that same store and renders +`WalkthroughHud`/`DroneHud` outside the canvas. Controllers update the store without importing UI. +Interaction sounds still travel through core `interaction:sound` to the optional `viewer/sfx` entry. +Graph-only XZ bounds live in core; the DOM typing-target predicate lives in viewer. + +Editor `FirstPersonControls` is a thin adapter: capture shutter hold, FOV ownership, reserved +pointer clicks, unlock suspension and editor exit/tool toggles stay there. The shared controller +accepts neutral `paused`, `externalFov`, `pointerInteraction`, `suspendOnUnlock` and `onExit` +props. There is one live implementation of movement, collision and interactions. Hosts disable +other camera controls while walkthrough is active and choose their own entry/pointer-lock UI. + +The render-bundle gate imports the built controller in both runtime and declaration consumers +with editor/Next resolution forbidden. The packed live fixture enters walkthrough with only +`renderPlugin`, checks keyboard camera movement, HUD/crosshair, a door's sound cue and unchanged +scene nodes in dev/production with normal/denied storage. + ## Live graph bootstrap and ownership ```tsx From 62ccee61a9c6769a21bd42b46400e67ffc32e5f5 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 11 Nov 2026 10:51:07 -0500 Subject: [PATCH 2/3] i18n: move the walkthrough literals' baseline to the viewer controller Co-Authored-By: Claude Opus 5.5 --- i18n/literal-baseline.json | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/i18n/literal-baseline.json b/i18n/literal-baseline.json index 6f141c7356..49ded54eba 100644 --- a/i18n/literal-baseline.json +++ b/i18n/literal-baseline.json @@ -149,13 +149,7 @@ "{hosted} {?} on it. Keep {it} at {its} height, or delete {it} too." ], "packages/editor/src/components/editor/first-person-controls.tsx": [ - "Lights", - "Place a spawn point from the Build tab to control where walkthrough starts.", - "door", - "door button", - "elevator button", - "item", - "window" + "Place a spawn point from the Build tab to control where walkthrough starts." ], "packages/editor/src/components/editor/floorplan-mode-coordinator.tsx": [ "Dismiss", @@ -798,6 +792,14 @@ ], "packages/viewer/src/ui/floorplan/floorplan-dimension-renderer.tsx": [ "override" + ], + "packages/viewer/src/components/viewer/live-walkthrough-controller.tsx": [ + "Lights", + "door", + "door button", + "elevator button", + "item", + "window" ] } } From f2eb986ffa5bb5acfb1cf0d64d5784a3ef34addf Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 11 Nov 2026 11:02:11 -0500 Subject: [PATCH 3/3] viewer: walkthrough reads host FOV ownership when it ends externalFov becomes a getter read at walkthrough start and teardown, as the editor did with capture mode, so leaving a walkthrough after closing capture restores the orbit FOV instead of keeping the walkthrough value. Co-Authored-By: Claude Opus 5.5 --- .../components/editor/first-person-controls.tsx | 4 +++- packages/viewer/README.md | 2 +- .../viewer/live-walkthrough-controller.test.tsx | 16 +++++++++++++++- .../viewer/live-walkthrough-controller.tsx | 15 ++++++++++----- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 7feb3f397e..04c9a01e87 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -9,6 +9,8 @@ import useEditor from '../../store/use-editor' export function FirstPersonControls() { const capture = useEditor((state) => state.isCaptureMode) const paused = useEditor((state) => state.captureShutterHold) + // Read at walkthrough start and end, as the capture rig may own the FOV by then. + const captureOwnsFov = useCallback(() => useEditor.getState().isCaptureMode, []) const onExit = useCallback(() => { const editor = useEditor.getState() if (editor.isCaptureMode) editor.setCaptureMode(false) @@ -17,7 +19,7 @@ export function FirstPersonControls() { return ( { const exits: boolean[] = [] const camera = await mount({ - externalFov: true, + externalFov: () => true, suspendOnUnlock: true, onExit: () => exits.push(useViewer.getState().walkthroughMode), }) @@ -156,3 +156,17 @@ test('main and UI exports share one HUD store and suppress equivalent updates', uiHud.getState().reset() expect(useFirstPersonHud.getState().interact).toBeNull() }) + +test('the orbit FOV returns when the host gave FOV back before walkthrough ends', async () => { + let hostOwnsFov = false + const camera = await mount({ externalFov: () => hostOwnsFov }) + await enterDrone() + expect(camera.fov).not.toBe(47) + hostOwnsFov = true + camera.fov = 32 + hostOwnsFov = false + lock() + await act(async () => key('Escape')) + expect(useViewer.getState().walkthroughMode).toBe(false) + expect(camera.fov).toBe(47) +}) diff --git a/packages/viewer/src/components/viewer/live-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/live-walkthrough-controller.tsx index 4949164c35..5f77142040 100644 --- a/packages/viewer/src/components/viewer/live-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/live-walkthrough-controller.tsx @@ -751,8 +751,11 @@ const resolvePlacedSpawnNode = (nodes: ReturnType['nod export type LiveWalkthroughControllerProps = { /** Freeze movement and look while a host renders a still. */ paused?: boolean - /** The host owns camera FOV, including restoration when this controller unmounts. */ - externalFov?: boolean + /** + * Whether the host owns camera FOV right now (e.g. a capture rig). Read when walkthrough + * starts and when it ends, so the answer at teardown decides whether the orbit FOV returns. + */ + externalFov?: () => boolean /** Allow locked-pointer clicks to operate the targeted object (E/R still work). */ pointerInteraction?: boolean /** Keep walkthrough active when pointer lock is lost; Escape with a free cursor exits. */ @@ -762,6 +765,8 @@ export type LiveWalkthroughControllerProps = { } /** Live scene walking, collision and interaction. Mount inside Viewer; useViewer owns activation. */ +const noExternalFov = () => false + export function LiveWalkthroughController(props: LiveWalkthroughControllerProps) { const active = useViewer((state) => state.walkthroughMode) return active ? : null @@ -769,7 +774,7 @@ export function LiveWalkthroughController(props: LiveWalkthroughControllerProps) function LiveWalkthroughSession({ paused = false, - externalFov = false, + externalFov = noExternalFov, pointerInteraction = true, suspendOnUnlock = false, onExit, @@ -836,12 +841,12 @@ function LiveWalkthroughSession({ useEffect(() => { const perspectiveCamera = camera as PerspectiveCamera if (!perspectiveCamera.isPerspectiveCamera) return - if (hostRef.current.externalFov) return + if (hostRef.current.externalFov()) return const previousFov = perspectiveCamera.fov perspectiveCamera.fov = WALKTHROUGH_FOV perspectiveCamera.updateProjectionMatrix() return () => { - if (hostRef.current.externalFov) return + if (hostRef.current.externalFov()) return perspectiveCamera.fov = previousFov perspectiveCamera.updateProjectionMatrix() }