@pascal-app/editor 0.8.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 +8 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +754 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +58 -51
- 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 +201 -83
- package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
- package/src/components/editor/first-person-controls.tsx +825 -20
- 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 +5829 -12005
- 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 +178 -149
- 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 +433 -156
- 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 +136 -62
- package/src/components/editor/thumbnail-generator.tsx +9 -3
- package/src/components/editor/use-floorplan-background-placement.ts +119 -32
- 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-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- 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 +24 -9
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
- 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 +1 -1
- 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 +41 -105
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +31 -5
- package/src/components/tools/item/placement-strategies.ts +190 -15
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
- 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 -538
- 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 +56 -0
- 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 +16 -6
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/control-modes.tsx +5 -82
- package/src/components/ui/action-menu/index.tsx +1 -113
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +243 -6
- package/src/components/ui/command-palette/editor-commands.tsx +18 -13
- package/src/components/ui/command-palette/index.tsx +2 -1
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +11 -32
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/helper-manager.tsx +16 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/panel-manager.tsx +35 -47
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- 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 +4 -6
- 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 +7 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +61 -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 +10 -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 +115 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +237 -5
- 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.ts +54 -42
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +211 -19
- 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 +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +23 -1
- package/src/lib/sfx-player.ts +70 -22
- package/src/lib/site-boundary.ts +1 -0
- 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 +320 -37
- 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/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- 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 -264
- 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 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- 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 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- 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 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- 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 -280
- 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 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
type AnyNodeId,
|
|
4
5
|
type CameraControlEvent,
|
|
5
6
|
type CameraControlFitSceneEvent,
|
|
6
7
|
emitter,
|
|
7
8
|
sceneRegistry,
|
|
8
9
|
useScene,
|
|
9
10
|
} from '@pascal-app/core'
|
|
10
|
-
import {
|
|
11
|
+
import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
|
11
12
|
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
|
12
|
-
import { useThree } from '@react-three/fiber'
|
|
13
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
13
14
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
Box3,
|
|
17
|
+
type Camera,
|
|
18
|
+
type OrthographicCamera,
|
|
19
|
+
type PerspectiveCamera,
|
|
20
|
+
Spherical,
|
|
21
|
+
Vector3,
|
|
22
|
+
} from 'three'
|
|
15
23
|
import { EDITOR_LAYER } from '../../lib/constants'
|
|
16
24
|
import useEditor from '../../store/use-editor'
|
|
17
25
|
|
|
@@ -22,30 +30,359 @@ const tempDelta = new Vector3()
|
|
|
22
30
|
const tempPosition = new Vector3()
|
|
23
31
|
const tempSize = new Vector3()
|
|
24
32
|
const tempTarget = new Vector3()
|
|
33
|
+
const syncTarget = new Vector3()
|
|
34
|
+
const syncSpherical = new Spherical()
|
|
35
|
+
const keyboardPanSpherical = new Spherical()
|
|
25
36
|
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
|
|
26
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
|
+
}
|
|
27
342
|
|
|
28
343
|
export const CustomCameraControls = () => {
|
|
29
|
-
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
|
+
})
|
|
30
351
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
31
352
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
|
32
353
|
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
|
354
|
+
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
|
33
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
|
+
)
|
|
34
362
|
const currentLevelId = selection.levelId
|
|
35
363
|
const firstLoad = useRef(true)
|
|
364
|
+
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
|
|
365
|
+
const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
|
|
366
|
+
const lastApplied2dNavigationRevision = useRef(0)
|
|
36
367
|
const maxPolarAngle =
|
|
37
368
|
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
|
|
369
|
+
const clearPendingFloorplanNavigationPose = useCallback(() => {
|
|
370
|
+
pendingFloorplanNavigationPose.current = null
|
|
371
|
+
}, [])
|
|
38
372
|
|
|
39
373
|
const camera = useThree((state) => state.camera)
|
|
374
|
+
const gl = useThree((state) => state.gl)
|
|
40
375
|
const raycaster = useThree((state) => state.raycaster)
|
|
376
|
+
const viewportSize = useThree((state) => state.size)
|
|
41
377
|
useEffect(() => {
|
|
42
378
|
camera.layers.enable(EDITOR_LAYER)
|
|
379
|
+
camera.layers.enable(GRID_LAYER)
|
|
43
380
|
raycaster.layers.enable(EDITOR_LAYER)
|
|
44
381
|
raycaster.layers.enable(ZONE_LAYER)
|
|
45
382
|
}, [camera, raycaster])
|
|
46
383
|
|
|
47
384
|
useEffect(() => {
|
|
48
|
-
if (isPreviewMode
|
|
385
|
+
if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
|
|
49
386
|
let targetY = 0
|
|
50
387
|
if (currentLevelId) {
|
|
51
388
|
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
|
@@ -56,14 +393,22 @@ export const CustomCameraControls = () => {
|
|
|
56
393
|
if (!controls.current) return
|
|
57
394
|
if (firstLoad.current) {
|
|
58
395
|
firstLoad.current = false
|
|
396
|
+
clearPendingFloorplanNavigationPose()
|
|
59
397
|
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
|
60
398
|
}
|
|
61
399
|
controls.current.getTarget(currentTarget)
|
|
400
|
+
clearPendingFloorplanNavigationPose()
|
|
62
401
|
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
|
|
63
|
-
}, [
|
|
402
|
+
}, [
|
|
403
|
+
clearPendingFloorplanNavigationPose,
|
|
404
|
+
currentLevelId,
|
|
405
|
+
isPreviewMode,
|
|
406
|
+
isFirstPersonMode,
|
|
407
|
+
isRestoringFirstPersonPose,
|
|
408
|
+
])
|
|
64
409
|
|
|
65
410
|
useEffect(() => {
|
|
66
|
-
if (!controls.current) return
|
|
411
|
+
if (isFirstPersonMode || !controls.current) return
|
|
67
412
|
|
|
68
413
|
controls.current.maxPolarAngle = maxPolarAngle
|
|
69
414
|
controls.current.minPolarAngle = 0
|
|
@@ -71,11 +416,11 @@ export const CustomCameraControls = () => {
|
|
|
71
416
|
if (controls.current.polarAngle > maxPolarAngle) {
|
|
72
417
|
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
|
|
73
418
|
}
|
|
74
|
-
}, [maxPolarAngle])
|
|
419
|
+
}, [isFirstPersonMode, maxPolarAngle])
|
|
75
420
|
|
|
76
421
|
const focusNode = useCallback(
|
|
77
422
|
(nodeId: string) => {
|
|
78
|
-
if (isPreviewMode || !controls.current) return
|
|
423
|
+
if (isPreviewMode || isFirstPersonMode || !controls.current) return
|
|
79
424
|
|
|
80
425
|
const object3D = sceneRegistry.nodes.get(nodeId)
|
|
81
426
|
if (!object3D) return
|
|
@@ -88,6 +433,7 @@ export const CustomCameraControls = () => {
|
|
|
88
433
|
controls.current.getTarget(tempTarget)
|
|
89
434
|
tempDelta.copy(tempCenter).sub(tempTarget)
|
|
90
435
|
|
|
436
|
+
clearPendingFloorplanNavigationPose()
|
|
91
437
|
controls.current.setLookAt(
|
|
92
438
|
tempPosition.x + tempDelta.x,
|
|
93
439
|
tempPosition.y + tempDelta.y,
|
|
@@ -98,11 +444,141 @@ export const CustomCameraControls = () => {
|
|
|
98
444
|
true,
|
|
99
445
|
)
|
|
100
446
|
},
|
|
101
|
-
[isPreviewMode],
|
|
447
|
+
[clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode],
|
|
102
448
|
)
|
|
103
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
|
+
|
|
104
581
|
// Configure mouse buttons based on control mode and camera mode
|
|
105
|
-
const cameraMode = useViewer((state) => state.cameraMode)
|
|
106
582
|
const mouseButtons = useMemo(() => {
|
|
107
583
|
// Use ZOOM for orthographic camera, DOLLY for perspective camera
|
|
108
584
|
const wheelAction =
|
|
@@ -138,9 +614,15 @@ export const CustomCameraControls = () => {
|
|
|
138
614
|
const movingNode = useEditor((s) => s.movingNode)
|
|
139
615
|
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
|
140
616
|
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
|
617
|
+
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
|
141
618
|
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
|
|
142
619
|
const isInteracting = Boolean(
|
|
143
|
-
tool ||
|
|
620
|
+
tool ||
|
|
621
|
+
movingNode ||
|
|
622
|
+
movingWallEndpoint ||
|
|
623
|
+
movingFenceEndpoint ||
|
|
624
|
+
activeHandleDrag ||
|
|
625
|
+
isBoxSelectActive,
|
|
144
626
|
)
|
|
145
627
|
const touches = useMemo(() => {
|
|
146
628
|
const twoFingerAction =
|
|
@@ -162,6 +644,8 @@ export const CustomCameraControls = () => {
|
|
|
162
644
|
}, [cameraMode, isPreviewMode, isInteracting])
|
|
163
645
|
|
|
164
646
|
useEffect(() => {
|
|
647
|
+
if (isFirstPersonMode) return
|
|
648
|
+
|
|
165
649
|
const keyState = {
|
|
166
650
|
shiftRight: false,
|
|
167
651
|
shiftLeft: false,
|
|
@@ -169,6 +653,52 @@ export const CustomCameraControls = () => {
|
|
|
169
653
|
controlLeft: false,
|
|
170
654
|
space: false,
|
|
171
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
|
+
}
|
|
172
702
|
|
|
173
703
|
const updateConfig = () => {
|
|
174
704
|
if (!controls.current) return
|
|
@@ -195,9 +725,24 @@ export const CustomCameraControls = () => {
|
|
|
195
725
|
}
|
|
196
726
|
|
|
197
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
|
+
|
|
198
741
|
if (event.code === 'Space') {
|
|
742
|
+
if (isEditableKeyboardTarget(event.target)) return
|
|
743
|
+
event.preventDefault()
|
|
199
744
|
keyState.space = true
|
|
200
|
-
|
|
745
|
+
updateNavigationCursor()
|
|
201
746
|
}
|
|
202
747
|
if (event.code === 'ShiftRight') {
|
|
203
748
|
keyState.shiftRight = true
|
|
@@ -215,9 +760,22 @@ export const CustomCameraControls = () => {
|
|
|
215
760
|
}
|
|
216
761
|
|
|
217
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
|
+
|
|
218
772
|
if (event.code === 'Space') {
|
|
219
773
|
keyState.space = false
|
|
220
|
-
|
|
774
|
+
if (panPointerButton === 0) {
|
|
775
|
+
panPointerId = null
|
|
776
|
+
panPointerButton = null
|
|
777
|
+
}
|
|
778
|
+
updateNavigationCursor()
|
|
221
779
|
}
|
|
222
780
|
if (event.code === 'ShiftRight') {
|
|
223
781
|
keyState.shiftRight = false
|
|
@@ -234,15 +792,60 @@ export const CustomCameraControls = () => {
|
|
|
234
792
|
updateConfig()
|
|
235
793
|
}
|
|
236
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
|
+
|
|
237
828
|
document.addEventListener('keydown', onKeyDown)
|
|
238
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 })
|
|
239
835
|
updateConfig()
|
|
240
836
|
|
|
241
837
|
return () => {
|
|
242
838
|
document.removeEventListener('keydown', onKeyDown)
|
|
243
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()
|
|
244
847
|
}
|
|
245
|
-
}, [cameraMode, isPreviewMode])
|
|
848
|
+
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
|
|
246
849
|
|
|
247
850
|
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
|
248
851
|
const previewTargetNodeId = isPreviewMode
|
|
@@ -250,7 +853,7 @@ export const CustomCameraControls = () => {
|
|
|
250
853
|
: null
|
|
251
854
|
|
|
252
855
|
useEffect(() => {
|
|
253
|
-
if (!(isPreviewMode && controls.current)) return
|
|
856
|
+
if (!(isPreviewMode && controls.current) || isFirstPersonMode) return
|
|
254
857
|
|
|
255
858
|
const nodes = useScene.getState().nodes
|
|
256
859
|
let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
|
|
@@ -310,11 +913,122 @@ export const CustomCameraControls = () => {
|
|
|
310
913
|
tempCenter.z,
|
|
311
914
|
true,
|
|
312
915
|
)
|
|
313
|
-
}, [isPreviewMode, previewTargetNodeId])
|
|
916
|
+
}, [isPreviewMode, isFirstPersonMode, previewTargetNodeId])
|
|
314
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)
|
|
315
926
|
useEffect(() => {
|
|
316
|
-
|
|
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).
|
|
317
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
|
|
318
1032
|
|
|
319
1033
|
const position = new Vector3()
|
|
320
1034
|
const target = new Vector3()
|
|
@@ -332,12 +1046,13 @@ export const CustomCameraControls = () => {
|
|
|
332
1046
|
})
|
|
333
1047
|
}
|
|
334
1048
|
const handleNodeView = ({ nodeId }: CameraControlEvent) => {
|
|
335
|
-
if (!controls.current) return
|
|
1049
|
+
if (isFirstPersonMode || !controls.current) return
|
|
336
1050
|
|
|
337
1051
|
const node = useScene.getState().nodes[nodeId]
|
|
338
1052
|
if (!node?.camera) return
|
|
339
1053
|
const { position, target } = node.camera
|
|
340
1054
|
|
|
1055
|
+
clearPendingFloorplanNavigationPose()
|
|
341
1056
|
controls.current.setLookAt(
|
|
342
1057
|
position[0],
|
|
343
1058
|
position[1],
|
|
@@ -350,7 +1065,7 @@ export const CustomCameraControls = () => {
|
|
|
350
1065
|
}
|
|
351
1066
|
|
|
352
1067
|
const handleTopView = () => {
|
|
353
|
-
if (!controls.current) return
|
|
1068
|
+
if (isFirstPersonMode || !controls.current) return
|
|
354
1069
|
|
|
355
1070
|
const currentPolarAngle = controls.current.polarAngle
|
|
356
1071
|
|
|
@@ -358,11 +1073,12 @@ export const CustomCameraControls = () => {
|
|
|
358
1073
|
// Otherwise, go to top view (0°)
|
|
359
1074
|
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
|
|
360
1075
|
|
|
1076
|
+
clearPendingFloorplanNavigationPose()
|
|
361
1077
|
controls.current.rotatePolarTo(targetAngle, true)
|
|
362
1078
|
}
|
|
363
1079
|
|
|
364
1080
|
const handleOrbitCW = () => {
|
|
365
|
-
if (!controls.current) return
|
|
1081
|
+
if (isFirstPersonMode || !controls.current) return
|
|
366
1082
|
|
|
367
1083
|
const currentAzimuth = controls.current.azimuthAngle
|
|
368
1084
|
const currentPolar = controls.current.polarAngle
|
|
@@ -370,11 +1086,12 @@ export const CustomCameraControls = () => {
|
|
|
370
1086
|
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
|
371
1087
|
const target = rounded - Math.PI / 2
|
|
372
1088
|
|
|
1089
|
+
clearPendingFloorplanNavigationPose()
|
|
373
1090
|
controls.current.rotateTo(target, currentPolar, true)
|
|
374
1091
|
}
|
|
375
1092
|
|
|
376
1093
|
const handleOrbitCCW = () => {
|
|
377
|
-
if (!controls.current) return
|
|
1094
|
+
if (isFirstPersonMode || !controls.current) return
|
|
378
1095
|
|
|
379
1096
|
const currentAzimuth = controls.current.azimuthAngle
|
|
380
1097
|
const currentPolar = controls.current.polarAngle
|
|
@@ -382,6 +1099,7 @@ export const CustomCameraControls = () => {
|
|
|
382
1099
|
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
|
383
1100
|
const target = rounded + Math.PI / 2
|
|
384
1101
|
|
|
1102
|
+
clearPendingFloorplanNavigationPose()
|
|
385
1103
|
controls.current.rotateTo(target, currentPolar, true)
|
|
386
1104
|
}
|
|
387
1105
|
|
|
@@ -390,9 +1108,10 @@ export const CustomCameraControls = () => {
|
|
|
390
1108
|
}
|
|
391
1109
|
|
|
392
1110
|
const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
|
|
393
|
-
if (!controls.current || isPreviewMode) return
|
|
1111
|
+
if (isFirstPersonMode || !controls.current || isPreviewMode) return
|
|
394
1112
|
if (!bounds) {
|
|
395
1113
|
// Restore default framing pose when no bounds were computed.
|
|
1114
|
+
clearPendingFloorplanNavigationPose()
|
|
396
1115
|
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
|
397
1116
|
return
|
|
398
1117
|
}
|
|
@@ -403,6 +1122,7 @@ export const CustomCameraControls = () => {
|
|
|
403
1122
|
const maxExtent = Math.max(w, d)
|
|
404
1123
|
const distance = Math.max(maxExtent * 1.4, 15)
|
|
405
1124
|
const height = Math.max(maxExtent * 0.8, 10)
|
|
1125
|
+
clearPendingFloorplanNavigationPose()
|
|
406
1126
|
controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
|
|
407
1127
|
}
|
|
408
1128
|
|
|
@@ -423,7 +1143,7 @@ export const CustomCameraControls = () => {
|
|
|
423
1143
|
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
|
424
1144
|
emitter.off('camera-controls:fit-scene', handleFitScene)
|
|
425
1145
|
}
|
|
426
|
-
}, [focusNode, isPreviewMode])
|
|
1146
|
+
}, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
|
|
427
1147
|
|
|
428
1148
|
const onTransitionStart = useCallback(() => {
|
|
429
1149
|
useViewer.getState().setCameraDragging(true)
|
|
@@ -433,6 +1153,14 @@ export const CustomCameraControls = () => {
|
|
|
433
1153
|
useViewer.getState().setCameraDragging(false)
|
|
434
1154
|
}, [])
|
|
435
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
|
+
|
|
436
1164
|
if (isFirstPersonMode) {
|
|
437
1165
|
return null
|
|
438
1166
|
}
|
|
@@ -442,9 +1170,10 @@ export const CustomCameraControls = () => {
|
|
|
442
1170
|
makeDefault
|
|
443
1171
|
maxDistance={100}
|
|
444
1172
|
maxPolarAngle={maxPolarAngle}
|
|
445
|
-
minDistance={
|
|
1173
|
+
minDistance={minDistance}
|
|
446
1174
|
minPolarAngle={0}
|
|
447
1175
|
mouseButtons={mouseButtons}
|
|
1176
|
+
onUpdate={publishCurrentNavigationPose}
|
|
448
1177
|
onRest={onRest}
|
|
449
1178
|
onSleep={onRest}
|
|
450
1179
|
onTransitionStart={onTransitionStart}
|