@pascal-app/viewer 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -0
- package/dist/components/viewer/drone-controller.d.ts.map +1 -1
- package/dist/components/viewer/drone-controller.js +1 -5
- package/dist/components/viewer/first-person/build-collider-world.d.ts +14 -0
- package/dist/components/viewer/first-person/build-collider-world.d.ts.map +1 -0
- package/dist/components/viewer/first-person/build-collider-world.js +426 -0
- package/dist/components/viewer/first-person/ground-holes.d.ts +22 -0
- package/dist/components/viewer/first-person/ground-holes.d.ts.map +1 -0
- package/dist/components/viewer/first-person/ground-holes.js +116 -0
- package/dist/components/viewer/first-person/mechanism-targets.d.ts +27 -0
- package/dist/components/viewer/first-person/mechanism-targets.d.ts.map +1 -0
- package/dist/components/viewer/first-person/mechanism-targets.js +79 -0
- package/dist/components/viewer/first-person/terrain-collider.d.ts +32 -0
- package/dist/components/viewer/first-person/terrain-collider.d.ts.map +1 -0
- package/dist/components/viewer/first-person/terrain-collider.js +175 -0
- package/dist/components/viewer/glb-walkthrough-controller.d.ts +1 -1
- package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
- package/dist/components/viewer/glb-walkthrough-controller.js +3 -4
- package/dist/components/viewer/live-walkthrough-controller.d.ts +17 -0
- package/dist/components/viewer/live-walkthrough-controller.d.ts.map +1 -0
- package/dist/components/viewer/live-walkthrough-controller.js +1449 -0
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +22 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/lib/daylight/room-map.d.ts +29 -4
- package/dist/lib/daylight/room-map.d.ts.map +1 -1
- package/dist/lib/daylight/room-map.js +219 -36
- package/dist/lib/daylight/room-scene.d.ts.map +1 -1
- package/dist/lib/daylight/room-scene.js +9 -3
- package/dist/lib/daylight/rooms.d.ts +15 -6
- package/dist/lib/daylight/rooms.d.ts.map +1 -1
- package/dist/lib/daylight/rooms.js +49 -9
- package/dist/lib/measurement-presentation.d.ts +2 -0
- package/dist/lib/measurement-presentation.d.ts.map +1 -1
- package/dist/lib/measurement-presentation.js +6 -0
- package/dist/lib/typing-target.d.ts +2 -0
- package/dist/lib/typing-target.d.ts.map +1 -0
- package/dist/lib/typing-target.js +7 -0
- package/dist/lib/viewer-config.d.ts +6 -0
- package/dist/lib/viewer-config.d.ts.map +1 -1
- package/dist/lib/viewer-config.js +2 -0
- package/dist/sfx/audio-settings.d.ts +81 -0
- package/dist/sfx/audio-settings.d.ts.map +1 -0
- package/dist/sfx/audio-settings.js +166 -0
- package/dist/sfx/index.d.ts +6 -0
- package/dist/sfx/index.d.ts.map +1 -0
- package/dist/sfx/index.js +5 -0
- package/dist/sfx/interaction-sounds.d.ts +7 -0
- package/dist/sfx/interaction-sounds.d.ts.map +1 -0
- package/dist/sfx/interaction-sounds.js +39 -0
- package/dist/sfx/player.d.ts +31 -0
- package/dist/sfx/player.d.ts.map +1 -0
- package/dist/sfx/player.js +255 -0
- package/dist/sfx/sounds.d.ts +60 -0
- package/dist/sfx/sounds.d.ts.map +1 -0
- package/dist/sfx/sounds.js +25 -0
- package/dist/sfx/use-viewer-ui-sounds.d.ts +8 -0
- package/dist/sfx/use-viewer-ui-sounds.d.ts.map +1 -0
- package/dist/sfx/use-viewer-ui-sounds.js +42 -0
- package/dist/store/use-first-person-hud.d.ts +13 -0
- package/dist/store/use-first-person-hud.d.ts.map +1 -0
- package/dist/store/use-first-person-hud.js +21 -0
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +27 -13
- package/dist/ui/components/action-button.d.ts +14 -0
- package/dist/ui/components/action-button.d.ts.map +1 -0
- package/dist/ui/components/action-button.js +21 -0
- package/dist/ui/components/action-menu-placement.d.ts +7 -0
- package/dist/ui/components/action-menu-placement.d.ts.map +1 -0
- package/dist/ui/components/action-menu-placement.js +10 -0
- package/dist/ui/components/floorplan-compass-button.d.ts +10 -0
- package/dist/ui/components/floorplan-compass-button.d.ts.map +1 -0
- package/dist/ui/components/floorplan-compass-button.js +22 -0
- package/dist/ui/components/follow-pascal-toggle.d.ts +6 -0
- package/dist/ui/components/follow-pascal-toggle.d.ts.map +1 -0
- package/dist/ui/components/follow-pascal-toggle.js +21 -0
- package/dist/ui/components/localized-html.d.ts +9 -0
- package/dist/ui/components/localized-html.d.ts.map +1 -0
- package/dist/ui/components/localized-html.js +16 -0
- package/dist/ui/components/render-settings-menu.d.ts +9 -0
- package/dist/ui/components/render-settings-menu.d.ts.map +1 -0
- package/dist/ui/components/render-settings-menu.js +145 -0
- package/dist/ui/components/rooms/room-highlight.d.ts +10 -0
- package/dist/ui/components/rooms/room-highlight.d.ts.map +1 -0
- package/dist/ui/components/rooms/room-highlight.js +56 -0
- package/dist/ui/components/rooms/room-labels.d.ts +278 -0
- package/dist/ui/components/rooms/room-labels.d.ts.map +1 -0
- package/dist/ui/components/rooms/room-labels.js +1295 -0
- package/dist/ui/components/rooms/viewer-rooms.d.ts +14 -0
- package/dist/ui/components/rooms/viewer-rooms.d.ts.map +1 -0
- package/dist/ui/components/rooms/viewer-rooms.js +123 -0
- package/dist/ui/components/scene-loader.d.ts +18 -0
- package/dist/ui/components/scene-loader.d.ts.map +1 -0
- package/dist/ui/components/scene-loader.js +29 -0
- package/dist/ui/components/sky-control.d.ts +37 -0
- package/dist/ui/components/sky-control.d.ts.map +1 -0
- package/dist/ui/components/sky-control.js +382 -0
- package/dist/ui/components/use-viewer-camera-navigation-sync.d.ts +4 -0
- package/dist/ui/components/use-viewer-camera-navigation-sync.d.ts.map +1 -0
- package/dist/ui/components/use-viewer-camera-navigation-sync.js +117 -0
- package/dist/ui/components/viewer-controls-bar.d.ts +20 -0
- package/dist/ui/components/viewer-controls-bar.d.ts.map +1 -0
- package/dist/ui/components/viewer-controls-bar.js +130 -0
- package/dist/ui/components/viewer-hover-label.d.ts +14 -0
- package/dist/ui/components/viewer-hover-label.d.ts.map +1 -0
- package/dist/ui/components/viewer-hover-label.js +28 -0
- package/dist/ui/components/viewer-scene-header.d.ts +34 -0
- package/dist/ui/components/viewer-scene-header.d.ts.map +1 -0
- package/dist/ui/components/viewer-scene-header.js +92 -0
- package/dist/ui/components/viewer-selection-manager.d.ts +7 -0
- package/dist/ui/components/viewer-selection-manager.d.ts.map +1 -0
- package/dist/ui/components/viewer-selection-manager.js +116 -0
- package/dist/ui/components/viewer-stage-modes.d.ts +7 -0
- package/dist/ui/components/viewer-stage-modes.d.ts.map +1 -0
- package/dist/ui/components/viewer-stage-modes.js +42 -0
- package/dist/ui/components/viewer-stage-switcher.d.ts +10 -0
- package/dist/ui/components/viewer-stage-switcher.d.ts.map +1 -0
- package/dist/ui/components/viewer-stage-switcher.js +16 -0
- package/dist/ui/components/viewer-stage.d.ts +25 -0
- package/dist/ui/components/viewer-stage.d.ts.map +1 -0
- package/dist/ui/components/viewer-stage.js +107 -0
- package/dist/ui/components/walkthrough-hud.d.ts +29 -0
- package/dist/ui/components/walkthrough-hud.d.ts.map +1 -0
- package/dist/ui/components/walkthrough-hud.js +55 -0
- package/dist/ui/floorplan/floorplan-camera-sync.d.ts +22 -0
- package/dist/ui/floorplan/floorplan-camera-sync.d.ts.map +1 -0
- package/dist/ui/floorplan/floorplan-camera-sync.js +157 -0
- package/dist/ui/floorplan/floorplan-dimension-renderer.d.ts +61 -0
- package/dist/ui/floorplan/floorplan-dimension-renderer.d.ts.map +1 -0
- package/dist/ui/floorplan/floorplan-dimension-renderer.js +280 -0
- package/dist/ui/floorplan/floorplan-geometry-renderer.d.ts +54 -0
- package/dist/ui/floorplan/floorplan-geometry-renderer.d.ts.map +1 -0
- package/dist/ui/floorplan/floorplan-geometry-renderer.js +317 -0
- package/dist/ui/floorplan/floorplan-label-angle.d.ts +24 -0
- package/dist/ui/floorplan/floorplan-label-angle.d.ts.map +1 -0
- package/dist/ui/floorplan/floorplan-label-angle.js +60 -0
- package/dist/ui/floorplan/floorplan-navigation-sync.d.ts +18 -0
- package/dist/ui/floorplan/floorplan-navigation-sync.d.ts.map +1 -0
- package/dist/ui/floorplan/floorplan-navigation-sync.js +41 -0
- package/dist/ui/floorplan/floorplan-preview-geometry.d.ts +26 -0
- package/dist/ui/floorplan/floorplan-preview-geometry.d.ts.map +1 -0
- package/dist/ui/floorplan/floorplan-preview-geometry.js +173 -0
- package/dist/ui/floorplan/floorplan-preview-navigation.d.ts +22 -0
- package/dist/ui/floorplan/floorplan-preview-navigation.d.ts.map +1 -0
- package/dist/ui/floorplan/floorplan-preview-navigation.js +41 -0
- package/dist/ui/floorplan/floorplan-preview-visibility.d.ts +8 -0
- package/dist/ui/floorplan/floorplan-preview-visibility.d.ts.map +1 -0
- package/dist/ui/floorplan/floorplan-preview-visibility.js +21 -0
- package/dist/ui/floorplan/floorplan-preview.d.ts +19 -0
- package/dist/ui/floorplan/floorplan-preview.d.ts.map +1 -0
- package/dist/ui/floorplan/floorplan-preview.js +700 -0
- package/dist/ui/hooks/use-mobile.d.ts +2 -0
- package/dist/ui/hooks/use-mobile.d.ts.map +1 -0
- package/dist/ui/hooks/use-mobile.js +14 -0
- package/dist/ui/index.d.ts +49 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +48 -0
- package/dist/ui/lib/below-grade.d.ts +38 -0
- package/dist/ui/lib/below-grade.d.ts.map +1 -0
- package/dist/ui/lib/below-grade.js +102 -0
- package/dist/ui/lib/camera-pose.d.ts +19 -0
- package/dist/ui/lib/camera-pose.d.ts.map +1 -0
- package/dist/ui/lib/camera-pose.js +113 -0
- package/dist/ui/lib/cn.d.ts +3 -0
- package/dist/ui/lib/cn.d.ts.map +1 -0
- package/dist/ui/lib/cn.js +5 -0
- package/dist/ui/lib/level-name.d.ts +10 -0
- package/dist/ui/lib/level-name.d.ts.map +1 -0
- package/dist/ui/lib/level-name.js +33 -0
- package/dist/ui/lib/node-display-label.d.ts +8 -0
- package/dist/ui/lib/node-display-label.d.ts.map +1 -0
- package/dist/ui/lib/node-display-label.js +36 -0
- package/dist/ui/lib/room-area.d.ts +3 -0
- package/dist/ui/lib/room-area.d.ts.map +1 -0
- package/dist/ui/lib/room-area.js +8 -0
- package/dist/ui/lib/room-assembly-overlay.d.ts +46 -0
- package/dist/ui/lib/room-assembly-overlay.d.ts.map +1 -0
- package/dist/ui/lib/room-assembly-overlay.js +222 -0
- package/dist/ui/lib/room-name-messages.d.ts +28 -0
- package/dist/ui/lib/room-name-messages.d.ts.map +1 -0
- package/dist/ui/lib/room-name-messages.js +112 -0
- package/dist/ui/lib/room-selection.d.ts +58 -0
- package/dist/ui/lib/room-selection.d.ts.map +1 -0
- package/dist/ui/lib/room-selection.js +297 -0
- package/dist/ui/lib/scene-theme-name.d.ts +4 -0
- package/dist/ui/lib/scene-theme-name.d.ts.map +1 -0
- package/dist/ui/lib/scene-theme-name.js +22 -0
- package/dist/ui/lib/selection-modifiers.d.ts +20 -0
- package/dist/ui/lib/selection-modifiers.d.ts.map +1 -0
- package/dist/ui/lib/selection-modifiers.js +15 -0
- package/dist/ui/lib/viewer-selection.d.ts +132 -0
- package/dist/ui/lib/viewer-selection.d.ts.map +1 -0
- package/dist/ui/lib/viewer-selection.js +462 -0
- package/dist/ui/lib/zone-content.d.ts +6 -0
- package/dist/ui/lib/zone-content.d.ts.map +1 -0
- package/dist/ui/lib/zone-content.js +96 -0
- package/dist/ui/primitives/button.d.ts +11 -0
- package/dist/ui/primitives/button.d.ts.map +1 -0
- package/dist/ui/primitives/button.js +35 -0
- package/dist/ui/primitives/dropdown-menu.d.ts +26 -0
- package/dist/ui/primitives/dropdown-menu.d.ts.map +1 -0
- package/dist/ui/primitives/dropdown-menu.js +53 -0
- package/dist/ui/primitives/popover.d.ts +8 -0
- package/dist/ui/primitives/popover.d.ts.map +1 -0
- package/dist/ui/primitives/popover.js +17 -0
- package/dist/ui/primitives/switch.d.ts +5 -0
- package/dist/ui/primitives/switch.d.ts.map +1 -0
- package/dist/ui/primitives/switch.js +8 -0
- package/dist/ui/primitives/tooltip.d.ts +8 -0
- package/dist/ui/primitives/tooltip.d.ts.map +1 -0
- package/dist/ui/primitives/tooltip.js +17 -0
- package/dist/ui/store/camera-pose-store.d.ts +17 -0
- package/dist/ui/store/camera-pose-store.d.ts.map +1 -0
- package/dist/ui/store/camera-pose-store.js +15 -0
- package/dist/ui/store/navigation-sync-pose-store.d.ts +26 -0
- package/dist/ui/store/navigation-sync-pose-store.d.ts.map +1 -0
- package/dist/ui/store/navigation-sync-pose-store.js +16 -0
- package/dist/ui/store/use-follow-pascal.d.ts +30 -0
- package/dist/ui/store/use-follow-pascal.d.ts.map +1 -0
- package/dist/ui/store/use-follow-pascal.js +28 -0
- package/dist/ui/theme.css +339 -0
- package/package.json +34 -7
|
@@ -0,0 +1,1449 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { closeDoorOpenState, closeWindowOpenState, DOOR_SWING_OPEN_ANGLE, emitter, getDisplayedDoorValue, getDisplayedWindowValue, getElevatorCabCenterZ, getElevatorCabDepth, getElevatorCabWidth, getElevatorDoorLeafSides, getElevatorDoorLeafWidth, getElevatorDoorLeafX, getElevatorShaftDepth, getElevatorShaftWallThickness, getElevatorShaftWidth, getLevelDisplayName, getLevelElevations, getResolvedElevatorDoorStyle, isOperableWindowType, isOperationDoorType, itemPrompt, nodeMechanism, openElevatorDoor, operateItem, pointInPolygon2D, requestElevatorLevel, resolveElevatorDispatchTarget, resolveElevatorLevels, sceneRegistry, toggleDoorOpenState, toggleNodeMechanism, toggleWindowOpenState, useInteractive, useScene, } from '@pascal-app/core';
|
|
4
|
+
import { operablePartFor, operableParts, } from '@pascal-app/core/procedural-items';
|
|
5
|
+
import { KeyboardControls } from '@react-three/drei';
|
|
6
|
+
import { useFrame, useThree } from '@react-three/fiber';
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
8
|
+
import { Box3, BoxGeometry, Euler, Matrix4, Mesh, MeshBasicMaterial, Ray, Raycaster, Vector2, Vector3, } from 'three';
|
|
9
|
+
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh';
|
|
10
|
+
import { createDroneInput, DRONE_SLOW_MULTIPLIER, seedDroneLook, stepDroneFlight, turnFirstPersonLook, } from '../../lib/drone-flight';
|
|
11
|
+
import { setSurfaceRaycastLayers } from '../../lib/layers';
|
|
12
|
+
import { isTypingTarget } from '../../lib/typing-target';
|
|
13
|
+
import { useFirstPersonHud } from '../../store/use-first-person-hud';
|
|
14
|
+
import useViewer from '../../store/use-viewer';
|
|
15
|
+
import { openingSettled, proceduralSettled } from '../../systems/interactive/opening-settled';
|
|
16
|
+
import BVHEcctrl, {} from './bvh-ecctrl';
|
|
17
|
+
import { buildFirstPersonColliderWorldFromRegistry, deriveFirstPersonSpawn, FIRST_PERSON_SPAWN_EYE_HEIGHT, } from './first-person/build-collider-world';
|
|
18
|
+
import { hasNativeLeaf, mechanismHudInteract, mechanismTargetIds, nativeOpeningIds, watchScriptedDoors, } from './first-person/mechanism-targets';
|
|
19
|
+
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';
|
|
20
|
+
import { applyWalkthroughCameraClipping } from './viewer-camera';
|
|
21
|
+
const CAMERA_EYE_OFFSET = 0.45;
|
|
22
|
+
const CONTROLLER_CENTER_FROM_EYE = 0.85;
|
|
23
|
+
const DOOR_INTERACTION_DISTANCE = 2.5;
|
|
24
|
+
const DOOR_LEAF_INTERACTION_DEPTH = 0.08;
|
|
25
|
+
const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18;
|
|
26
|
+
const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14;
|
|
27
|
+
const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08;
|
|
28
|
+
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12;
|
|
29
|
+
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72;
|
|
30
|
+
const VOID_FALL_RESPAWN_DEPTH = 12;
|
|
31
|
+
const HUD_LABEL_SAMPLE_FRAMES = 10;
|
|
32
|
+
const movementKeyboardBindings = [
|
|
33
|
+
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
|
34
|
+
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
|
35
|
+
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
|
|
36
|
+
{ name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
|
|
37
|
+
{ name: 'jump', keys: ['Space'] },
|
|
38
|
+
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
|
|
39
|
+
];
|
|
40
|
+
const keyboardMap = movementKeyboardBindings;
|
|
41
|
+
const movementKeyToName = new Map(movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name])));
|
|
42
|
+
const inactiveMovementInput = {
|
|
43
|
+
backward: false,
|
|
44
|
+
forward: false,
|
|
45
|
+
jump: false,
|
|
46
|
+
leftward: false,
|
|
47
|
+
rightward: false,
|
|
48
|
+
run: false,
|
|
49
|
+
};
|
|
50
|
+
function getMovementInputForKey(code, active) {
|
|
51
|
+
const name = movementKeyToName.get(code);
|
|
52
|
+
return name ? { [name]: active } : null;
|
|
53
|
+
}
|
|
54
|
+
function focusFirstPersonCanvas(canvas) {
|
|
55
|
+
const activeElement = document.activeElement;
|
|
56
|
+
if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) {
|
|
57
|
+
activeElement.blur();
|
|
58
|
+
}
|
|
59
|
+
if (!canvas.hasAttribute('tabindex')) {
|
|
60
|
+
canvas.tabIndex = -1;
|
|
61
|
+
}
|
|
62
|
+
canvas.focus({ preventScroll: true });
|
|
63
|
+
}
|
|
64
|
+
const cameraOffset = new Vector3();
|
|
65
|
+
const cameraEuler = new Euler(0, 0, 0, 'YXZ');
|
|
66
|
+
const droneInput = createDroneInput();
|
|
67
|
+
const standClearanceRaycaster = new Raycaster();
|
|
68
|
+
const standClearanceUp = new Vector3(0, 1, 0);
|
|
69
|
+
const centerScreenPoint = new Vector2(0, 0);
|
|
70
|
+
const doorInteractionRaycaster = new Raycaster();
|
|
71
|
+
setSurfaceRaycastLayers(doorInteractionRaycaster.layers);
|
|
72
|
+
const proceduralInteractionRaycaster = new Raycaster();
|
|
73
|
+
setSurfaceRaycastLayers(proceduralInteractionRaycaster.layers);
|
|
74
|
+
const doorLeafBox = new Box3();
|
|
75
|
+
const doorLeafInverseMatrix = new Matrix4();
|
|
76
|
+
const doorLeafLocalHit = new Vector3();
|
|
77
|
+
const doorLeafLocalRay = new Ray();
|
|
78
|
+
const doorLeafMatrix = new Matrix4();
|
|
79
|
+
const doorLeafWorldHit = new Vector3();
|
|
80
|
+
const doorOpeningBox = new Box3();
|
|
81
|
+
const doorOpeningInverseMatrix = new Matrix4();
|
|
82
|
+
const doorOpeningLocalHit = new Vector3();
|
|
83
|
+
const doorOpeningLocalRay = new Ray();
|
|
84
|
+
const doorOpeningMatrix = new Matrix4();
|
|
85
|
+
const doorOpeningWorldHit = new Vector3();
|
|
86
|
+
const elevatorLocalControllerPosition = new Vector3();
|
|
87
|
+
const elevatorInteractionRaycaster = new Raycaster();
|
|
88
|
+
const elevatorColliderMatrix = new Matrix4();
|
|
89
|
+
const elevatorColliderLocalMatrix = new Matrix4();
|
|
90
|
+
const elevatorLocalEyePosition = new Vector3();
|
|
91
|
+
const elevatorWorldControllerPosition = new Vector3();
|
|
92
|
+
const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false });
|
|
93
|
+
const spawnWorldPosition = new Vector3();
|
|
94
|
+
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ');
|
|
95
|
+
const windowInteractionRaycaster = new Raycaster();
|
|
96
|
+
setSurfaceRaycastLayers(windowInteractionRaycaster.layers);
|
|
97
|
+
const hudBuildingLocalEyePosition = new Vector3();
|
|
98
|
+
const hudWorldEyePosition = new Vector3();
|
|
99
|
+
const hudLevelBounds = new Box3();
|
|
100
|
+
function getLevelChildren(level, nodes) {
|
|
101
|
+
const childIds = new Set(level.children);
|
|
102
|
+
return Object.values(nodes).filter((node) => node.parentId === level.id || childIds.has(node.id));
|
|
103
|
+
}
|
|
104
|
+
function pointIsInLevelFootprint(point, worldPoint, level, nodes) {
|
|
105
|
+
const children = getLevelChildren(level, nodes);
|
|
106
|
+
const slabs = children.filter((node) => node.type === 'slab' && node.polygon.length >= 3);
|
|
107
|
+
const zones = children.filter((node) => node.type === 'zone' && node.polygon.length >= 3);
|
|
108
|
+
if (slabs.length > 0) {
|
|
109
|
+
return slabs.some((slab) => pointInPolygon2D(point, slab.polygon) &&
|
|
110
|
+
!slab.holes.some((hole) => pointInPolygon2D(point, hole)));
|
|
111
|
+
}
|
|
112
|
+
if (zones.length > 0) {
|
|
113
|
+
if (zones.some((zone) => pointInPolygon2D(point, zone.polygon)))
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
const levelObject = sceneRegistry.nodes.get(level.id);
|
|
117
|
+
if (!levelObject)
|
|
118
|
+
return false;
|
|
119
|
+
hudLevelBounds.setFromObject(levelObject);
|
|
120
|
+
return (!hudLevelBounds.isEmpty() &&
|
|
121
|
+
worldPoint.x >= hudLevelBounds.min.x &&
|
|
122
|
+
worldPoint.x <= hudLevelBounds.max.x &&
|
|
123
|
+
worldPoint.z >= hudLevelBounds.min.z &&
|
|
124
|
+
worldPoint.z <= hudLevelBounds.max.z);
|
|
125
|
+
}
|
|
126
|
+
function resolveFirstPersonHudLabels(worldPoint) {
|
|
127
|
+
const nodes = useScene.getState().nodes;
|
|
128
|
+
const levelElevations = getLevelElevations(nodes);
|
|
129
|
+
for (const building of Object.values(nodes)) {
|
|
130
|
+
if (building.type !== 'building')
|
|
131
|
+
continue;
|
|
132
|
+
const buildingObject = sceneRegistry.nodes.get(building.id);
|
|
133
|
+
if (!buildingObject)
|
|
134
|
+
continue;
|
|
135
|
+
buildingObject.updateWorldMatrix(true, true);
|
|
136
|
+
hudBuildingLocalEyePosition.copy(worldPoint);
|
|
137
|
+
buildingObject.worldToLocal(hudBuildingLocalEyePosition);
|
|
138
|
+
const levels = Object.values(nodes)
|
|
139
|
+
.filter((node) => node.type === 'level')
|
|
140
|
+
.filter((level) => levelElevations.get(level.id)?.buildingId === building.id)
|
|
141
|
+
.sort((left, right) => (levelElevations.get(left.id)?.baseY ?? 0) - (levelElevations.get(right.id)?.baseY ?? 0));
|
|
142
|
+
let activeLevel = null;
|
|
143
|
+
for (const level of levels) {
|
|
144
|
+
const elevation = levelElevations.get(level.id);
|
|
145
|
+
if (!elevation)
|
|
146
|
+
continue;
|
|
147
|
+
if (hudBuildingLocalEyePosition.y >= elevation.baseY - 0.5 &&
|
|
148
|
+
hudBuildingLocalEyePosition.y < elevation.baseY + elevation.height + 0.5) {
|
|
149
|
+
activeLevel = level;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!activeLevel)
|
|
153
|
+
continue;
|
|
154
|
+
const point = [hudBuildingLocalEyePosition.x, hudBuildingLocalEyePosition.z];
|
|
155
|
+
if (!pointIsInLevelFootprint(point, worldPoint, activeLevel, nodes))
|
|
156
|
+
continue;
|
|
157
|
+
const zone = getLevelChildren(activeLevel, nodes).find((node) => node.type === 'zone' && node.polygon.length >= 3 && pointInPolygon2D(point, node.polygon));
|
|
158
|
+
return {
|
|
159
|
+
floorLabel: getLevelDisplayName(activeLevel),
|
|
160
|
+
zoneLabel: zone?.type === 'zone' ? zone.name : null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { floorLabel: null, zoneLabel: null };
|
|
164
|
+
}
|
|
165
|
+
function resolveHudInteract(target) {
|
|
166
|
+
if (!target)
|
|
167
|
+
return null;
|
|
168
|
+
if (target.type === 'elevator') {
|
|
169
|
+
return {
|
|
170
|
+
label: target.action === 'open-door' ? 'door button' : 'elevator button',
|
|
171
|
+
verb: 'press',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const node = useScene.getState().nodes[target.id];
|
|
175
|
+
if (target.type === 'mechanism')
|
|
176
|
+
return node ? mechanismHudInteract(node) : null;
|
|
177
|
+
if (target.type === 'item') {
|
|
178
|
+
if (node?.type !== 'item' || !node.asset.interactive)
|
|
179
|
+
return null;
|
|
180
|
+
const { label, verb } = itemPrompt(target.id, node.name ?? node.asset.name, node.asset.interactive);
|
|
181
|
+
return { label, verb };
|
|
182
|
+
}
|
|
183
|
+
if (target.type === 'procedural') {
|
|
184
|
+
if (node?.type !== 'procedural-item')
|
|
185
|
+
return null;
|
|
186
|
+
const procedural = node;
|
|
187
|
+
const parts = operableParts(procedural.recipe);
|
|
188
|
+
if (parts.length === 0 && procedural.recipe.parts.some((part) => part.light)) {
|
|
189
|
+
const state = useInteractive.getState();
|
|
190
|
+
const isOn = state.procedural[target.id]?.lightsOn ?? state.lampDefault;
|
|
191
|
+
return { label: procedural.name ?? 'Lights', verb: isOn ? 'turn off' : 'turn on' };
|
|
192
|
+
}
|
|
193
|
+
const part = target.partId ? parts.find((entry) => entry.id === target.partId) : undefined;
|
|
194
|
+
const active = useInteractive.getState().procedural[target.id]?.parts;
|
|
195
|
+
const isOn = part ? Boolean(active?.[part.id]) : parts.some((entry) => active?.[entry.id]);
|
|
196
|
+
const kind = part?.kind ?? (parts.every((entry) => entry.kind === 'spin') ? 'spin' : 'hinge');
|
|
197
|
+
return {
|
|
198
|
+
label: part?.label ?? procedural.name ?? 'item',
|
|
199
|
+
verb: kind === 'spin' ? (isOn ? 'turn off' : 'turn on') : isOn ? 'close' : 'open',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (target.type === 'window') {
|
|
203
|
+
if (node?.type !== 'window')
|
|
204
|
+
return null;
|
|
205
|
+
const isOpen = getDisplayedWindowValue(target.id, node.operationState) > 0;
|
|
206
|
+
return { label: node.name || 'window', verb: isOpen ? 'close' : 'open' };
|
|
207
|
+
}
|
|
208
|
+
if (node?.type !== 'door')
|
|
209
|
+
return null;
|
|
210
|
+
const isOpen = isOperationDoorType(node.doorType)
|
|
211
|
+
? getDisplayedDoorValue(target.id, 'operationState', node.operationState) > 0
|
|
212
|
+
: getDisplayedDoorValue(target.id, 'swingAngle', node.swingAngle) > 0;
|
|
213
|
+
return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' };
|
|
214
|
+
}
|
|
215
|
+
/** E plays what the viewer's callout plays for the same door, window or switch. */
|
|
216
|
+
function playTargetSound(target) {
|
|
217
|
+
const verb = resolveHudInteract(target)?.verb;
|
|
218
|
+
if (!verb || verb === 'press')
|
|
219
|
+
return;
|
|
220
|
+
const node = useScene.getState().nodes[target.id];
|
|
221
|
+
// A recipe's door or lid swings and claps shut, its drawer slides; both land on the motion's end.
|
|
222
|
+
if (target.type === 'procedural' && (verb === 'open' || verb === 'close')) {
|
|
223
|
+
if (verb === 'open') {
|
|
224
|
+
emitter.emit('interaction:sound', 'panel-open');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (target.kind !== 'slide')
|
|
228
|
+
emitter.emit('interaction:sound', 'panel-swing');
|
|
229
|
+
void proceduralSettled(target.id).then(() => emitter.emit('interaction:sound', 'panel-close'));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
let sound;
|
|
233
|
+
if (verb !== 'open' && verb !== 'close')
|
|
234
|
+
sound = verb === 'turn on' || verb === 'play' ? 'switch-on' : 'switch-off';
|
|
235
|
+
else if (node?.type === 'door' && node.doorType?.startsWith('garage-'))
|
|
236
|
+
sound = 'garage';
|
|
237
|
+
else if (node?.type === 'window')
|
|
238
|
+
sound = verb === 'open' ? 'window-open' : 'window-close';
|
|
239
|
+
else
|
|
240
|
+
sound = verb === 'open' ? 'door-open' : 'door-close';
|
|
241
|
+
// A built-in leaf or sash swings shut, then latches on the frame it lands.
|
|
242
|
+
if ((sound === 'door-close' || sound === 'window-close') &&
|
|
243
|
+
(target.type === 'door' || target.type === 'window') &&
|
|
244
|
+
node) {
|
|
245
|
+
emitter.emit('interaction:sound', sound === 'door-close' ? 'door-swing' : 'window-swing');
|
|
246
|
+
void openingSettled(node).then(() => emitter.emit('interaction:sound', sound));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
emitter.emit('interaction:sound', sound);
|
|
250
|
+
}
|
|
251
|
+
function resolveElevatorButtonTarget(object) {
|
|
252
|
+
let current = object;
|
|
253
|
+
while (current) {
|
|
254
|
+
const candidate = current.userData.elevatorButton;
|
|
255
|
+
if (candidate?.disabled === true) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') {
|
|
259
|
+
const action = candidate.action === 'open-door' ? 'open-door' : 'request-level';
|
|
260
|
+
if (action === 'open-door') {
|
|
261
|
+
return {
|
|
262
|
+
action,
|
|
263
|
+
buttonKind: candidate.kind,
|
|
264
|
+
elevatorId: candidate.elevatorId,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (typeof candidate?.elevatorId === 'string' &&
|
|
269
|
+
typeof candidate.levelId === 'string' &&
|
|
270
|
+
(candidate.kind === 'cab' || candidate.kind === 'landing')) {
|
|
271
|
+
return {
|
|
272
|
+
action: 'request-level',
|
|
273
|
+
buttonKind: candidate.kind,
|
|
274
|
+
elevatorId: candidate.elevatorId,
|
|
275
|
+
levelId: candidate.levelId,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
current = current.parent;
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
function getInteractableTargetKey(target) {
|
|
283
|
+
if (!target)
|
|
284
|
+
return null;
|
|
285
|
+
return target.type === 'elevator'
|
|
286
|
+
? `${target.type}:${target.id}:${target.levelId}`
|
|
287
|
+
: target.type === 'procedural'
|
|
288
|
+
? `${target.type}:${target.id}:${target.partId ?? 'all'}`
|
|
289
|
+
: `${target.type}:${target.id}`;
|
|
290
|
+
}
|
|
291
|
+
function isDynamicElevatorCollider(kind) {
|
|
292
|
+
return kind.startsWith('cab-') || kind.startsWith('landing-door');
|
|
293
|
+
}
|
|
294
|
+
function isInsideElevatorCab(elevator, runtime, localEyePosition) {
|
|
295
|
+
const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING;
|
|
296
|
+
const halfDepth = getElevatorCabDepth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING;
|
|
297
|
+
const cabCenterZ = getElevatorCabCenterZ(elevator);
|
|
298
|
+
const cabHeight = Math.max(elevator.cabHeight, 1.4);
|
|
299
|
+
return (Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) &&
|
|
300
|
+
Math.abs(localEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) &&
|
|
301
|
+
localEyePosition.y >= runtime.carY + 0.35 &&
|
|
302
|
+
localEyePosition.y <= runtime.carY + cabHeight + 0.7);
|
|
303
|
+
}
|
|
304
|
+
function createElevatorColliderMesh(elevatorId, kind, size, localPosition, userData = {}) {
|
|
305
|
+
const geometry = new BoxGeometry(size[0], size[1], size[2]);
|
|
306
|
+
const bvhGeometry = geometry;
|
|
307
|
+
bvhGeometry.computeBoundsTree = computeBoundsTree;
|
|
308
|
+
bvhGeometry.disposeBoundsTree = disposeBoundsTree;
|
|
309
|
+
bvhGeometry.computeBoundsTree?.({
|
|
310
|
+
targetLeafSize: 12,
|
|
311
|
+
strategy: 0,
|
|
312
|
+
});
|
|
313
|
+
bvhGeometry.computeBoundingBox();
|
|
314
|
+
const mesh = new Mesh(bvhGeometry, elevatorColliderMaterial);
|
|
315
|
+
mesh.raycast = acceleratedRaycast;
|
|
316
|
+
mesh.matrixAutoUpdate = false;
|
|
317
|
+
mesh.visible = true;
|
|
318
|
+
mesh.userData = {
|
|
319
|
+
...userData,
|
|
320
|
+
dynamic: isDynamicElevatorCollider(kind),
|
|
321
|
+
elevatorId,
|
|
322
|
+
excludeCollisionCheck: false,
|
|
323
|
+
excludeFloatHit: false,
|
|
324
|
+
friction: 0.8,
|
|
325
|
+
kind,
|
|
326
|
+
localPosition,
|
|
327
|
+
matrixInitialized: false,
|
|
328
|
+
restitution: 0.03,
|
|
329
|
+
type: 'ELEVATOR_COLLIDER',
|
|
330
|
+
};
|
|
331
|
+
return mesh;
|
|
332
|
+
}
|
|
333
|
+
function buildElevatorColliderMeshes() {
|
|
334
|
+
const nodes = useScene.getState().nodes;
|
|
335
|
+
const meshes = [];
|
|
336
|
+
for (const elevatorId of sceneRegistry.byType.elevator) {
|
|
337
|
+
const typedElevatorId = elevatorId;
|
|
338
|
+
const node = nodes[typedElevatorId];
|
|
339
|
+
if (node?.type !== 'elevator' || node.visible === false)
|
|
340
|
+
continue;
|
|
341
|
+
const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorLevels(node, nodes);
|
|
342
|
+
const cabWidth = getElevatorCabWidth(node);
|
|
343
|
+
const cabDepth = getElevatorCabDepth(node);
|
|
344
|
+
const shaftWidth = getElevatorShaftWidth(node, cabWidth);
|
|
345
|
+
const shaftDepth = getElevatorShaftDepth(node, cabDepth);
|
|
346
|
+
const cabHeight = Math.max(node.cabHeight, 1.4);
|
|
347
|
+
const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18);
|
|
348
|
+
const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1);
|
|
349
|
+
const doorStyle = getResolvedElevatorDoorStyle(node.doorStyle);
|
|
350
|
+
const shaftHeight = Math.max(totalHeight, cabHeight + 0.3);
|
|
351
|
+
const wallThickness = getElevatorShaftWallThickness(node);
|
|
352
|
+
const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48);
|
|
353
|
+
const cabFloorDepth = Math.max(cabDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48);
|
|
354
|
+
const frontWallZ = -shaftDepth / 2 - wallThickness / 2;
|
|
355
|
+
const frontZ = frontWallZ - wallThickness / 2 - 0.018;
|
|
356
|
+
const cabCenterZ = -shaftDepth / 2 + cabDepth / 2;
|
|
357
|
+
const leafWidth = getElevatorDoorLeafWidth(doorWidth, doorStyle);
|
|
358
|
+
const doorLeafSides = getElevatorDoorLeafSides(doorStyle);
|
|
359
|
+
const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight);
|
|
360
|
+
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 })));
|
|
361
|
+
const entrySpans = entries.map((entry, index) => {
|
|
362
|
+
const nextEntry = entries[index + 1];
|
|
363
|
+
return {
|
|
364
|
+
entry,
|
|
365
|
+
levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24),
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
for (const { entry, levelTopY } of entrySpans) {
|
|
369
|
+
const wallDepth = wallThickness;
|
|
370
|
+
const levelHeight = Math.max(levelTopY - entry.baseY, doorHeight + 0.24);
|
|
371
|
+
const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08);
|
|
372
|
+
const jambCenterOffset = doorWidth / 2 + jambWidth / 2;
|
|
373
|
+
const headerHeight = Math.max(levelHeight - doorHeight, 0.14);
|
|
374
|
+
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 })));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return meshes;
|
|
378
|
+
}
|
|
379
|
+
function disposeElevatorColliderMeshes(meshes) {
|
|
380
|
+
for (const mesh of meshes) {
|
|
381
|
+
const geometry = mesh.geometry;
|
|
382
|
+
geometry.disposeBoundsTree?.();
|
|
383
|
+
geometry.dispose();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const resolvePlacedSpawnNode = (nodes) => {
|
|
387
|
+
const candidates = Object.values(nodes).filter((node) => node.type === 'spawn');
|
|
388
|
+
if (candidates.length === 0)
|
|
389
|
+
return null;
|
|
390
|
+
return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null;
|
|
391
|
+
};
|
|
392
|
+
/** Live scene walking, collision and interaction. Mount inside Viewer; useViewer owns activation. */
|
|
393
|
+
const noExternalFov = () => false;
|
|
394
|
+
export function LiveWalkthroughController(props) {
|
|
395
|
+
const active = useViewer((state) => state.walkthroughMode);
|
|
396
|
+
return active ? _jsx(LiveWalkthroughSession, { ...props }) : null;
|
|
397
|
+
}
|
|
398
|
+
function LiveWalkthroughSession({ paused = false, externalFov = noExternalFov, pointerInteraction = true, suspendOnUnlock = false, onExit, }) {
|
|
399
|
+
const hostRef = useRef({ paused, externalFov, pointerInteraction, suspendOnUnlock, onExit });
|
|
400
|
+
hostRef.current = { paused, externalFov, pointerInteraction, suspendOnUnlock, onExit };
|
|
401
|
+
const exit = useCallback(() => {
|
|
402
|
+
if (!useViewer.getState().walkthroughMode)
|
|
403
|
+
return;
|
|
404
|
+
useViewer.getState().setWalkthroughMode(false);
|
|
405
|
+
hostRef.current.onExit?.();
|
|
406
|
+
}, []);
|
|
407
|
+
const { camera, gl } = useThree();
|
|
408
|
+
const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes));
|
|
409
|
+
const isDroneMode = useViewer((state) => state.walkthroughMovement === 'drone');
|
|
410
|
+
const controllerRef = useRef(null);
|
|
411
|
+
const movementInputRef = useRef({ ...inactiveMovementInput });
|
|
412
|
+
const hadPointerLockRef = useRef(false);
|
|
413
|
+
const lookRef = useRef({ yaw: 0, pitch: 0 });
|
|
414
|
+
const interactableTargetRef = useRef(null);
|
|
415
|
+
const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1);
|
|
416
|
+
const crouchKeyRef = useRef(false);
|
|
417
|
+
const droneAscendKeyRef = useRef(false);
|
|
418
|
+
const droneSlowKeyRef = useRef(false);
|
|
419
|
+
const droneDescendKeyRef = useRef(false);
|
|
420
|
+
const droneVelocityRef = useRef(new Vector3());
|
|
421
|
+
const suspendRef = useRef(false);
|
|
422
|
+
const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET);
|
|
423
|
+
const [crouched, setCrouched] = useState(false);
|
|
424
|
+
const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false);
|
|
425
|
+
const ridingElevatorRef = useRef(null);
|
|
426
|
+
const rideLockedRef = useRef(false);
|
|
427
|
+
const worldRef = useRef(null);
|
|
428
|
+
const elevatorColliderMeshesRef = useRef([]);
|
|
429
|
+
const [world, setWorld] = useState(null);
|
|
430
|
+
const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState([]);
|
|
431
|
+
const [controllerStart, setControllerStart] = useState(null);
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
if (!camera.isPerspectiveCamera)
|
|
434
|
+
return;
|
|
435
|
+
return applyWalkthroughCameraClipping(camera);
|
|
436
|
+
}, [camera]);
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
const previousCameraMode = useViewer.getState().cameraMode;
|
|
439
|
+
if (previousCameraMode === 'orthographic') {
|
|
440
|
+
useViewer.getState().setCameraMode('perspective');
|
|
441
|
+
}
|
|
442
|
+
return () => {
|
|
443
|
+
if (previousCameraMode === 'orthographic') {
|
|
444
|
+
useViewer.getState().setCameraMode('orthographic');
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
}, []);
|
|
448
|
+
// Read the host's current FOV ownership at entry and teardown: changing it
|
|
449
|
+
// mid-session must not restore the orbit FOV underneath a host's framing rig.
|
|
450
|
+
useEffect(() => {
|
|
451
|
+
const perspectiveCamera = camera;
|
|
452
|
+
if (!perspectiveCamera.isPerspectiveCamera)
|
|
453
|
+
return;
|
|
454
|
+
if (hostRef.current.externalFov())
|
|
455
|
+
return;
|
|
456
|
+
const previousFov = perspectiveCamera.fov;
|
|
457
|
+
perspectiveCamera.fov = WALKTHROUGH_FOV;
|
|
458
|
+
perspectiveCamera.updateProjectionMatrix();
|
|
459
|
+
return () => {
|
|
460
|
+
if (hostRef.current.externalFov())
|
|
461
|
+
return;
|
|
462
|
+
perspectiveCamera.fov = previousFov;
|
|
463
|
+
perspectiveCamera.updateProjectionMatrix();
|
|
464
|
+
};
|
|
465
|
+
}, [camera]);
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
useFirstPersonHud.getState().reset();
|
|
468
|
+
return () => useFirstPersonHud.getState().reset();
|
|
469
|
+
}, []);
|
|
470
|
+
const replaceColliderWorld = useCallback((nextWorld) => {
|
|
471
|
+
worldRef.current?.dispose();
|
|
472
|
+
worldRef.current = nextWorld;
|
|
473
|
+
setWorld(nextWorld);
|
|
474
|
+
}, []);
|
|
475
|
+
const replaceElevatorColliderMeshes = useCallback((nextMeshes) => {
|
|
476
|
+
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current);
|
|
477
|
+
elevatorColliderMeshesRef.current = nextMeshes;
|
|
478
|
+
setElevatorColliderMeshes(nextMeshes);
|
|
479
|
+
}, []);
|
|
480
|
+
const rebuildColliderWorld = useCallback(() => {
|
|
481
|
+
replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry());
|
|
482
|
+
replaceElevatorColliderMeshes(buildElevatorColliderMeshes());
|
|
483
|
+
}, [replaceColliderWorld, replaceElevatorColliderMeshes]);
|
|
484
|
+
const setElevatorRideLocked = useCallback((locked) => {
|
|
485
|
+
if (rideLockedRef.current === locked)
|
|
486
|
+
return;
|
|
487
|
+
rideLockedRef.current = locked;
|
|
488
|
+
setIsElevatorRideLocked(locked);
|
|
489
|
+
}, []);
|
|
490
|
+
const setControllerApi = useCallback((api) => {
|
|
491
|
+
controllerRef.current = api;
|
|
492
|
+
if (api) {
|
|
493
|
+
api.setMovement(movementInputRef.current);
|
|
494
|
+
}
|
|
495
|
+
}, []);
|
|
496
|
+
const resolveInteractableDoorId = useCallback(() => {
|
|
497
|
+
const nodes = useScene.getState().nodes;
|
|
498
|
+
camera.updateMatrixWorld(true);
|
|
499
|
+
doorInteractionRaycaster.setFromCamera(centerScreenPoint, camera);
|
|
500
|
+
let closestDoorId = null;
|
|
501
|
+
let closestDistance = DOOR_INTERACTION_DISTANCE;
|
|
502
|
+
for (const doorId of sceneRegistry.byType.door) {
|
|
503
|
+
const node = nodes[doorId];
|
|
504
|
+
if (node?.type !== 'door')
|
|
505
|
+
continue;
|
|
506
|
+
if (node.openingKind === 'opening')
|
|
507
|
+
continue;
|
|
508
|
+
if (!hasNativeLeaf(node))
|
|
509
|
+
continue;
|
|
510
|
+
if (node.segments.every((segment) => segment.type === 'empty'))
|
|
511
|
+
continue;
|
|
512
|
+
const object = sceneRegistry.nodes.get(doorId);
|
|
513
|
+
if (!object)
|
|
514
|
+
continue;
|
|
515
|
+
object.updateWorldMatrix(true, true);
|
|
516
|
+
const placementHit = doorInteractionRaycaster
|
|
517
|
+
.intersectObject(object, true)
|
|
518
|
+
.find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE);
|
|
519
|
+
if (placementHit && placementHit.distance < closestDistance) {
|
|
520
|
+
closestDoorId = doorId;
|
|
521
|
+
closestDistance = placementHit.distance;
|
|
522
|
+
}
|
|
523
|
+
const leafW = node.width - 2 * node.frameThickness;
|
|
524
|
+
const leafH = node.height - node.frameThickness;
|
|
525
|
+
if (leafW <= 0 || leafH <= 0)
|
|
526
|
+
continue;
|
|
527
|
+
const leafCenterY = -node.frameThickness / 2;
|
|
528
|
+
if (isOperationDoorType(node.doorType)) {
|
|
529
|
+
doorOpeningMatrix
|
|
530
|
+
.copy(object.matrixWorld)
|
|
531
|
+
.multiply(new Matrix4().makeTranslation(0, leafCenterY, 0));
|
|
532
|
+
doorOpeningInverseMatrix.copy(doorOpeningMatrix).invert();
|
|
533
|
+
doorOpeningBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2);
|
|
534
|
+
doorOpeningBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2);
|
|
535
|
+
doorOpeningLocalRay
|
|
536
|
+
.copy(doorInteractionRaycaster.ray)
|
|
537
|
+
.applyMatrix4(doorOpeningInverseMatrix);
|
|
538
|
+
const localOpeningHit = doorOpeningLocalRay.intersectBox(doorOpeningBox, doorOpeningLocalHit);
|
|
539
|
+
if (!localOpeningHit)
|
|
540
|
+
continue;
|
|
541
|
+
doorOpeningWorldHit.copy(localOpeningHit).applyMatrix4(doorOpeningMatrix);
|
|
542
|
+
const openingHitDistance = doorOpeningWorldHit.distanceTo(doorInteractionRaycaster.ray.origin);
|
|
543
|
+
if (openingHitDistance <= DOOR_INTERACTION_DISTANCE &&
|
|
544
|
+
openingHitDistance < closestDistance) {
|
|
545
|
+
closestDoorId = doorId;
|
|
546
|
+
closestDistance = openingHitDistance;
|
|
547
|
+
}
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2;
|
|
551
|
+
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1;
|
|
552
|
+
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1;
|
|
553
|
+
const currentSwingAngle = useInteractive.getState().doors[doorId]?.swingAngle ?? node.swingAngle ?? 0;
|
|
554
|
+
const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, currentSwingAngle));
|
|
555
|
+
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign;
|
|
556
|
+
doorLeafMatrix
|
|
557
|
+
.copy(object.matrixWorld)
|
|
558
|
+
.multiply(new Matrix4().makeTranslation(hingeX, 0, 0))
|
|
559
|
+
.multiply(new Matrix4().makeRotationY(leafSwingRotation))
|
|
560
|
+
.multiply(new Matrix4().makeTranslation(-hingeX, leafCenterY, 0));
|
|
561
|
+
doorLeafInverseMatrix.copy(doorLeafMatrix).invert();
|
|
562
|
+
doorLeafBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2);
|
|
563
|
+
doorLeafBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2);
|
|
564
|
+
doorLeafLocalRay.copy(doorInteractionRaycaster.ray).applyMatrix4(doorLeafInverseMatrix);
|
|
565
|
+
const localHit = doorLeafLocalRay.intersectBox(doorLeafBox, doorLeafLocalHit);
|
|
566
|
+
if (!localHit)
|
|
567
|
+
continue;
|
|
568
|
+
doorLeafWorldHit.copy(localHit).applyMatrix4(doorLeafMatrix);
|
|
569
|
+
const hitDistance = doorLeafWorldHit.distanceTo(doorInteractionRaycaster.ray.origin);
|
|
570
|
+
if (hitDistance <= DOOR_INTERACTION_DISTANCE && hitDistance < closestDistance) {
|
|
571
|
+
closestDoorId = doorId;
|
|
572
|
+
closestDistance = hitDistance;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return closestDoorId;
|
|
576
|
+
}, [camera]);
|
|
577
|
+
const resolveInteractableWindowId = useCallback(() => {
|
|
578
|
+
const nodes = useScene.getState().nodes;
|
|
579
|
+
camera.updateMatrixWorld(true);
|
|
580
|
+
windowInteractionRaycaster.setFromCamera(centerScreenPoint, camera);
|
|
581
|
+
let closestWindowId = null;
|
|
582
|
+
let closestDistance = DOOR_INTERACTION_DISTANCE;
|
|
583
|
+
for (const windowId of sceneRegistry.byType.window) {
|
|
584
|
+
const node = nodes[windowId];
|
|
585
|
+
if (node?.type !== 'window')
|
|
586
|
+
continue;
|
|
587
|
+
if (node.openingKind === 'opening')
|
|
588
|
+
continue;
|
|
589
|
+
if (!hasNativeLeaf(node))
|
|
590
|
+
continue;
|
|
591
|
+
if (!isOperableWindowType(node.windowType))
|
|
592
|
+
continue;
|
|
593
|
+
const object = sceneRegistry.nodes.get(windowId);
|
|
594
|
+
if (!object)
|
|
595
|
+
continue;
|
|
596
|
+
const hit = windowInteractionRaycaster
|
|
597
|
+
.intersectObject(object, true)
|
|
598
|
+
.find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE);
|
|
599
|
+
if (!(hit && hit.distance < closestDistance))
|
|
600
|
+
continue;
|
|
601
|
+
closestWindowId = windowId;
|
|
602
|
+
closestDistance = hit.distance;
|
|
603
|
+
}
|
|
604
|
+
return closestWindowId;
|
|
605
|
+
}, [camera]);
|
|
606
|
+
const resolveInteractableElevatorTarget = useCallback(() => {
|
|
607
|
+
const nodes = useScene.getState().nodes;
|
|
608
|
+
camera.updateMatrixWorld(true);
|
|
609
|
+
elevatorInteractionRaycaster.setFromCamera(centerScreenPoint, camera);
|
|
610
|
+
let closestTarget = null;
|
|
611
|
+
let closestDistance = DOOR_INTERACTION_DISTANCE;
|
|
612
|
+
for (const elevatorId of sceneRegistry.byType.elevator) {
|
|
613
|
+
const typedElevatorId = elevatorId;
|
|
614
|
+
const node = nodes[typedElevatorId];
|
|
615
|
+
if (node?.type !== 'elevator')
|
|
616
|
+
continue;
|
|
617
|
+
const object = sceneRegistry.nodes.get(typedElevatorId);
|
|
618
|
+
if (!object)
|
|
619
|
+
continue;
|
|
620
|
+
const runtime = useInteractive.getState().elevators[typedElevatorId];
|
|
621
|
+
object.updateWorldMatrix(true, true);
|
|
622
|
+
if (runtime) {
|
|
623
|
+
elevatorLocalEyePosition.copy(camera.position);
|
|
624
|
+
object.worldToLocal(elevatorLocalEyePosition);
|
|
625
|
+
}
|
|
626
|
+
const canUseCabButtons = runtime && isInsideElevatorCab(node, runtime, elevatorLocalEyePosition);
|
|
627
|
+
const intersections = elevatorInteractionRaycaster.intersectObject(object, true);
|
|
628
|
+
for (const intersection of intersections) {
|
|
629
|
+
if (intersection.distance > closestDistance)
|
|
630
|
+
continue;
|
|
631
|
+
const target = resolveElevatorButtonTarget(intersection.object);
|
|
632
|
+
if (!target || target.elevatorId !== elevatorId)
|
|
633
|
+
continue;
|
|
634
|
+
if (target.action === 'request-level') {
|
|
635
|
+
if (!target.levelId || nodes[target.levelId]?.type !== 'level')
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (target.buttonKind === 'cab' && !canUseCabButtons)
|
|
639
|
+
continue;
|
|
640
|
+
closestTarget = {
|
|
641
|
+
action: target.action,
|
|
642
|
+
buttonKind: target.buttonKind,
|
|
643
|
+
id: target.elevatorId,
|
|
644
|
+
levelId: target.levelId,
|
|
645
|
+
type: 'elevator',
|
|
646
|
+
};
|
|
647
|
+
closestDistance = intersection.distance;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return closestTarget;
|
|
651
|
+
}, [camera]);
|
|
652
|
+
const resolveInteractableTarget = useCallback(() => {
|
|
653
|
+
const elevatorTarget = resolveInteractableElevatorTarget();
|
|
654
|
+
if (elevatorTarget)
|
|
655
|
+
return elevatorTarget;
|
|
656
|
+
const doorId = resolveInteractableDoorId();
|
|
657
|
+
if (doorId)
|
|
658
|
+
return { id: doorId, type: 'door' };
|
|
659
|
+
const windowId = resolveInteractableWindowId();
|
|
660
|
+
if (windowId)
|
|
661
|
+
return { id: windowId, type: 'window' };
|
|
662
|
+
camera.updateMatrixWorld(true);
|
|
663
|
+
proceduralInteractionRaycaster.setFromCamera(centerScreenPoint, camera);
|
|
664
|
+
proceduralInteractionRaycaster.far = DOOR_INTERACTION_DISTANCE;
|
|
665
|
+
let closest = null;
|
|
666
|
+
let closestDistance = DOOR_INTERACTION_DISTANCE;
|
|
667
|
+
const nodes = useScene.getState().nodes;
|
|
668
|
+
const covered = new Set();
|
|
669
|
+
for (const rawId of sceneRegistry.byType['procedural-item'] ?? []) {
|
|
670
|
+
covered.add(rawId);
|
|
671
|
+
const id = rawId;
|
|
672
|
+
const node = nodes[id];
|
|
673
|
+
if (node?.type !== 'procedural-item')
|
|
674
|
+
continue;
|
|
675
|
+
const procedural = node;
|
|
676
|
+
if (!operableParts(procedural.recipe).length &&
|
|
677
|
+
!procedural.recipe.parts.some((part) => part.light))
|
|
678
|
+
continue;
|
|
679
|
+
const object = sceneRegistry.nodes.get(id);
|
|
680
|
+
if (!object)
|
|
681
|
+
continue;
|
|
682
|
+
for (const hit of proceduralInteractionRaycaster.intersectObject(object, true)) {
|
|
683
|
+
if (hit.distance >= closestDistance)
|
|
684
|
+
break;
|
|
685
|
+
// Skip what is not drawn: a joint tree's inactive rest or joint meshes.
|
|
686
|
+
let drawn = true;
|
|
687
|
+
for (let at = hit.object; at && at !== object; at = at.parent)
|
|
688
|
+
if (!at.visible)
|
|
689
|
+
drawn = false;
|
|
690
|
+
if (!drawn)
|
|
691
|
+
continue;
|
|
692
|
+
let ancestor = hit.object;
|
|
693
|
+
while (ancestor && ancestor !== object && !ancestor.userData.proceduralMotion)
|
|
694
|
+
ancestor = ancestor.parent;
|
|
695
|
+
let motion = ancestor?.userData.proceduralMotion;
|
|
696
|
+
// An idle joint tree draws one merged mesh per slot; its face ranges name the part.
|
|
697
|
+
const ranges = hit.object.userData.proceduralRanges;
|
|
698
|
+
if (!motion && ranges && hit.faceIndex != null) {
|
|
699
|
+
const face = hit.faceIndex;
|
|
700
|
+
const partId = ranges.find((range) => face < range.end)?.partId;
|
|
701
|
+
const part = partId ? operablePartFor(procedural.recipe, partId) : undefined;
|
|
702
|
+
if (part)
|
|
703
|
+
motion = { nodeId: id, partId: part.id, kind: part.kind };
|
|
704
|
+
}
|
|
705
|
+
closest =
|
|
706
|
+
motion?.nodeId === id && motion.partId
|
|
707
|
+
? { id, partId: motion.partId, kind: motion.kind, type: 'procedural' }
|
|
708
|
+
: { id, type: 'procedural' };
|
|
709
|
+
closestDistance = hit.distance;
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
for (const rawId of sceneRegistry.byType.item ?? []) {
|
|
714
|
+
covered.add(rawId);
|
|
715
|
+
const id = rawId;
|
|
716
|
+
const node = nodes[id];
|
|
717
|
+
if (node?.type !== 'item' ||
|
|
718
|
+
!node.asset.interactive?.controls.some((control) => control.kind === 'toggle'))
|
|
719
|
+
continue;
|
|
720
|
+
const object = sceneRegistry.nodes.get(id);
|
|
721
|
+
if (!object)
|
|
722
|
+
continue;
|
|
723
|
+
const hit = proceduralInteractionRaycaster.intersectObject(object, true)[0];
|
|
724
|
+
if (hit && hit.distance < closestDistance) {
|
|
725
|
+
closest = { id, type: 'item' };
|
|
726
|
+
closestDistance = hit.distance;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
// Doors and windows have their own walkthrough targets (resolveInteractable*Id), except those
|
|
730
|
+
// built from a script: their clips run through the mechanism target below.
|
|
731
|
+
for (const id of nativeOpeningIds())
|
|
732
|
+
covered.add(id);
|
|
733
|
+
for (const id of mechanismTargetIds(covered)) {
|
|
734
|
+
const object = sceneRegistry.nodes.get(id);
|
|
735
|
+
const hit = object && proceduralInteractionRaycaster.intersectObject(object, true)[0];
|
|
736
|
+
if (hit && hit.distance < closestDistance) {
|
|
737
|
+
closest = { id, type: 'mechanism' };
|
|
738
|
+
closestDistance = hit.distance;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (!closest) {
|
|
742
|
+
const selectedId = useViewer.getState().selection.selectedIds.at(-1);
|
|
743
|
+
const selected = selectedId ? nodes[selectedId] : undefined;
|
|
744
|
+
if (selected?.type === 'item' &&
|
|
745
|
+
selected.asset.interactive?.controls.some((control) => control.kind === 'toggle'))
|
|
746
|
+
return { id: selected.id, type: 'item' };
|
|
747
|
+
}
|
|
748
|
+
if (closest)
|
|
749
|
+
return closest;
|
|
750
|
+
return null;
|
|
751
|
+
}, [
|
|
752
|
+
camera,
|
|
753
|
+
resolveInteractableDoorId,
|
|
754
|
+
resolveInteractableElevatorTarget,
|
|
755
|
+
resolveInteractableWindowId,
|
|
756
|
+
]);
|
|
757
|
+
const toggleInteractableTarget = useCallback(() => {
|
|
758
|
+
if (isDroneMode)
|
|
759
|
+
return;
|
|
760
|
+
const target = interactableTargetRef.current ?? resolveInteractableTarget();
|
|
761
|
+
if (!target)
|
|
762
|
+
return;
|
|
763
|
+
playTargetSound(target);
|
|
764
|
+
if (target.type === 'mechanism') {
|
|
765
|
+
const node = useScene.getState().nodes[target.id];
|
|
766
|
+
if (node)
|
|
767
|
+
toggleNodeMechanism(node);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (target.type === 'item') {
|
|
771
|
+
const node = useScene.getState().nodes[target.id];
|
|
772
|
+
if (node?.type === 'item' && node.asset.interactive)
|
|
773
|
+
operateItem(target.id, node.asset.interactive);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (target.type === 'procedural') {
|
|
777
|
+
const node = useScene.getState().nodes[target.id];
|
|
778
|
+
if (node?.type !== 'procedural-item')
|
|
779
|
+
return;
|
|
780
|
+
const parts = operableParts(node.recipe).map((part) => part.id);
|
|
781
|
+
const state = useInteractive.getState();
|
|
782
|
+
if (parts.length === 0) {
|
|
783
|
+
state.toggleProceduralLights(target.id);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (target.partId)
|
|
787
|
+
state.toggleProceduralPart(target.id, target.partId);
|
|
788
|
+
else
|
|
789
|
+
state.setProceduralParts(target.id, parts, !parts.some((id) => state.procedural[target.id]?.parts[id]));
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (target.type === 'elevator') {
|
|
793
|
+
if (target.buttonKind === 'cab') {
|
|
794
|
+
const state = useInteractive.getState().elevators[target.id];
|
|
795
|
+
if (state) {
|
|
796
|
+
ridingElevatorRef.current = {
|
|
797
|
+
elevatorId: target.id,
|
|
798
|
+
localControllerY: null,
|
|
799
|
+
previousCarY: state.carY,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (target.action === 'open-door') {
|
|
804
|
+
openElevatorDoor(target.id);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (target.levelId) {
|
|
808
|
+
const targetElevatorId = target.buttonKind === 'landing'
|
|
809
|
+
? resolveElevatorDispatchTarget({
|
|
810
|
+
elevators: useInteractive.getState().elevators,
|
|
811
|
+
levelId: target.levelId,
|
|
812
|
+
nodes: useScene.getState().nodes,
|
|
813
|
+
requestedElevatorId: target.id,
|
|
814
|
+
})
|
|
815
|
+
: target.id;
|
|
816
|
+
requestElevatorLevel(targetElevatorId, target.levelId);
|
|
817
|
+
}
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (target.type === 'window') {
|
|
821
|
+
const node = useScene.getState().nodes[target.id];
|
|
822
|
+
if (node?.type !== 'window' ||
|
|
823
|
+
node.openingKind === 'opening' ||
|
|
824
|
+
!isOperableWindowType(node.windowType)) {
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
toggleWindowOpenState(target.id, { persist: false });
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const doorId = target.id;
|
|
831
|
+
const node = useScene.getState().nodes[doorId];
|
|
832
|
+
if (node?.type !== 'door' || node.openingKind === 'opening')
|
|
833
|
+
return;
|
|
834
|
+
toggleDoorOpenState(doorId, { persist: false });
|
|
835
|
+
}, [isDroneMode, resolveInteractableTarget]);
|
|
836
|
+
const closeInteractableTarget = useCallback(() => {
|
|
837
|
+
if (isDroneMode)
|
|
838
|
+
return;
|
|
839
|
+
const target = interactableTargetRef.current ?? resolveInteractableTarget();
|
|
840
|
+
if (!target)
|
|
841
|
+
return;
|
|
842
|
+
if (target.type === 'mechanism') {
|
|
843
|
+
const node = useScene.getState().nodes[target.id];
|
|
844
|
+
if (node)
|
|
845
|
+
nodeMechanism(node)?.set(node, false);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (target.type === 'procedural') {
|
|
849
|
+
const node = useScene.getState().nodes[target.id];
|
|
850
|
+
if (node?.type !== 'procedural-item')
|
|
851
|
+
return;
|
|
852
|
+
const parts = target.partId
|
|
853
|
+
? [target.partId]
|
|
854
|
+
: operableParts(node.recipe).map((part) => part.id);
|
|
855
|
+
useInteractive.getState().setProceduralParts(target.id, parts, false);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (target.type === 'elevator')
|
|
859
|
+
return;
|
|
860
|
+
if (target.type === 'window') {
|
|
861
|
+
const node = useScene.getState().nodes[target.id];
|
|
862
|
+
if (node?.type !== 'window' ||
|
|
863
|
+
node.openingKind === 'opening' ||
|
|
864
|
+
!isOperableWindowType(node.windowType)) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
closeWindowOpenState(target.id, { persist: false });
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
const node = useScene.getState().nodes[target.id];
|
|
871
|
+
if (node?.type !== 'door' || node.openingKind === 'opening')
|
|
872
|
+
return;
|
|
873
|
+
closeDoorOpenState(target.id, { persist: false });
|
|
874
|
+
}, [isDroneMode, resolveInteractableTarget]);
|
|
875
|
+
const placedSpawn = useMemo(() => {
|
|
876
|
+
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn'))
|
|
877
|
+
return null;
|
|
878
|
+
const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id);
|
|
879
|
+
if (spawnObject) {
|
|
880
|
+
spawnObject.updateWorldMatrix(true, false);
|
|
881
|
+
spawnObject.getWorldPosition(spawnWorldPosition);
|
|
882
|
+
spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ');
|
|
883
|
+
return {
|
|
884
|
+
position: [
|
|
885
|
+
spawnWorldPosition.x,
|
|
886
|
+
spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT,
|
|
887
|
+
spawnWorldPosition.z,
|
|
888
|
+
],
|
|
889
|
+
yaw: spawnWorldEuler.y,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
return {
|
|
893
|
+
position: [
|
|
894
|
+
placedSpawnNode.position[0],
|
|
895
|
+
placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT,
|
|
896
|
+
placedSpawnNode.position[2],
|
|
897
|
+
],
|
|
898
|
+
yaw: placedSpawnNode.rotation,
|
|
899
|
+
};
|
|
900
|
+
}, [placedSpawnNode]);
|
|
901
|
+
useEffect(() => {
|
|
902
|
+
// Drone has no gravity, no floor and no collision, so the BVH collider world
|
|
903
|
+
// (a full-scene traversal, rebuilt on every door/window animation) is pure
|
|
904
|
+
// cost there. Switching modes disposes it and rebuilds on the way back.
|
|
905
|
+
if (!isDroneMode)
|
|
906
|
+
rebuildColliderWorld();
|
|
907
|
+
return () => {
|
|
908
|
+
worldRef.current?.dispose();
|
|
909
|
+
worldRef.current = null;
|
|
910
|
+
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current);
|
|
911
|
+
elevatorColliderMeshesRef.current = [];
|
|
912
|
+
setElevatorColliderMeshes([]);
|
|
913
|
+
setWorld(null);
|
|
914
|
+
};
|
|
915
|
+
}, [isDroneMode, rebuildColliderWorld]);
|
|
916
|
+
useEffect(() => {
|
|
917
|
+
if (isDroneMode)
|
|
918
|
+
return;
|
|
919
|
+
emitter.on('door:animation-completed', rebuildColliderWorld);
|
|
920
|
+
emitter.on('window:animation-completed', rebuildColliderWorld);
|
|
921
|
+
// A door built from a script opens by its model's clip, which reports no animation end.
|
|
922
|
+
const stopWatching = watchScriptedDoors(rebuildColliderWorld);
|
|
923
|
+
return () => {
|
|
924
|
+
emitter.off('door:animation-completed', rebuildColliderWorld);
|
|
925
|
+
emitter.off('window:animation-completed', rebuildColliderWorld);
|
|
926
|
+
stopWatching();
|
|
927
|
+
};
|
|
928
|
+
}, [isDroneMode, rebuildColliderWorld]);
|
|
929
|
+
// A walk session started before a drone detour would otherwise resume at its
|
|
930
|
+
// original spawn; drop it so the next walk re-derives one.
|
|
931
|
+
useEffect(() => {
|
|
932
|
+
if (isDroneMode)
|
|
933
|
+
setControllerStart(null);
|
|
934
|
+
}, [isDroneMode]);
|
|
935
|
+
// Drone picks up wherever the camera currently is — orbit pose or walk eye.
|
|
936
|
+
useEffect(() => {
|
|
937
|
+
if (!isDroneMode)
|
|
938
|
+
return;
|
|
939
|
+
seedDroneLook(lookRef.current, camera.quaternion);
|
|
940
|
+
droneVelocityRef.current.set(0, 0, 0);
|
|
941
|
+
// Interaction prompts belong to walk; drop whatever its frame left behind.
|
|
942
|
+
if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
|
|
943
|
+
useViewer.getState().setHoveredId(null);
|
|
944
|
+
}
|
|
945
|
+
interactableTargetRef.current = null;
|
|
946
|
+
useFirstPersonHud.getState().reset();
|
|
947
|
+
}, [camera, isDroneMode]);
|
|
948
|
+
useEffect(() => {
|
|
949
|
+
if (!world)
|
|
950
|
+
return;
|
|
951
|
+
if (controllerStart)
|
|
952
|
+
return;
|
|
953
|
+
const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world);
|
|
954
|
+
const [x, y, z] = spawn.position;
|
|
955
|
+
lookRef.current.yaw = spawn.yaw;
|
|
956
|
+
lookRef.current.pitch = 0;
|
|
957
|
+
setControllerStart({
|
|
958
|
+
position: [x, y - CONTROLLER_CENTER_FROM_EYE, z],
|
|
959
|
+
yaw: spawn.yaw,
|
|
960
|
+
});
|
|
961
|
+
}, [camera, controllerStart, placedSpawn, world]);
|
|
962
|
+
useEffect(() => {
|
|
963
|
+
const canvas = gl.domElement;
|
|
964
|
+
focusFirstPersonCanvas(canvas);
|
|
965
|
+
const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas));
|
|
966
|
+
return () => window.cancelAnimationFrame(frame);
|
|
967
|
+
}, [gl]);
|
|
968
|
+
// The pointer-lock effect below must be mount-stable. Its cleanup exits
|
|
969
|
+
// pointer lock, and `toggleInteractableTarget` is recreated whenever the
|
|
970
|
+
// camera object changes — which happens right after entry when the
|
|
971
|
+
// persisted orthographic mode swaps to perspective. If the async lock
|
|
972
|
+
// grant lands before that re-run, the cleanup's exitPointerLock fires an
|
|
973
|
+
// unlock the handler reads as "user left walkthrough", instantly
|
|
974
|
+
// cancelling a fresh entry (and arming the browser's ~1.25s re-lock
|
|
975
|
+
// cooldown, so the next presses fail too). Route the callback through a
|
|
976
|
+
// ref so the effect deps stay `[gl]`.
|
|
977
|
+
const toggleInteractableTargetRef = useRef(toggleInteractableTarget);
|
|
978
|
+
useEffect(() => {
|
|
979
|
+
toggleInteractableTargetRef.current = toggleInteractableTarget;
|
|
980
|
+
}, [toggleInteractableTarget]);
|
|
981
|
+
useEffect(() => {
|
|
982
|
+
const canvas = gl.domElement;
|
|
983
|
+
const handleMouseMove = (e) => {
|
|
984
|
+
if (document.pointerLockElement !== canvas)
|
|
985
|
+
return;
|
|
986
|
+
// A still render must retain the exact framed pose.
|
|
987
|
+
if (hostRef.current.paused)
|
|
988
|
+
return;
|
|
989
|
+
turnFirstPersonLook(lookRef.current, e.movementX, e.movementY, useViewer.getState().walkthroughMovement === 'drone' && droneSlowKeyRef.current
|
|
990
|
+
? DRONE_SLOW_MULTIPLIER
|
|
991
|
+
: 1);
|
|
992
|
+
};
|
|
993
|
+
const handleClick = (event) => {
|
|
994
|
+
const target = event.target;
|
|
995
|
+
if (!(target instanceof HTMLElement))
|
|
996
|
+
return;
|
|
997
|
+
if (!canvas.contains(target))
|
|
998
|
+
return;
|
|
999
|
+
if (document.pointerLockElement !== canvas) {
|
|
1000
|
+
canvas.requestPointerLock?.();
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
const handleMouseDown = (event) => {
|
|
1004
|
+
if (document.pointerLockElement !== canvas)
|
|
1005
|
+
return;
|
|
1006
|
+
if (event.button !== 0)
|
|
1007
|
+
return;
|
|
1008
|
+
if (!hostRef.current.pointerInteraction)
|
|
1009
|
+
return;
|
|
1010
|
+
event.preventDefault();
|
|
1011
|
+
event.stopPropagation();
|
|
1012
|
+
toggleInteractableTargetRef.current();
|
|
1013
|
+
};
|
|
1014
|
+
const handlePointerLockChange = () => {
|
|
1015
|
+
const isLocked = document.pointerLockElement === canvas;
|
|
1016
|
+
if (isLocked) {
|
|
1017
|
+
hadPointerLockRef.current = true;
|
|
1018
|
+
suspendRef.current = false;
|
|
1019
|
+
useViewer.getState().setWalkthroughSuspended(false);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
// Deliberately released (screenshot pause) — stay in first person;
|
|
1023
|
+
// clicking the canvas re-locks.
|
|
1024
|
+
if (suspendRef.current)
|
|
1025
|
+
return;
|
|
1026
|
+
if (hadPointerLockRef.current &&
|
|
1027
|
+
hostRef.current.suspendOnUnlock &&
|
|
1028
|
+
useViewer.getState().walkthroughMode) {
|
|
1029
|
+
suspendRef.current = true;
|
|
1030
|
+
useViewer.getState().setWalkthroughSuspended(true);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (hadPointerLockRef.current && useViewer.getState().walkthroughMode) {
|
|
1034
|
+
exit();
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
handlePointerLockChange();
|
|
1038
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
1039
|
+
document.addEventListener('click', handleClick);
|
|
1040
|
+
document.addEventListener('mousedown', handleMouseDown, true);
|
|
1041
|
+
document.addEventListener('pointerlockchange', handlePointerLockChange);
|
|
1042
|
+
return () => {
|
|
1043
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
1044
|
+
document.removeEventListener('click', handleClick);
|
|
1045
|
+
document.removeEventListener('mousedown', handleMouseDown, true);
|
|
1046
|
+
document.removeEventListener('pointerlockchange', handlePointerLockChange);
|
|
1047
|
+
useViewer.getState().setWalkthroughSuspended(false);
|
|
1048
|
+
if (document.pointerLockElement === canvas) {
|
|
1049
|
+
document.exitPointerLock();
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
}, [exit, gl]);
|
|
1053
|
+
useEffect(() => {
|
|
1054
|
+
const canvas = gl.domElement;
|
|
1055
|
+
const applyMovementKey = (event, active) => {
|
|
1056
|
+
if (isTypingTarget(event.target))
|
|
1057
|
+
return false;
|
|
1058
|
+
const movement = getMovementInputForKey(event.code, active);
|
|
1059
|
+
if (!movement)
|
|
1060
|
+
return false;
|
|
1061
|
+
event.preventDefault();
|
|
1062
|
+
Object.assign(movementInputRef.current, movement);
|
|
1063
|
+
controllerRef.current?.setMovement(movement);
|
|
1064
|
+
return true;
|
|
1065
|
+
};
|
|
1066
|
+
const handleKeyDown = (event) => {
|
|
1067
|
+
const handledMovement = applyMovementKey(event, true);
|
|
1068
|
+
if (handledMovement)
|
|
1069
|
+
return;
|
|
1070
|
+
if (isTypingTarget(event.target))
|
|
1071
|
+
return;
|
|
1072
|
+
if (event.code === 'ControlLeft' || event.code === 'ControlRight') {
|
|
1073
|
+
// While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
|
|
1074
|
+
// screenshot) must not toggle it under the user.
|
|
1075
|
+
if (!suspendRef.current)
|
|
1076
|
+
crouchKeyRef.current = true;
|
|
1077
|
+
}
|
|
1078
|
+
else if (event.code === 'KeyQ') {
|
|
1079
|
+
// Drone descend. Space (already bound to jump) and E are the matching ascend.
|
|
1080
|
+
event.preventDefault();
|
|
1081
|
+
event.stopPropagation();
|
|
1082
|
+
if (!suspendRef.current)
|
|
1083
|
+
droneDescendKeyRef.current = true;
|
|
1084
|
+
}
|
|
1085
|
+
else if (event.code === 'KeyE' && isDroneMode) {
|
|
1086
|
+
event.preventDefault();
|
|
1087
|
+
event.stopPropagation();
|
|
1088
|
+
if (!suspendRef.current)
|
|
1089
|
+
droneAscendKeyRef.current = true;
|
|
1090
|
+
}
|
|
1091
|
+
else if ((event.code === 'AltLeft' || event.code === 'AltRight') && isDroneMode) {
|
|
1092
|
+
event.preventDefault();
|
|
1093
|
+
event.stopPropagation();
|
|
1094
|
+
if (!suspendRef.current)
|
|
1095
|
+
droneSlowKeyRef.current = true;
|
|
1096
|
+
}
|
|
1097
|
+
else if (event.code === 'Escape') {
|
|
1098
|
+
event.preventDefault();
|
|
1099
|
+
event.stopPropagation();
|
|
1100
|
+
if (hostRef.current.suspendOnUnlock) {
|
|
1101
|
+
if (document.pointerLockElement === canvas) {
|
|
1102
|
+
suspendRef.current = true;
|
|
1103
|
+
useViewer.getState().setWalkthroughSuspended(true);
|
|
1104
|
+
document.exitPointerLock();
|
|
1105
|
+
}
|
|
1106
|
+
else {
|
|
1107
|
+
exit();
|
|
1108
|
+
}
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (document.pointerLockElement === canvas) {
|
|
1112
|
+
document.exitPointerLock();
|
|
1113
|
+
}
|
|
1114
|
+
exit();
|
|
1115
|
+
}
|
|
1116
|
+
else if (event.code === 'KeyE' || event.code === 'KeyR') {
|
|
1117
|
+
event.preventDefault();
|
|
1118
|
+
event.stopPropagation();
|
|
1119
|
+
toggleInteractableTarget();
|
|
1120
|
+
}
|
|
1121
|
+
else if (event.code === 'KeyT') {
|
|
1122
|
+
event.preventDefault();
|
|
1123
|
+
event.stopPropagation();
|
|
1124
|
+
closeInteractableTarget();
|
|
1125
|
+
}
|
|
1126
|
+
else if (event.code === 'KeyP') {
|
|
1127
|
+
// P toggles a cursor pause (advertised in the HUD): frees the pointer
|
|
1128
|
+
// without leaving first person — e.g. for an OS screenshot, which
|
|
1129
|
+
// needs a movable cursor — and click or P resumes.
|
|
1130
|
+
event.preventDefault();
|
|
1131
|
+
event.stopPropagation();
|
|
1132
|
+
if (document.pointerLockElement === canvas) {
|
|
1133
|
+
suspendRef.current = true;
|
|
1134
|
+
useViewer.getState().setWalkthroughSuspended(true);
|
|
1135
|
+
document.exitPointerLock();
|
|
1136
|
+
}
|
|
1137
|
+
else if (suspendRef.current) {
|
|
1138
|
+
const result = canvas.requestPointerLock?.();
|
|
1139
|
+
if (result && typeof result.catch === 'function')
|
|
1140
|
+
result.catch(() => { });
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
const handleKeyUp = (event) => {
|
|
1145
|
+
if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
|
|
1146
|
+
crouchKeyRef.current = false;
|
|
1147
|
+
}
|
|
1148
|
+
if (event.code === 'KeyQ' && !suspendRef.current) {
|
|
1149
|
+
droneDescendKeyRef.current = false;
|
|
1150
|
+
}
|
|
1151
|
+
if (event.code === 'KeyE' && !suspendRef.current) {
|
|
1152
|
+
droneAscendKeyRef.current = false;
|
|
1153
|
+
}
|
|
1154
|
+
if ((event.code === 'AltLeft' || event.code === 'AltRight') && !suspendRef.current) {
|
|
1155
|
+
droneSlowKeyRef.current = false;
|
|
1156
|
+
}
|
|
1157
|
+
applyMovementKey(event, false);
|
|
1158
|
+
};
|
|
1159
|
+
const handleBlur = () => {
|
|
1160
|
+
if (!suspendRef.current) {
|
|
1161
|
+
crouchKeyRef.current = false;
|
|
1162
|
+
droneAscendKeyRef.current = false;
|
|
1163
|
+
droneDescendKeyRef.current = false;
|
|
1164
|
+
droneSlowKeyRef.current = false;
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
1168
|
+
document.addEventListener('keyup', handleKeyUp, true);
|
|
1169
|
+
window.addEventListener('blur', handleBlur);
|
|
1170
|
+
return () => {
|
|
1171
|
+
document.removeEventListener('keydown', handleKeyDown, true);
|
|
1172
|
+
document.removeEventListener('keyup', handleKeyUp, true);
|
|
1173
|
+
window.removeEventListener('blur', handleBlur);
|
|
1174
|
+
};
|
|
1175
|
+
}, [closeInteractableTarget, exit, gl, isDroneMode, toggleInteractableTarget]);
|
|
1176
|
+
const syncElevatorColliderMeshes = useCallback(() => {
|
|
1177
|
+
const nodes = useScene.getState().nodes;
|
|
1178
|
+
const interactive = useInteractive.getState();
|
|
1179
|
+
for (const mesh of elevatorColliderMeshesRef.current) {
|
|
1180
|
+
const { doorWidth, dynamic, elevatorId, kind, levelId, localPosition, side } = mesh.userData;
|
|
1181
|
+
const node = nodes[elevatorId];
|
|
1182
|
+
const runtime = interactive.elevators[elevatorId];
|
|
1183
|
+
const object = sceneRegistry.nodes.get(elevatorId);
|
|
1184
|
+
if (!(node?.type === 'elevator' && object && node.visible !== false)) {
|
|
1185
|
+
mesh.visible = false;
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
if (!dynamic && mesh.userData.matrixInitialized && mesh.visible) {
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
let [localX, localY, localZ] = localPosition;
|
|
1192
|
+
const isCabCollider = kind.startsWith('cab-');
|
|
1193
|
+
const isDoorCollider = kind === 'landing-door-left' || kind === 'landing-door-right';
|
|
1194
|
+
const isCabDoorGate = kind === 'cab-door-gate';
|
|
1195
|
+
const isLandingDoorGate = kind === 'landing-door-gate';
|
|
1196
|
+
if (isCabCollider) {
|
|
1197
|
+
localY += runtime?.carY ?? 0;
|
|
1198
|
+
}
|
|
1199
|
+
if (kind === 'cab-door-left' || kind === 'cab-door-right') {
|
|
1200
|
+
if (!runtime) {
|
|
1201
|
+
mesh.visible = false;
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, runtime.doorOpen, node.doorStyle);
|
|
1205
|
+
mesh.visible = true;
|
|
1206
|
+
}
|
|
1207
|
+
else if (isCabDoorGate) {
|
|
1208
|
+
if (!runtime) {
|
|
1209
|
+
mesh.visible = false;
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD;
|
|
1213
|
+
}
|
|
1214
|
+
else if (isDoorCollider) {
|
|
1215
|
+
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0;
|
|
1216
|
+
localX = getElevatorDoorLeafX(side ?? 'left', doorWidth ?? node.doorWidth, doorOpen, node.doorStyle);
|
|
1217
|
+
mesh.visible = true;
|
|
1218
|
+
}
|
|
1219
|
+
else if (isLandingDoorGate) {
|
|
1220
|
+
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0;
|
|
1221
|
+
mesh.visible = doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD;
|
|
1222
|
+
}
|
|
1223
|
+
else {
|
|
1224
|
+
mesh.visible = true;
|
|
1225
|
+
}
|
|
1226
|
+
object.updateWorldMatrix(true, false);
|
|
1227
|
+
elevatorColliderMatrix.copy(object.matrixWorld);
|
|
1228
|
+
elevatorColliderMatrix.multiply(elevatorColliderLocalMatrix.makeTranslation(localX, localY, localZ));
|
|
1229
|
+
mesh.matrix.copy(elevatorColliderMatrix);
|
|
1230
|
+
mesh.matrixWorld.copy(elevatorColliderMatrix);
|
|
1231
|
+
mesh.userData.matrixInitialized = true;
|
|
1232
|
+
}
|
|
1233
|
+
}, []);
|
|
1234
|
+
useFrame(() => {
|
|
1235
|
+
if (isDroneMode)
|
|
1236
|
+
return;
|
|
1237
|
+
syncElevatorColliderMeshes();
|
|
1238
|
+
}, -1);
|
|
1239
|
+
const syncElevatorRide = useCallback((group) => {
|
|
1240
|
+
const nodes = useScene.getState().nodes;
|
|
1241
|
+
const interactive = useInteractive.getState();
|
|
1242
|
+
const activeRide = ridingElevatorRef.current;
|
|
1243
|
+
let nextRide = null;
|
|
1244
|
+
const elevatorIds = activeRide
|
|
1245
|
+
? [
|
|
1246
|
+
activeRide.elevatorId,
|
|
1247
|
+
...Array.from(sceneRegistry.byType.elevator).filter((elevatorId) => elevatorId !== activeRide.elevatorId),
|
|
1248
|
+
]
|
|
1249
|
+
: Array.from(sceneRegistry.byType.elevator);
|
|
1250
|
+
for (const elevatorId of elevatorIds) {
|
|
1251
|
+
const typedElevatorId = elevatorId;
|
|
1252
|
+
const node = nodes[typedElevatorId];
|
|
1253
|
+
if (node?.type !== 'elevator')
|
|
1254
|
+
continue;
|
|
1255
|
+
const runtime = interactive.elevators[typedElevatorId];
|
|
1256
|
+
const object = sceneRegistry.nodes.get(typedElevatorId);
|
|
1257
|
+
if (!(runtime && object))
|
|
1258
|
+
continue;
|
|
1259
|
+
object.updateWorldMatrix(true, true);
|
|
1260
|
+
elevatorLocalEyePosition.copy(camera.position);
|
|
1261
|
+
object.worldToLocal(elevatorLocalEyePosition);
|
|
1262
|
+
const halfWidth = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING;
|
|
1263
|
+
const halfDepth = getElevatorCabDepth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING;
|
|
1264
|
+
const cabCenterZ = getElevatorCabCenterZ(node);
|
|
1265
|
+
const cabHeight = Math.max(node.cabHeight, 1.4);
|
|
1266
|
+
const insideFootprint = Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) &&
|
|
1267
|
+
Math.abs(elevatorLocalEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24);
|
|
1268
|
+
const insideCabHeight = elevatorLocalEyePosition.y >= runtime.carY + 0.35 &&
|
|
1269
|
+
elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7;
|
|
1270
|
+
const continuingRide = activeRide?.elevatorId === typedElevatorId &&
|
|
1271
|
+
insideFootprint &&
|
|
1272
|
+
elevatorLocalEyePosition.y >= runtime.carY - 0.2 &&
|
|
1273
|
+
elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 1.25;
|
|
1274
|
+
if ((insideFootprint && insideCabHeight) || continuingRide) {
|
|
1275
|
+
nextRide = {
|
|
1276
|
+
cabHeight,
|
|
1277
|
+
cabCenterZ,
|
|
1278
|
+
carY: runtime.carY,
|
|
1279
|
+
doorOpen: runtime.doorOpen,
|
|
1280
|
+
elevatorId: typedElevatorId,
|
|
1281
|
+
halfDepth: Math.max(halfDepth, 0.24),
|
|
1282
|
+
halfWidth: Math.max(halfWidth, 0.24),
|
|
1283
|
+
object,
|
|
1284
|
+
phase: runtime.phase,
|
|
1285
|
+
};
|
|
1286
|
+
break;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
if (!nextRide) {
|
|
1290
|
+
ridingElevatorRef.current = null;
|
|
1291
|
+
setElevatorRideLocked(false);
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
const previousCarY = activeRide?.elevatorId === nextRide.elevatorId ? activeRide.previousCarY : nextRide.carY;
|
|
1295
|
+
const deltaY = nextRide.carY - previousCarY;
|
|
1296
|
+
nextRide.object.updateWorldMatrix(true, true);
|
|
1297
|
+
elevatorLocalControllerPosition.copy(group.position);
|
|
1298
|
+
nextRide.object.worldToLocal(elevatorLocalControllerPosition);
|
|
1299
|
+
const localControllerY = activeRide?.elevatorId === nextRide.elevatorId && activeRide.localControllerY !== null
|
|
1300
|
+
? activeRide.localControllerY
|
|
1301
|
+
: elevatorLocalControllerPosition.y - nextRide.carY;
|
|
1302
|
+
if (Math.abs(deltaY) > 0.0001) {
|
|
1303
|
+
group.position.y += deltaY;
|
|
1304
|
+
controllerRef.current?.resetLinVel();
|
|
1305
|
+
}
|
|
1306
|
+
const shouldLockToCab = nextRide.phase === 'closing' ||
|
|
1307
|
+
nextRide.phase === 'moving' ||
|
|
1308
|
+
(nextRide.phase === 'opening' && nextRide.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD);
|
|
1309
|
+
if (shouldLockToCab) {
|
|
1310
|
+
elevatorLocalControllerPosition.copy(group.position);
|
|
1311
|
+
nextRide.object.worldToLocal(elevatorLocalControllerPosition);
|
|
1312
|
+
const desiredLocalY = nextRide.carY + localControllerY;
|
|
1313
|
+
if (Math.abs(elevatorLocalControllerPosition.y - desiredLocalY) > 0.002) {
|
|
1314
|
+
elevatorLocalControllerPosition.y = desiredLocalY;
|
|
1315
|
+
elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition);
|
|
1316
|
+
nextRide.object.localToWorld(elevatorWorldControllerPosition);
|
|
1317
|
+
group.position.y = elevatorWorldControllerPosition.y;
|
|
1318
|
+
controllerRef.current?.resetLinVel();
|
|
1319
|
+
elevatorLocalControllerPosition.copy(group.position);
|
|
1320
|
+
nextRide.object.worldToLocal(elevatorLocalControllerPosition);
|
|
1321
|
+
}
|
|
1322
|
+
const clampedX = Math.max(-nextRide.halfWidth, Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x));
|
|
1323
|
+
const clampedZ = Math.max(nextRide.cabCenterZ - nextRide.halfDepth, Math.min(nextRide.cabCenterZ + nextRide.halfDepth, elevatorLocalControllerPosition.z));
|
|
1324
|
+
if (Math.abs(clampedX - elevatorLocalControllerPosition.x) > 0.0001 ||
|
|
1325
|
+
Math.abs(clampedZ - elevatorLocalControllerPosition.z) > 0.0001) {
|
|
1326
|
+
elevatorLocalControllerPosition.x = clampedX;
|
|
1327
|
+
elevatorLocalControllerPosition.z = clampedZ;
|
|
1328
|
+
elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition);
|
|
1329
|
+
nextRide.object.localToWorld(elevatorWorldControllerPosition);
|
|
1330
|
+
group.position.x = elevatorWorldControllerPosition.x;
|
|
1331
|
+
group.position.z = elevatorWorldControllerPosition.z;
|
|
1332
|
+
controllerRef.current?.resetLinVel();
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
setElevatorRideLocked(shouldLockToCab);
|
|
1336
|
+
ridingElevatorRef.current = {
|
|
1337
|
+
elevatorId: nextRide.elevatorId,
|
|
1338
|
+
localControllerY,
|
|
1339
|
+
previousCarY: nextRide.carY,
|
|
1340
|
+
};
|
|
1341
|
+
}, [camera, setElevatorRideLocked]);
|
|
1342
|
+
const hasStandingClearance = useCallback((position) => {
|
|
1343
|
+
standClearanceRaycaster.set(position, standClearanceUp);
|
|
1344
|
+
standClearanceRaycaster.far = STAND_CLEARANCE;
|
|
1345
|
+
const meshes = [];
|
|
1346
|
+
if (worldRef.current)
|
|
1347
|
+
meshes.push(worldRef.current.mesh);
|
|
1348
|
+
for (const mesh of elevatorColliderMeshesRef.current) {
|
|
1349
|
+
if (mesh.visible)
|
|
1350
|
+
meshes.push(mesh);
|
|
1351
|
+
}
|
|
1352
|
+
return standClearanceRaycaster.intersectObjects(meshes, false).length === 0;
|
|
1353
|
+
}, []);
|
|
1354
|
+
// Drone: the viewer's free flight (`stepDroneFlight`, also behind the viewer's
|
|
1355
|
+
// `DroneController`) fed from this controller's keys — WASD along the view
|
|
1356
|
+
// axes, Space or E rises, Q (or Ctrl) sinks, Shift boosts, Alt slows.
|
|
1357
|
+
useFrame((_, delta) => {
|
|
1358
|
+
if (!isDroneMode)
|
|
1359
|
+
return;
|
|
1360
|
+
if (hostRef.current.paused)
|
|
1361
|
+
return;
|
|
1362
|
+
const movement = movementInputRef.current;
|
|
1363
|
+
droneInput.forward = movement.forward === true;
|
|
1364
|
+
droneInput.backward = movement.backward === true;
|
|
1365
|
+
droneInput.leftward = movement.leftward === true;
|
|
1366
|
+
droneInput.rightward = movement.rightward === true;
|
|
1367
|
+
droneInput.up = movement.jump === true || droneAscendKeyRef.current;
|
|
1368
|
+
droneInput.down = droneDescendKeyRef.current || crouchKeyRef.current;
|
|
1369
|
+
droneInput.boost = movement.run === true;
|
|
1370
|
+
droneInput.slow = droneSlowKeyRef.current;
|
|
1371
|
+
stepDroneFlight(camera, lookRef.current, droneInput, droneVelocityRef.current, delta);
|
|
1372
|
+
}, 2.5);
|
|
1373
|
+
useFrame((_, delta) => {
|
|
1374
|
+
if (isDroneMode)
|
|
1375
|
+
return;
|
|
1376
|
+
if (!controllerRef.current?.group)
|
|
1377
|
+
return;
|
|
1378
|
+
const group = controllerRef.current.group;
|
|
1379
|
+
// Crouch follows the held key; standing back up waits for headroom.
|
|
1380
|
+
// Frozen while the cursor pause is active.
|
|
1381
|
+
if (!suspendRef.current && crouchKeyRef.current !== crouched) {
|
|
1382
|
+
if (crouchKeyRef.current)
|
|
1383
|
+
setCrouched(true);
|
|
1384
|
+
else if (hasStandingClearance(group.position))
|
|
1385
|
+
setCrouched(false);
|
|
1386
|
+
}
|
|
1387
|
+
const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET;
|
|
1388
|
+
eyeOffsetRef.current +=
|
|
1389
|
+
(targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED);
|
|
1390
|
+
cameraOffset.set(0, eyeOffsetRef.current, 0);
|
|
1391
|
+
// The site ground collider is effectively unbounded, but scenes without a
|
|
1392
|
+
// site node only have finite fallback floors — if the controller still ends
|
|
1393
|
+
// up below every collider it can never land, so put it back at the spawn.
|
|
1394
|
+
// Prefer the live spawn node over the mount-time start position so a spawn
|
|
1395
|
+
// moved mid-walkthrough doesn't respawn the player at stale coordinates.
|
|
1396
|
+
const worldBounds = worldRef.current?.bounds;
|
|
1397
|
+
if (worldBounds && group.position.y < worldBounds.min.y - VOID_FALL_RESPAWN_DEPTH) {
|
|
1398
|
+
const respawnPosition = placedSpawn
|
|
1399
|
+
? [
|
|
1400
|
+
placedSpawn.position[0],
|
|
1401
|
+
placedSpawn.position[1] - CONTROLLER_CENTER_FROM_EYE,
|
|
1402
|
+
placedSpawn.position[2],
|
|
1403
|
+
]
|
|
1404
|
+
: controllerStart?.position;
|
|
1405
|
+
if (respawnPosition) {
|
|
1406
|
+
group.position.set(respawnPosition[0], respawnPosition[1], respawnPosition[2]);
|
|
1407
|
+
controllerRef.current.resetLinVel();
|
|
1408
|
+
ridingElevatorRef.current = null;
|
|
1409
|
+
setElevatorRideLocked(false);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
group.rotation.y = 0;
|
|
1413
|
+
camera.position.copy(group.position).add(cameraOffset);
|
|
1414
|
+
cameraEuler.set(lookRef.current.pitch, lookRef.current.yaw, 0, 'YXZ');
|
|
1415
|
+
camera.quaternion.setFromEuler(cameraEuler);
|
|
1416
|
+
camera.updateMatrixWorld(true);
|
|
1417
|
+
syncElevatorRide(group);
|
|
1418
|
+
camera.position.copy(group.position).add(cameraOffset);
|
|
1419
|
+
camera.updateMatrixWorld(true);
|
|
1420
|
+
const nextInteractableTarget = resolveInteractableTarget();
|
|
1421
|
+
const previousInteractableTarget = interactableTargetRef.current;
|
|
1422
|
+
if (getInteractableTargetKey(previousInteractableTarget) !==
|
|
1423
|
+
getInteractableTargetKey(nextInteractableTarget)) {
|
|
1424
|
+
interactableTargetRef.current = nextInteractableTarget;
|
|
1425
|
+
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null);
|
|
1426
|
+
}
|
|
1427
|
+
useFirstPersonHud.getState().setHud({
|
|
1428
|
+
interact: resolveHudInteract(nextInteractableTarget),
|
|
1429
|
+
});
|
|
1430
|
+
hudLabelFrameRef.current += 1;
|
|
1431
|
+
if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) {
|
|
1432
|
+
hudLabelFrameRef.current = 0;
|
|
1433
|
+
camera.getWorldPosition(hudWorldEyePosition);
|
|
1434
|
+
useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition));
|
|
1435
|
+
}
|
|
1436
|
+
}, 2.5);
|
|
1437
|
+
useEffect(() => {
|
|
1438
|
+
return () => {
|
|
1439
|
+
if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
|
|
1440
|
+
useViewer.getState().setHoveredId(null);
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
}, []);
|
|
1444
|
+
const firstPersonColliderMeshes = useMemo(() => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes), [world, elevatorColliderMeshes]);
|
|
1445
|
+
if (isDroneMode || !world) {
|
|
1446
|
+
return null;
|
|
1447
|
+
}
|
|
1448
|
+
return (_jsx(_Fragment, { children: controllerStart && (_jsx(KeyboardControls, { map: keyboardMap, children: _jsx(BVHEcctrl, { acceleration: 26, airDragFactor: 0.3, colliderCapsuleArgs: crouched ? CROUCH_CAPSULE : STAND_CAPSULE, colliderMeshes: firstPersonColliderMeshes, collisionCheckIteration: 3, collisionPushBackDamping: 0.1, collisionPushBackThreshold: 0.001, debug: false, deceleration: 30, delay: 0, fallGravityFactor: 4, floatCheckType: "BOTH", floatDampingC: 36, floatHeight: crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT, floatPullBackHeight: 0.35, floatSensorRadius: 0.15, floatSpringK: 1200, gravity: 9.81, jumpVel: 5, maxRunSpeed: crouched ? CROUCH_RUN_SPEED : 5, maxSlope: 1.2, maxWalkSpeed: crouched ? CROUCH_WALK_SPEED : 2, paused: isElevatorRideLocked || paused, position: controllerStart.position, ref: setControllerApi }, "first-person-controller") })) }));
|
|
1449
|
+
}
|