@pascal-app/editor 0.7.0 → 0.9.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/package.json +12 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5837 -12006
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +234 -190
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +435 -158
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +2 -2
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -543
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +57 -1
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +24 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +342 -91
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/tsconfig.json +2 -1
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -230
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
'use client'
|
|
2
|
+
|
|
2
3
|
import {
|
|
4
|
+
type AnyNodeId,
|
|
3
5
|
type CameraControlEvent,
|
|
4
6
|
type CameraControlFitSceneEvent,
|
|
5
7
|
emitter,
|
|
6
8
|
sceneRegistry,
|
|
7
9
|
useScene,
|
|
8
10
|
} from '@pascal-app/core'
|
|
9
|
-
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
|
11
|
+
import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
|
10
12
|
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
|
11
|
-
import { useThree } from '@react-three/fiber'
|
|
13
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
12
14
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
13
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
Box3,
|
|
17
|
+
type Camera,
|
|
18
|
+
type OrthographicCamera,
|
|
19
|
+
type PerspectiveCamera,
|
|
20
|
+
Spherical,
|
|
21
|
+
Vector3,
|
|
22
|
+
} from 'three'
|
|
14
23
|
import { EDITOR_LAYER } from '../../lib/constants'
|
|
15
24
|
import useEditor from '../../store/use-editor'
|
|
16
25
|
|
|
@@ -21,30 +30,359 @@ const tempDelta = new Vector3()
|
|
|
21
30
|
const tempPosition = new Vector3()
|
|
22
31
|
const tempSize = new Vector3()
|
|
23
32
|
const tempTarget = new Vector3()
|
|
33
|
+
const syncTarget = new Vector3()
|
|
34
|
+
const syncSpherical = new Spherical()
|
|
35
|
+
const keyboardPanSpherical = new Spherical()
|
|
24
36
|
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
|
|
25
37
|
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
|
|
38
|
+
const NAVIGATION_SYNC_POSITION_EPSILON = 0.001
|
|
39
|
+
const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005
|
|
40
|
+
const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001
|
|
41
|
+
const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
|
|
42
|
+
const KEYBOARD_PAN_MIN_SPEED = 2
|
|
43
|
+
const KEYBOARD_PAN_MAX_SPEED = 55
|
|
44
|
+
type CameraMode = ReturnType<typeof useViewer.getState>['cameraMode']
|
|
45
|
+
type CameraPoseSnapshot = {
|
|
46
|
+
mode: CameraMode
|
|
47
|
+
position: [number, number, number]
|
|
48
|
+
target: [number, number, number]
|
|
49
|
+
}
|
|
50
|
+
type NavigationCameraPoseSnapshot = {
|
|
51
|
+
target: [number, number, number]
|
|
52
|
+
azimuth: number
|
|
53
|
+
viewWidth: number
|
|
54
|
+
}
|
|
55
|
+
type PendingNavigationCameraPoseSnapshot = NavigationCameraPoseSnapshot & {
|
|
56
|
+
publishOnComplete: boolean
|
|
57
|
+
}
|
|
58
|
+
type CameraViewWidthUpdate =
|
|
59
|
+
| { type: 'distance'; distance: number; viewWidth: number }
|
|
60
|
+
| { type: 'zoom'; viewWidth: number; zoom: number }
|
|
61
|
+
| { type: 'none'; viewWidth: number }
|
|
62
|
+
|
|
63
|
+
function writeVectorTuple(tuple: [number, number, number], vector: Vector3) {
|
|
64
|
+
tuple[0] = vector.x
|
|
65
|
+
tuple[1] = vector.y
|
|
66
|
+
tuple[2] = vector.z
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function saveCameraPose(
|
|
70
|
+
control: CameraControlsImpl,
|
|
71
|
+
mode: CameraMode,
|
|
72
|
+
pose: CameraPoseSnapshot,
|
|
73
|
+
position: Vector3,
|
|
74
|
+
target: Vector3,
|
|
75
|
+
) {
|
|
76
|
+
control.getPosition(position)
|
|
77
|
+
control.getTarget(target)
|
|
78
|
+
pose.mode = mode
|
|
79
|
+
writeVectorTuple(pose.position, position)
|
|
80
|
+
writeVectorTuple(pose.target, target)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function restoreCameraPose(control: CameraControlsImpl, pose: CameraPoseSnapshot) {
|
|
84
|
+
control.setLookAt(
|
|
85
|
+
pose.position[0],
|
|
86
|
+
pose.position[1],
|
|
87
|
+
pose.position[2],
|
|
88
|
+
pose.target[0],
|
|
89
|
+
pose.target[1],
|
|
90
|
+
pose.target[2],
|
|
91
|
+
false,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isEditableKeyboardTarget(target: EventTarget | null) {
|
|
96
|
+
return (
|
|
97
|
+
target instanceof HTMLInputElement ||
|
|
98
|
+
target instanceof HTMLTextAreaElement ||
|
|
99
|
+
target instanceof HTMLSelectElement ||
|
|
100
|
+
(target instanceof HTMLElement && target.isContentEditable)
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type KeyboardPanState = {
|
|
105
|
+
forward: boolean
|
|
106
|
+
backward: boolean
|
|
107
|
+
left: boolean
|
|
108
|
+
right: boolean
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function setKeyboardPanKey(state: KeyboardPanState, code: string, pressed: boolean): boolean {
|
|
112
|
+
if (code === 'KeyW') {
|
|
113
|
+
const changed = state.forward !== pressed
|
|
114
|
+
state.forward = pressed
|
|
115
|
+
return changed
|
|
116
|
+
}
|
|
117
|
+
if (code === 'KeyS') {
|
|
118
|
+
const changed = state.backward !== pressed
|
|
119
|
+
state.backward = pressed
|
|
120
|
+
return changed
|
|
121
|
+
}
|
|
122
|
+
if (code === 'KeyA') {
|
|
123
|
+
const changed = state.left !== pressed
|
|
124
|
+
state.left = pressed
|
|
125
|
+
return changed
|
|
126
|
+
}
|
|
127
|
+
if (code === 'KeyD') {
|
|
128
|
+
const changed = state.right !== pressed
|
|
129
|
+
state.right = pressed
|
|
130
|
+
return changed
|
|
131
|
+
}
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isKeyboardPanKey(code: string): boolean {
|
|
136
|
+
return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
type CameraViewportSize = {
|
|
140
|
+
width: number
|
|
141
|
+
height: number
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isPerspectiveCamera(camera: Camera): camera is PerspectiveCamera {
|
|
145
|
+
return (camera as PerspectiveCamera).isPerspectiveCamera === true
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isOrthographicCamera(camera: Camera): camera is OrthographicCamera {
|
|
149
|
+
return (camera as OrthographicCamera).isOrthographicCamera === true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getCameraViewAspect(size: CameraViewportSize) {
|
|
153
|
+
return Math.max(size.width, 1) / Math.max(size.height, 1)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function getCameraViewWidth(camera: Camera, distance: number, size: CameraViewportSize) {
|
|
157
|
+
if (isPerspectiveCamera(camera)) {
|
|
158
|
+
const fovRadians = (camera.getEffectiveFOV() * Math.PI) / 180
|
|
159
|
+
return Math.max(0.001, 2 * distance * Math.tan(fovRadians / 2) * getCameraViewAspect(size))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (isOrthographicCamera(camera)) {
|
|
163
|
+
return Math.max(0.001, (camera.right - camera.left) / camera.zoom)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return Math.max(0.001, distance)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getAngleDeltaRadians(a: number, b: number) {
|
|
170
|
+
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function nearestEquivalentRadians(angle: number, reference: number) {
|
|
174
|
+
return reference + getAngleDeltaRadians(angle, reference)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function clampFinite(value: number, min: number, max: number) {
|
|
178
|
+
const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY
|
|
179
|
+
const resolvedMax = Number.isFinite(max) ? max : Number.POSITIVE_INFINITY
|
|
180
|
+
return Math.min(Math.max(value, resolvedMin), resolvedMax)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function clampCameraControlDistance(control: CameraControlsImpl, distance: number) {
|
|
184
|
+
const bounds = control as { minDistance?: number; maxDistance?: number }
|
|
185
|
+
return clampFinite(
|
|
186
|
+
distance,
|
|
187
|
+
bounds.minDistance ?? Number.NEGATIVE_INFINITY,
|
|
188
|
+
bounds.maxDistance ?? Number.POSITIVE_INFINITY,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function clampCameraControlZoom(control: CameraControlsImpl, zoom: number) {
|
|
193
|
+
const bounds = control as { minZoom?: number; maxZoom?: number }
|
|
194
|
+
return clampFinite(
|
|
195
|
+
zoom,
|
|
196
|
+
bounds.minZoom ?? Number.NEGATIVE_INFINITY,
|
|
197
|
+
bounds.maxZoom ?? Number.POSITIVE_INFINITY,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isCameraAtNavigationPose(
|
|
202
|
+
pose: NavigationCameraPoseSnapshot,
|
|
203
|
+
target: Vector3,
|
|
204
|
+
azimuth: number,
|
|
205
|
+
viewWidth: number,
|
|
206
|
+
) {
|
|
207
|
+
return (
|
|
208
|
+
Math.abs(pose.target[0] - target.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
|
209
|
+
Math.abs(pose.target[1] - target.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
|
210
|
+
Math.abs(pose.target[2] - target.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
|
211
|
+
Math.abs(getAngleDeltaRadians(pose.azimuth, azimuth)) < NAVIGATION_SYNC_AZIMUTH_EPSILON &&
|
|
212
|
+
Math.abs(pose.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getCameraDistanceForViewWidth(
|
|
217
|
+
camera: Camera,
|
|
218
|
+
viewWidth: number,
|
|
219
|
+
size: CameraViewportSize,
|
|
220
|
+
) {
|
|
221
|
+
if (!isPerspectiveCamera(camera)) {
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const fovRadians = (camera.getEffectiveFOV() * Math.PI) / 180
|
|
226
|
+
const denominator = 2 * Math.tan(fovRadians / 2) * getCameraViewAspect(size)
|
|
227
|
+
|
|
228
|
+
return denominator > 0 ? Math.max(0.001, viewWidth / denominator) : null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getCameraZoomForViewWidth(camera: Camera, viewWidth: number) {
|
|
232
|
+
if (!isOrthographicCamera(camera)) {
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return viewWidth > 0 ? Math.max(0.001, (camera.right - camera.left) / viewWidth) : null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function resolveCameraViewWidthUpdate(
|
|
240
|
+
control: CameraControlsImpl,
|
|
241
|
+
camera: Camera,
|
|
242
|
+
viewWidth: number,
|
|
243
|
+
size: CameraViewportSize,
|
|
244
|
+
): CameraViewWidthUpdate {
|
|
245
|
+
const nextDistance = getCameraDistanceForViewWidth(camera, viewWidth, size)
|
|
246
|
+
if (nextDistance !== null) {
|
|
247
|
+
const appliedDistance = clampCameraControlDistance(control, nextDistance)
|
|
248
|
+
return {
|
|
249
|
+
type: 'distance',
|
|
250
|
+
distance: appliedDistance,
|
|
251
|
+
viewWidth: getCameraViewWidth(camera, appliedDistance, size),
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const nextZoom = getCameraZoomForViewWidth(camera, viewWidth)
|
|
256
|
+
if (nextZoom !== null) {
|
|
257
|
+
const appliedZoom = clampCameraControlZoom(control, nextZoom)
|
|
258
|
+
if (isOrthographicCamera(camera)) {
|
|
259
|
+
return {
|
|
260
|
+
type: 'zoom',
|
|
261
|
+
zoom: appliedZoom,
|
|
262
|
+
viewWidth: Math.max(0.001, (camera.right - camera.left) / Math.max(appliedZoom, 0.001)),
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { type: 'none', viewWidth }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function applyCameraViewWidth(control: CameraControlsImpl, update: CameraViewWidthUpdate) {
|
|
271
|
+
if (update.type === 'distance') {
|
|
272
|
+
control.dollyTo(update.distance, true)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (update.type === 'zoom') {
|
|
277
|
+
control.zoomTo(update.zoom, true)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function useFirstPersonCameraPoseRestore(
|
|
282
|
+
controls: { current: CameraControlsImpl | null },
|
|
283
|
+
isFirstPersonMode: boolean,
|
|
284
|
+
cameraMode: CameraMode,
|
|
285
|
+
) {
|
|
286
|
+
const restorePose = useRef<CameraPoseSnapshot>({
|
|
287
|
+
mode: cameraMode,
|
|
288
|
+
position: [0, 0, 0],
|
|
289
|
+
target: [0, 0, 0],
|
|
290
|
+
})
|
|
291
|
+
const hasRestorePose = useRef(false)
|
|
292
|
+
const isRestoring = useRef(false)
|
|
293
|
+
const wasFirstPersonMode = useRef(isFirstPersonMode)
|
|
294
|
+
const snapshotPosition = useRef(new Vector3())
|
|
295
|
+
const snapshotTarget = useRef(new Vector3())
|
|
296
|
+
|
|
297
|
+
useFrame(() => {
|
|
298
|
+
if (isFirstPersonMode || isRestoring.current) return
|
|
299
|
+
const control = controls.current
|
|
300
|
+
if (!control) return
|
|
301
|
+
|
|
302
|
+
saveCameraPose(
|
|
303
|
+
control,
|
|
304
|
+
cameraMode,
|
|
305
|
+
restorePose.current,
|
|
306
|
+
snapshotPosition.current,
|
|
307
|
+
snapshotTarget.current,
|
|
308
|
+
)
|
|
309
|
+
hasRestorePose.current = true
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
const wasFirstPerson = wasFirstPersonMode.current
|
|
314
|
+
wasFirstPersonMode.current = isFirstPersonMode
|
|
315
|
+
|
|
316
|
+
if (isFirstPersonMode) {
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!wasFirstPerson || !hasRestorePose.current) return
|
|
321
|
+
|
|
322
|
+
const pose = restorePose.current
|
|
323
|
+
isRestoring.current = true
|
|
324
|
+
useViewer.getState().setCameraMode(pose.mode)
|
|
325
|
+
|
|
326
|
+
const restoreFrame = requestAnimationFrame(() => {
|
|
327
|
+
const currentControls = controls.current
|
|
328
|
+
if (currentControls) {
|
|
329
|
+
restoreCameraPose(currentControls, pose)
|
|
330
|
+
}
|
|
331
|
+
isRestoring.current = false
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
return () => {
|
|
335
|
+
cancelAnimationFrame(restoreFrame)
|
|
336
|
+
isRestoring.current = false
|
|
337
|
+
}
|
|
338
|
+
}, [controls, isFirstPersonMode])
|
|
339
|
+
|
|
340
|
+
return useCallback(() => isRestoring.current, [])
|
|
341
|
+
}
|
|
26
342
|
|
|
27
343
|
export const CustomCameraControls = () => {
|
|
28
|
-
const controls = useRef<CameraControlsImpl>(null
|
|
344
|
+
const controls = useRef<CameraControlsImpl | null>(null)
|
|
345
|
+
const keyboardPanKeys = useRef<KeyboardPanState>({
|
|
346
|
+
forward: false,
|
|
347
|
+
backward: false,
|
|
348
|
+
left: false,
|
|
349
|
+
right: false,
|
|
350
|
+
})
|
|
29
351
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
30
352
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
|
31
353
|
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
|
354
|
+
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
|
32
355
|
const selection = useViewer((s) => s.selection)
|
|
356
|
+
const cameraMode = useViewer((state) => state.cameraMode)
|
|
357
|
+
const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
|
|
358
|
+
controls,
|
|
359
|
+
isFirstPersonMode,
|
|
360
|
+
cameraMode,
|
|
361
|
+
)
|
|
33
362
|
const currentLevelId = selection.levelId
|
|
34
363
|
const firstLoad = useRef(true)
|
|
364
|
+
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
|
|
365
|
+
const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
|
|
366
|
+
const lastApplied2dNavigationRevision = useRef(0)
|
|
35
367
|
const maxPolarAngle =
|
|
36
368
|
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
|
|
369
|
+
const clearPendingFloorplanNavigationPose = useCallback(() => {
|
|
370
|
+
pendingFloorplanNavigationPose.current = null
|
|
371
|
+
}, [])
|
|
37
372
|
|
|
38
373
|
const camera = useThree((state) => state.camera)
|
|
374
|
+
const gl = useThree((state) => state.gl)
|
|
39
375
|
const raycaster = useThree((state) => state.raycaster)
|
|
376
|
+
const viewportSize = useThree((state) => state.size)
|
|
40
377
|
useEffect(() => {
|
|
41
378
|
camera.layers.enable(EDITOR_LAYER)
|
|
379
|
+
camera.layers.enable(GRID_LAYER)
|
|
42
380
|
raycaster.layers.enable(EDITOR_LAYER)
|
|
43
381
|
raycaster.layers.enable(ZONE_LAYER)
|
|
44
382
|
}, [camera, raycaster])
|
|
45
383
|
|
|
46
384
|
useEffect(() => {
|
|
47
|
-
if (isPreviewMode
|
|
385
|
+
if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
|
|
48
386
|
let targetY = 0
|
|
49
387
|
if (currentLevelId) {
|
|
50
388
|
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
|
@@ -55,14 +393,22 @@ export const CustomCameraControls = () => {
|
|
|
55
393
|
if (!controls.current) return
|
|
56
394
|
if (firstLoad.current) {
|
|
57
395
|
firstLoad.current = false
|
|
396
|
+
clearPendingFloorplanNavigationPose()
|
|
58
397
|
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
|
59
398
|
}
|
|
60
399
|
controls.current.getTarget(currentTarget)
|
|
400
|
+
clearPendingFloorplanNavigationPose()
|
|
61
401
|
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
|
|
62
|
-
}, [
|
|
402
|
+
}, [
|
|
403
|
+
clearPendingFloorplanNavigationPose,
|
|
404
|
+
currentLevelId,
|
|
405
|
+
isPreviewMode,
|
|
406
|
+
isFirstPersonMode,
|
|
407
|
+
isRestoringFirstPersonPose,
|
|
408
|
+
])
|
|
63
409
|
|
|
64
410
|
useEffect(() => {
|
|
65
|
-
if (!controls.current) return
|
|
411
|
+
if (isFirstPersonMode || !controls.current) return
|
|
66
412
|
|
|
67
413
|
controls.current.maxPolarAngle = maxPolarAngle
|
|
68
414
|
controls.current.minPolarAngle = 0
|
|
@@ -70,11 +416,11 @@ export const CustomCameraControls = () => {
|
|
|
70
416
|
if (controls.current.polarAngle > maxPolarAngle) {
|
|
71
417
|
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
|
|
72
418
|
}
|
|
73
|
-
}, [maxPolarAngle])
|
|
419
|
+
}, [isFirstPersonMode, maxPolarAngle])
|
|
74
420
|
|
|
75
421
|
const focusNode = useCallback(
|
|
76
422
|
(nodeId: string) => {
|
|
77
|
-
if (isPreviewMode || !controls.current) return
|
|
423
|
+
if (isPreviewMode || isFirstPersonMode || !controls.current) return
|
|
78
424
|
|
|
79
425
|
const object3D = sceneRegistry.nodes.get(nodeId)
|
|
80
426
|
if (!object3D) return
|
|
@@ -87,6 +433,7 @@ export const CustomCameraControls = () => {
|
|
|
87
433
|
controls.current.getTarget(tempTarget)
|
|
88
434
|
tempDelta.copy(tempCenter).sub(tempTarget)
|
|
89
435
|
|
|
436
|
+
clearPendingFloorplanNavigationPose()
|
|
90
437
|
controls.current.setLookAt(
|
|
91
438
|
tempPosition.x + tempDelta.x,
|
|
92
439
|
tempPosition.y + tempDelta.y,
|
|
@@ -97,11 +444,141 @@ export const CustomCameraControls = () => {
|
|
|
97
444
|
true,
|
|
98
445
|
)
|
|
99
446
|
},
|
|
100
|
-
[isPreviewMode],
|
|
447
|
+
[clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode],
|
|
101
448
|
)
|
|
102
449
|
|
|
450
|
+
useEffect(() => {
|
|
451
|
+
if (isFirstPersonMode) return
|
|
452
|
+
|
|
453
|
+
return useEditor.subscribe((state) => {
|
|
454
|
+
const pose = state.navigationSyncPose
|
|
455
|
+
if (
|
|
456
|
+
!pose ||
|
|
457
|
+
pose.source !== '2d' ||
|
|
458
|
+
pose.revision === lastApplied2dNavigationRevision.current
|
|
459
|
+
)
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
const control = controls.current
|
|
463
|
+
if (!control) return
|
|
464
|
+
|
|
465
|
+
lastApplied2dNavigationRevision.current = pose.revision
|
|
466
|
+
const targetAzimuth = nearestEquivalentRadians(pose.azimuth, control.azimuthAngle)
|
|
467
|
+
const viewWidthUpdate = resolveCameraViewWidthUpdate(
|
|
468
|
+
control,
|
|
469
|
+
camera,
|
|
470
|
+
pose.viewWidth,
|
|
471
|
+
viewportSize,
|
|
472
|
+
)
|
|
473
|
+
pendingFloorplanNavigationPose.current = {
|
|
474
|
+
target: [...pose.target],
|
|
475
|
+
azimuth: targetAzimuth,
|
|
476
|
+
viewWidth: viewWidthUpdate.viewWidth,
|
|
477
|
+
publishOnComplete:
|
|
478
|
+
Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
|
|
479
|
+
NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
|
|
480
|
+
}
|
|
481
|
+
control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
|
|
482
|
+
control.rotateTo(targetAzimuth, control.polarAngle, true)
|
|
483
|
+
applyCameraViewWidth(control, viewWidthUpdate)
|
|
484
|
+
})
|
|
485
|
+
}, [camera, isFirstPersonMode, viewportSize])
|
|
486
|
+
|
|
487
|
+
const publishCurrentNavigationPose = useCallback(() => {
|
|
488
|
+
if (isFirstPersonMode || !controls.current) return
|
|
489
|
+
|
|
490
|
+
controls.current.getTarget(syncTarget, false)
|
|
491
|
+
controls.current.getSpherical(syncSpherical, false)
|
|
492
|
+
const viewWidth = getCameraViewWidth(camera, syncSpherical.radius, viewportSize)
|
|
493
|
+
|
|
494
|
+
const pendingFloorplanPose = pendingFloorplanNavigationPose.current
|
|
495
|
+
if (pendingFloorplanPose) {
|
|
496
|
+
// The camera is still damping toward a 2D-originated pose; do not echo
|
|
497
|
+
// intermediate 3D poses back into the floorplan.
|
|
498
|
+
if (
|
|
499
|
+
isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
|
|
500
|
+
) {
|
|
501
|
+
lastPublishedNavigationSync.current = pendingFloorplanPose
|
|
502
|
+
pendingFloorplanNavigationPose.current = null
|
|
503
|
+
if (pendingFloorplanPose.publishOnComplete) {
|
|
504
|
+
useEditor.getState().publishNavigationSyncPose({
|
|
505
|
+
source: '3d',
|
|
506
|
+
target: [
|
|
507
|
+
pendingFloorplanPose.target[0],
|
|
508
|
+
pendingFloorplanPose.target[1],
|
|
509
|
+
pendingFloorplanPose.target[2],
|
|
510
|
+
],
|
|
511
|
+
azimuth: pendingFloorplanPose.azimuth,
|
|
512
|
+
viewWidth: pendingFloorplanPose.viewWidth,
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const previous = lastPublishedNavigationSync.current
|
|
520
|
+
if (
|
|
521
|
+
previous &&
|
|
522
|
+
Math.abs(previous.target[0] - syncTarget.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
|
523
|
+
Math.abs(previous.target[1] - syncTarget.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
|
524
|
+
Math.abs(previous.target[2] - syncTarget.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
|
525
|
+
Math.abs(getAngleDeltaRadians(previous.azimuth, syncSpherical.theta)) <
|
|
526
|
+
NAVIGATION_SYNC_AZIMUTH_EPSILON &&
|
|
527
|
+
Math.abs(previous.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
|
|
528
|
+
) {
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
lastPublishedNavigationSync.current = {
|
|
533
|
+
target: [syncTarget.x, syncTarget.y, syncTarget.z],
|
|
534
|
+
azimuth: syncSpherical.theta,
|
|
535
|
+
viewWidth,
|
|
536
|
+
}
|
|
537
|
+
useEditor.getState().publishNavigationSyncPose({
|
|
538
|
+
source: '3d',
|
|
539
|
+
target: [syncTarget.x, syncTarget.y, syncTarget.z],
|
|
540
|
+
azimuth: syncSpherical.theta,
|
|
541
|
+
viewWidth,
|
|
542
|
+
})
|
|
543
|
+
}, [camera, isFirstPersonMode, viewportSize])
|
|
544
|
+
|
|
545
|
+
useEffect(() => {
|
|
546
|
+
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
|
|
547
|
+
|
|
548
|
+
const frame = requestAnimationFrame(() => {
|
|
549
|
+
lastPublishedNavigationSync.current = null
|
|
550
|
+
publishCurrentNavigationPose()
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
return () => {
|
|
554
|
+
cancelAnimationFrame(frame)
|
|
555
|
+
}
|
|
556
|
+
}, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose])
|
|
557
|
+
|
|
558
|
+
useFrame((_, delta) => {
|
|
559
|
+
if (isFirstPersonMode || !controls.current) return
|
|
560
|
+
|
|
561
|
+
const panKeys = keyboardPanKeys.current
|
|
562
|
+
const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0)
|
|
563
|
+
const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0)
|
|
564
|
+
if (horizontal === 0 && vertical === 0) return
|
|
565
|
+
|
|
566
|
+
const control = controls.current
|
|
567
|
+
|
|
568
|
+
control.getSpherical(keyboardPanSpherical, false)
|
|
569
|
+
const viewWidth = getCameraViewWidth(camera, keyboardPanSpherical.radius, viewportSize)
|
|
570
|
+
const speed = Math.min(
|
|
571
|
+
Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED),
|
|
572
|
+
KEYBOARD_PAN_MAX_SPEED,
|
|
573
|
+
)
|
|
574
|
+
const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
|
|
575
|
+
|
|
576
|
+
pendingFloorplanNavigationPose.current = null
|
|
577
|
+
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
|
|
578
|
+
if (vertical !== 0) control.forward(vertical * step, true)
|
|
579
|
+
})
|
|
580
|
+
|
|
103
581
|
// Configure mouse buttons based on control mode and camera mode
|
|
104
|
-
const cameraMode = useViewer((state) => state.cameraMode)
|
|
105
582
|
const mouseButtons = useMemo(() => {
|
|
106
583
|
// Use ZOOM for orthographic camera, DOLLY for perspective camera
|
|
107
584
|
const wheelAction =
|
|
@@ -137,9 +614,15 @@ export const CustomCameraControls = () => {
|
|
|
137
614
|
const movingNode = useEditor((s) => s.movingNode)
|
|
138
615
|
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
|
139
616
|
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
|
617
|
+
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
|
140
618
|
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
|
|
141
619
|
const isInteracting = Boolean(
|
|
142
|
-
tool ||
|
|
620
|
+
tool ||
|
|
621
|
+
movingNode ||
|
|
622
|
+
movingWallEndpoint ||
|
|
623
|
+
movingFenceEndpoint ||
|
|
624
|
+
activeHandleDrag ||
|
|
625
|
+
isBoxSelectActive,
|
|
143
626
|
)
|
|
144
627
|
const touches = useMemo(() => {
|
|
145
628
|
const twoFingerAction =
|
|
@@ -161,6 +644,8 @@ export const CustomCameraControls = () => {
|
|
|
161
644
|
}, [cameraMode, isPreviewMode, isInteracting])
|
|
162
645
|
|
|
163
646
|
useEffect(() => {
|
|
647
|
+
if (isFirstPersonMode) return
|
|
648
|
+
|
|
164
649
|
const keyState = {
|
|
165
650
|
shiftRight: false,
|
|
166
651
|
shiftLeft: false,
|
|
@@ -168,6 +653,52 @@ export const CustomCameraControls = () => {
|
|
|
168
653
|
controlLeft: false,
|
|
169
654
|
space: false,
|
|
170
655
|
}
|
|
656
|
+
let ownsNavigationCursor = false
|
|
657
|
+
let panPointerId: number | null = null
|
|
658
|
+
let panPointerButton: number | null = null
|
|
659
|
+
|
|
660
|
+
const clearKeyboardPanKeys = () => {
|
|
661
|
+
keyboardPanKeys.current.forward = false
|
|
662
|
+
keyboardPanKeys.current.backward = false
|
|
663
|
+
keyboardPanKeys.current.left = false
|
|
664
|
+
keyboardPanKeys.current.right = false
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const setNavigationCursor = (cursor: 'grab' | 'grabbing') => {
|
|
668
|
+
document.body.style.cursor = cursor
|
|
669
|
+
gl.domElement.style.cursor = cursor
|
|
670
|
+
ownsNavigationCursor = true
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const clearNavigationCursor = () => {
|
|
674
|
+
if (
|
|
675
|
+
ownsNavigationCursor &&
|
|
676
|
+
(document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing')
|
|
677
|
+
) {
|
|
678
|
+
document.body.style.cursor = ''
|
|
679
|
+
}
|
|
680
|
+
if (ownsNavigationCursor && gl.domElement.style.cursor === 'grab') {
|
|
681
|
+
gl.domElement.style.cursor = ''
|
|
682
|
+
}
|
|
683
|
+
if (ownsNavigationCursor && gl.domElement.style.cursor === 'grabbing') {
|
|
684
|
+
gl.domElement.style.cursor = ''
|
|
685
|
+
}
|
|
686
|
+
ownsNavigationCursor = false
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const updateNavigationCursor = () => {
|
|
690
|
+
if (panPointerId !== null) {
|
|
691
|
+
setNavigationCursor('grabbing')
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (keyState.space) {
|
|
696
|
+
setNavigationCursor('grab')
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
clearNavigationCursor()
|
|
701
|
+
}
|
|
171
702
|
|
|
172
703
|
const updateConfig = () => {
|
|
173
704
|
if (!controls.current) return
|
|
@@ -194,9 +725,24 @@ export const CustomCameraControls = () => {
|
|
|
194
725
|
}
|
|
195
726
|
|
|
196
727
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
728
|
+
if (isKeyboardPanKey(event.code)) {
|
|
729
|
+
if (
|
|
730
|
+
!(event.metaKey || event.ctrlKey || event.altKey) &&
|
|
731
|
+
!isEditableKeyboardTarget(event.target)
|
|
732
|
+
) {
|
|
733
|
+
setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
|
|
734
|
+
pendingFloorplanNavigationPose.current = null
|
|
735
|
+
event.preventDefault()
|
|
736
|
+
event.stopPropagation()
|
|
737
|
+
}
|
|
738
|
+
return
|
|
739
|
+
}
|
|
740
|
+
|
|
197
741
|
if (event.code === 'Space') {
|
|
742
|
+
if (isEditableKeyboardTarget(event.target)) return
|
|
743
|
+
event.preventDefault()
|
|
198
744
|
keyState.space = true
|
|
199
|
-
|
|
745
|
+
updateNavigationCursor()
|
|
200
746
|
}
|
|
201
747
|
if (event.code === 'ShiftRight') {
|
|
202
748
|
keyState.shiftRight = true
|
|
@@ -214,9 +760,22 @@ export const CustomCameraControls = () => {
|
|
|
214
760
|
}
|
|
215
761
|
|
|
216
762
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
763
|
+
if (isKeyboardPanKey(event.code)) {
|
|
764
|
+
const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false)
|
|
765
|
+
if (changed) {
|
|
766
|
+
event.preventDefault()
|
|
767
|
+
event.stopPropagation()
|
|
768
|
+
}
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
|
|
217
772
|
if (event.code === 'Space') {
|
|
218
773
|
keyState.space = false
|
|
219
|
-
|
|
774
|
+
if (panPointerButton === 0) {
|
|
775
|
+
panPointerId = null
|
|
776
|
+
panPointerButton = null
|
|
777
|
+
}
|
|
778
|
+
updateNavigationCursor()
|
|
220
779
|
}
|
|
221
780
|
if (event.code === 'ShiftRight') {
|
|
222
781
|
keyState.shiftRight = false
|
|
@@ -233,15 +792,60 @@ export const CustomCameraControls = () => {
|
|
|
233
792
|
updateConfig()
|
|
234
793
|
}
|
|
235
794
|
|
|
795
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
796
|
+
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
|
|
797
|
+
pendingFloorplanNavigationPose.current = null
|
|
798
|
+
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
|
|
799
|
+
|
|
800
|
+
panPointerId = event.pointerId
|
|
801
|
+
panPointerButton = event.button
|
|
802
|
+
updateNavigationCursor()
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const onWheel = () => {
|
|
806
|
+
pendingFloorplanNavigationPose.current = null
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
810
|
+
if (panPointerId === null) return
|
|
811
|
+
if (event.type !== 'pointercancel' && event.pointerId !== panPointerId) return
|
|
812
|
+
if (event.type !== 'pointercancel' && event.button !== panPointerButton) return
|
|
813
|
+
|
|
814
|
+
panPointerId = null
|
|
815
|
+
panPointerButton = null
|
|
816
|
+
updateNavigationCursor()
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const onBlur = () => {
|
|
820
|
+
keyState.space = false
|
|
821
|
+
clearKeyboardPanKeys()
|
|
822
|
+
panPointerId = null
|
|
823
|
+
panPointerButton = null
|
|
824
|
+
clearNavigationCursor()
|
|
825
|
+
updateConfig()
|
|
826
|
+
}
|
|
827
|
+
|
|
236
828
|
document.addEventListener('keydown', onKeyDown)
|
|
237
829
|
document.addEventListener('keyup', onKeyUp)
|
|
830
|
+
window.addEventListener('pointerdown', onPointerDown, true)
|
|
831
|
+
window.addEventListener('pointerup', onPointerUp, true)
|
|
832
|
+
window.addEventListener('pointercancel', onPointerUp, true)
|
|
833
|
+
window.addEventListener('blur', onBlur)
|
|
834
|
+
gl.domElement.addEventListener('wheel', onWheel, { passive: true })
|
|
238
835
|
updateConfig()
|
|
239
836
|
|
|
240
837
|
return () => {
|
|
241
838
|
document.removeEventListener('keydown', onKeyDown)
|
|
242
839
|
document.removeEventListener('keyup', onKeyUp)
|
|
840
|
+
window.removeEventListener('pointerdown', onPointerDown, true)
|
|
841
|
+
window.removeEventListener('pointerup', onPointerUp, true)
|
|
842
|
+
window.removeEventListener('pointercancel', onPointerUp, true)
|
|
843
|
+
window.removeEventListener('blur', onBlur)
|
|
844
|
+
gl.domElement.removeEventListener('wheel', onWheel)
|
|
845
|
+
clearKeyboardPanKeys()
|
|
846
|
+
clearNavigationCursor()
|
|
243
847
|
}
|
|
244
|
-
}, [cameraMode, isPreviewMode])
|
|
848
|
+
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
|
|
245
849
|
|
|
246
850
|
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
|
247
851
|
const previewTargetNodeId = isPreviewMode
|
|
@@ -249,7 +853,7 @@ export const CustomCameraControls = () => {
|
|
|
249
853
|
: null
|
|
250
854
|
|
|
251
855
|
useEffect(() => {
|
|
252
|
-
if (!(isPreviewMode && controls.current)) return
|
|
856
|
+
if (!(isPreviewMode && controls.current) || isFirstPersonMode) return
|
|
253
857
|
|
|
254
858
|
const nodes = useScene.getState().nodes
|
|
255
859
|
let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
|
|
@@ -309,11 +913,122 @@ export const CustomCameraControls = () => {
|
|
|
309
913
|
tempCenter.z,
|
|
310
914
|
true,
|
|
311
915
|
)
|
|
312
|
-
}, [isPreviewMode, previewTargetNodeId])
|
|
916
|
+
}, [isPreviewMode, isFirstPersonMode, previewTargetNodeId])
|
|
313
917
|
|
|
918
|
+
// Preset capture auto-framing — when `setCaptureMode({ mode: 'preset',
|
|
919
|
+
// isolated })` fires, fly the camera to a pose that fits the union
|
|
920
|
+
// bounds of the isolated subtree inside the locked square crop. The
|
|
921
|
+
// user can still pan / orbit / zoom from there; we only set the
|
|
922
|
+
// initial pose. On exit (`mode: 'idle'`), we restore the previous
|
|
923
|
+
// pose so the user lands back exactly where they were before the
|
|
924
|
+
// modal opened.
|
|
925
|
+
const captureMode = useEditor((s) => s.captureMode)
|
|
314
926
|
useEffect(() => {
|
|
315
|
-
|
|
927
|
+
if (isFirstPersonMode) return
|
|
928
|
+
if (!controls.current) return
|
|
929
|
+
if (captureMode.mode !== 'preset') return
|
|
930
|
+
const ids = captureMode.isolated
|
|
931
|
+
if (ids.length === 0) return
|
|
932
|
+
|
|
933
|
+
// Stash the pre-capture pose so we can restore it on exit. Using
|
|
934
|
+
// a ref keeps the value across the cleanup phase without
|
|
935
|
+
// re-renders.
|
|
936
|
+
const restorePos = new Vector3()
|
|
937
|
+
const restoreTarget = new Vector3()
|
|
938
|
+
controls.current.getPosition(restorePos)
|
|
939
|
+
controls.current.getTarget(restoreTarget)
|
|
940
|
+
|
|
941
|
+
// Union the bounds of every isolated subtree root. `setFromObject`
|
|
942
|
+
// walks the Three.js descendants automatically, so this picks up
|
|
943
|
+
// synthesized children (door/window cutouts under a wall, etc.).
|
|
944
|
+
tempBox.makeEmpty()
|
|
945
|
+
for (const id of ids) {
|
|
946
|
+
const obj = sceneRegistry.nodes.get(id)
|
|
947
|
+
if (!obj) continue
|
|
948
|
+
const sub = new Box3().setFromObject(obj)
|
|
949
|
+
if (!sub.isEmpty()) tempBox.union(sub)
|
|
950
|
+
}
|
|
951
|
+
if (captureMode.framingBounds) {
|
|
952
|
+
const { center, max, min, size } = captureMode.framingBounds
|
|
953
|
+
const fallbackHeight = Math.max(Math.max(size[0], size[1]) * 0.35, 2.5)
|
|
954
|
+
const minY = tempBox.isEmpty() ? 0 : tempBox.min.y
|
|
955
|
+
const maxY = tempBox.isEmpty() ? fallbackHeight : tempBox.max.y
|
|
956
|
+
tempBox.min.set(min[0], minY, min[1])
|
|
957
|
+
tempBox.max.set(max[0], Math.max(maxY, minY + 0.1), max[1])
|
|
958
|
+
tempCenter.set(center[0], (tempBox.min.y + tempBox.max.y) / 2, center[1])
|
|
959
|
+
tempSize.set(size[0], tempBox.max.y - tempBox.min.y, size[1])
|
|
960
|
+
} else {
|
|
961
|
+
if (tempBox.isEmpty()) return
|
|
962
|
+
|
|
963
|
+
tempBox.getCenter(tempCenter)
|
|
964
|
+
tempBox.getSize(tempSize)
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Distance heuristic: fit the subject inside the 75%-of-shorter-
|
|
968
|
+
// side square crop with comfortable padding. Multiplier 2.4 leaves
|
|
969
|
+
// ~25-30% margin around the bounds so the user can frame without
|
|
970
|
+
// immediately needing to zoom out, but isn't so far away that the
|
|
971
|
+
// subject reads as small in the thumbnail.
|
|
972
|
+
const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
|
|
973
|
+
const distance = Math.max(maxDim * 2.4, 4)
|
|
974
|
+
|
|
975
|
+
// Frame the subject from a 3/4 view of its front face. The node's
|
|
976
|
+
// local +Z is its forward axis in this scene's authoring convention
|
|
977
|
+
// (the face the user sets up to be photographed). When a single
|
|
978
|
+
// subtree is isolated we read its yaw and rotate the camera around
|
|
979
|
+
// the bounds center so the framing follows the user's authored
|
|
980
|
+
// orientation; for multi-isolate sets we fall back to world +Z.
|
|
981
|
+
// The 3/4 view offsets the camera by 35° to the right of dead-on
|
|
982
|
+
// so both the front face and a side are visible — the "nice angle"
|
|
983
|
+
// that reads as a product shot rather than a flat elevation. ~25°
|
|
984
|
+
// elevation keeps the top visible without going isometric.
|
|
985
|
+
const SIDE_OFFSET_RAD = (35 * Math.PI) / 180
|
|
986
|
+
const ELEVATION_RAD = (25 * Math.PI) / 180
|
|
987
|
+
let yaw = 0
|
|
988
|
+
if (ids.length === 1) {
|
|
989
|
+
const node = useScene.getState().nodes[ids[0] as AnyNodeId]
|
|
990
|
+
if (node && 'rotation' in node) {
|
|
991
|
+
const r = (node as { rotation?: unknown }).rotation
|
|
992
|
+
if (typeof r === 'number') yaw = r
|
|
993
|
+
else if (Array.isArray(r)) yaw = (r as [number, number, number])[1] ?? 0
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// World-space direction the camera should sit *along* relative to
|
|
997
|
+
// bounds center: in front (object's local +Z under yaw) + a right
|
|
998
|
+
// offset around Y for the 3/4 read.
|
|
999
|
+
const viewAngle = yaw + SIDE_OFFSET_RAD
|
|
1000
|
+
const horizontal = distance * Math.cos(ELEVATION_RAD)
|
|
1001
|
+
const elevation = distance * Math.sin(ELEVATION_RAD)
|
|
1002
|
+
controls.current.setLookAt(
|
|
1003
|
+
tempCenter.x + Math.sin(viewAngle) * horizontal,
|
|
1004
|
+
tempCenter.y + elevation,
|
|
1005
|
+
tempCenter.z + Math.cos(viewAngle) * horizontal,
|
|
1006
|
+
tempCenter.x,
|
|
1007
|
+
tempCenter.y,
|
|
1008
|
+
tempCenter.z,
|
|
1009
|
+
true,
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
return () => {
|
|
1013
|
+
// Cleanup runs on captureMode change *or* unmount. Restore the
|
|
1014
|
+
// pre-capture pose only if the controls are still around (during
|
|
1015
|
+
// unmount they might be torn down already).
|
|
316
1016
|
if (!controls.current) return
|
|
1017
|
+
controls.current.setLookAt(
|
|
1018
|
+
restorePos.x,
|
|
1019
|
+
restorePos.y,
|
|
1020
|
+
restorePos.z,
|
|
1021
|
+
restoreTarget.x,
|
|
1022
|
+
restoreTarget.y,
|
|
1023
|
+
restoreTarget.z,
|
|
1024
|
+
true,
|
|
1025
|
+
)
|
|
1026
|
+
}
|
|
1027
|
+
}, [captureMode, isFirstPersonMode])
|
|
1028
|
+
|
|
1029
|
+
useEffect(() => {
|
|
1030
|
+
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
|
|
1031
|
+
if (isFirstPersonMode || !controls.current) return
|
|
317
1032
|
|
|
318
1033
|
const position = new Vector3()
|
|
319
1034
|
const target = new Vector3()
|
|
@@ -331,12 +1046,13 @@ export const CustomCameraControls = () => {
|
|
|
331
1046
|
})
|
|
332
1047
|
}
|
|
333
1048
|
const handleNodeView = ({ nodeId }: CameraControlEvent) => {
|
|
334
|
-
if (!controls.current) return
|
|
1049
|
+
if (isFirstPersonMode || !controls.current) return
|
|
335
1050
|
|
|
336
1051
|
const node = useScene.getState().nodes[nodeId]
|
|
337
1052
|
if (!node?.camera) return
|
|
338
1053
|
const { position, target } = node.camera
|
|
339
1054
|
|
|
1055
|
+
clearPendingFloorplanNavigationPose()
|
|
340
1056
|
controls.current.setLookAt(
|
|
341
1057
|
position[0],
|
|
342
1058
|
position[1],
|
|
@@ -349,7 +1065,7 @@ export const CustomCameraControls = () => {
|
|
|
349
1065
|
}
|
|
350
1066
|
|
|
351
1067
|
const handleTopView = () => {
|
|
352
|
-
if (!controls.current) return
|
|
1068
|
+
if (isFirstPersonMode || !controls.current) return
|
|
353
1069
|
|
|
354
1070
|
const currentPolarAngle = controls.current.polarAngle
|
|
355
1071
|
|
|
@@ -357,11 +1073,12 @@ export const CustomCameraControls = () => {
|
|
|
357
1073
|
// Otherwise, go to top view (0°)
|
|
358
1074
|
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
|
|
359
1075
|
|
|
1076
|
+
clearPendingFloorplanNavigationPose()
|
|
360
1077
|
controls.current.rotatePolarTo(targetAngle, true)
|
|
361
1078
|
}
|
|
362
1079
|
|
|
363
1080
|
const handleOrbitCW = () => {
|
|
364
|
-
if (!controls.current) return
|
|
1081
|
+
if (isFirstPersonMode || !controls.current) return
|
|
365
1082
|
|
|
366
1083
|
const currentAzimuth = controls.current.azimuthAngle
|
|
367
1084
|
const currentPolar = controls.current.polarAngle
|
|
@@ -369,11 +1086,12 @@ export const CustomCameraControls = () => {
|
|
|
369
1086
|
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
|
370
1087
|
const target = rounded - Math.PI / 2
|
|
371
1088
|
|
|
1089
|
+
clearPendingFloorplanNavigationPose()
|
|
372
1090
|
controls.current.rotateTo(target, currentPolar, true)
|
|
373
1091
|
}
|
|
374
1092
|
|
|
375
1093
|
const handleOrbitCCW = () => {
|
|
376
|
-
if (!controls.current) return
|
|
1094
|
+
if (isFirstPersonMode || !controls.current) return
|
|
377
1095
|
|
|
378
1096
|
const currentAzimuth = controls.current.azimuthAngle
|
|
379
1097
|
const currentPolar = controls.current.polarAngle
|
|
@@ -381,6 +1099,7 @@ export const CustomCameraControls = () => {
|
|
|
381
1099
|
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
|
382
1100
|
const target = rounded + Math.PI / 2
|
|
383
1101
|
|
|
1102
|
+
clearPendingFloorplanNavigationPose()
|
|
384
1103
|
controls.current.rotateTo(target, currentPolar, true)
|
|
385
1104
|
}
|
|
386
1105
|
|
|
@@ -389,9 +1108,10 @@ export const CustomCameraControls = () => {
|
|
|
389
1108
|
}
|
|
390
1109
|
|
|
391
1110
|
const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
|
|
392
|
-
if (!controls.current || isPreviewMode) return
|
|
1111
|
+
if (isFirstPersonMode || !controls.current || isPreviewMode) return
|
|
393
1112
|
if (!bounds) {
|
|
394
1113
|
// Restore default framing pose when no bounds were computed.
|
|
1114
|
+
clearPendingFloorplanNavigationPose()
|
|
395
1115
|
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
|
396
1116
|
return
|
|
397
1117
|
}
|
|
@@ -402,6 +1122,7 @@ export const CustomCameraControls = () => {
|
|
|
402
1122
|
const maxExtent = Math.max(w, d)
|
|
403
1123
|
const distance = Math.max(maxExtent * 1.4, 15)
|
|
404
1124
|
const height = Math.max(maxExtent * 0.8, 10)
|
|
1125
|
+
clearPendingFloorplanNavigationPose()
|
|
405
1126
|
controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
|
|
406
1127
|
}
|
|
407
1128
|
|
|
@@ -422,7 +1143,7 @@ export const CustomCameraControls = () => {
|
|
|
422
1143
|
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
|
423
1144
|
emitter.off('camera-controls:fit-scene', handleFitScene)
|
|
424
1145
|
}
|
|
425
|
-
}, [focusNode, isPreviewMode])
|
|
1146
|
+
}, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
|
|
426
1147
|
|
|
427
1148
|
const onTransitionStart = useCallback(() => {
|
|
428
1149
|
useViewer.getState().setCameraDragging(true)
|
|
@@ -432,6 +1153,14 @@ export const CustomCameraControls = () => {
|
|
|
432
1153
|
useViewer.getState().setCameraDragging(false)
|
|
433
1154
|
}, [])
|
|
434
1155
|
|
|
1156
|
+
// Preset capture mode frames a single subtree (often a 0.3–2m preset),
|
|
1157
|
+
// so the default 6m minDistance prevents the user from getting close
|
|
1158
|
+
// enough to compose a good thumbnail. Relax the clamp to 0.5m while
|
|
1159
|
+
// capturing presets; reset on exit so general editing keeps the looser
|
|
1160
|
+
// navigation guardrails.
|
|
1161
|
+
const isPresetCapture = captureMode.mode === 'preset'
|
|
1162
|
+
const minDistance = isPresetCapture ? 0.5 : 6
|
|
1163
|
+
|
|
435
1164
|
if (isFirstPersonMode) {
|
|
436
1165
|
return null
|
|
437
1166
|
}
|
|
@@ -441,9 +1170,10 @@ export const CustomCameraControls = () => {
|
|
|
441
1170
|
makeDefault
|
|
442
1171
|
maxDistance={100}
|
|
443
1172
|
maxPolarAngle={maxPolarAngle}
|
|
444
|
-
minDistance={
|
|
1173
|
+
minDistance={minDistance}
|
|
445
1174
|
minPolarAngle={0}
|
|
446
1175
|
mouseButtons={mouseButtons}
|
|
1176
|
+
onUpdate={publishCurrentNavigationPose}
|
|
447
1177
|
onRest={onRest}
|
|
448
1178
|
onSleep={onRest}
|
|
449
1179
|
onTransitionStart={onTransitionStart}
|