@pascal-app/editor 1.0.0-beta.4 → 1.0.0
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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/bake-exporter.tsx +1 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +17 -5
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +182 -57
- package/src/components/editor/floating-action-menu.tsx +55 -55
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +81 -57
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/linear-resize-drag.ts +67 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +18 -1
- package/src/components/editor/index.tsx +252 -46
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +42 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +132 -47
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -232
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +494 -0
- package/src/components/tools/item/placement-strategies.ts +192 -24
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +6 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +63 -8
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +45 -8
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +527 -4
- package/src/components/tools/wall/wall-drafting.ts +55 -462
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +12 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +23 -6
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
- package/src/components/ui/sidebar/tab-bar.tsx +9 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +221 -0
- package/src/components/viewer-overlay.tsx +13 -30
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +122 -1
- package/src/hooks/use-auto-save.ts +119 -13
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +164 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +109 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +85 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +523 -77
- package/src/lib/history.ts +133 -14
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +366 -0
- package/src/lib/portable-export.ts +969 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +30 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +125 -6
- package/src/lib/selection-routing.ts +34 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/lib/usdz-export.ts +38 -0
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-session-groups.ts +103 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -21,9 +21,8 @@ import {
|
|
|
21
21
|
openElevatorDoor,
|
|
22
22
|
pointInPolygon2D,
|
|
23
23
|
requestElevatorLevel,
|
|
24
|
-
resolveElevatorBuildingLevels,
|
|
25
24
|
resolveElevatorDispatchTarget,
|
|
26
|
-
|
|
25
|
+
resolveElevatorLevels,
|
|
27
26
|
sceneRegistry,
|
|
28
27
|
useInteractive,
|
|
29
28
|
useScene,
|
|
@@ -41,6 +40,7 @@ import {
|
|
|
41
40
|
STAND_CAPSULE,
|
|
42
41
|
STAND_CLEARANCE,
|
|
43
42
|
STAND_FLOAT_HEIGHT,
|
|
43
|
+
setSurfaceRaycastLayers,
|
|
44
44
|
useViewer,
|
|
45
45
|
WALKTHROUGH_FOV,
|
|
46
46
|
} from '@pascal-app/viewer'
|
|
@@ -90,6 +90,12 @@ import {
|
|
|
90
90
|
|
|
91
91
|
const CAMERA_EYE_OFFSET = 0.45
|
|
92
92
|
const LOOK_SENSITIVITY = 0.002
|
|
93
|
+
// Drone mode: metres per second, and how hard Shift boosts it. The smoothing
|
|
94
|
+
// constant is an exponential approach rate, not a linear acceleration.
|
|
95
|
+
const DRONE_SPEED = 7
|
|
96
|
+
const DRONE_RUN_MULTIPLIER = 3
|
|
97
|
+
const DRONE_SLOW_MULTIPLIER = 0.2
|
|
98
|
+
const DRONE_SMOOTHING = 12
|
|
93
99
|
const CONTROLLER_CENTER_FROM_EYE = 0.85
|
|
94
100
|
const DOOR_INTERACTION_DISTANCE = 2.5
|
|
95
101
|
const DOOR_LEAF_INTERACTION_DEPTH = 0.08
|
|
@@ -144,10 +150,15 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
|
|
|
144
150
|
|
|
145
151
|
const cameraOffset = new Vector3()
|
|
146
152
|
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
|
153
|
+
const droneEuler = new Euler(0, 0, 0, 'YXZ')
|
|
154
|
+
const droneForward = new Vector3()
|
|
155
|
+
const droneRight = new Vector3()
|
|
156
|
+
const droneDesiredVelocity = new Vector3()
|
|
147
157
|
const standClearanceRaycaster = new Raycaster()
|
|
148
158
|
const standClearanceUp = new Vector3(0, 1, 0)
|
|
149
159
|
const centerScreenPoint = new Vector2(0, 0)
|
|
150
160
|
const doorInteractionRaycaster = new Raycaster()
|
|
161
|
+
setSurfaceRaycastLayers(doorInteractionRaycaster.layers)
|
|
151
162
|
const doorLeafBox = new Box3()
|
|
152
163
|
const doorLeafInverseMatrix = new Matrix4()
|
|
153
164
|
const doorLeafLocalHit = new Vector3()
|
|
@@ -170,6 +181,7 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
|
|
|
170
181
|
const spawnWorldPosition = new Vector3()
|
|
171
182
|
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
|
172
183
|
const windowInteractionRaycaster = new Raycaster()
|
|
184
|
+
setSurfaceRaycastLayers(windowInteractionRaycaster.layers)
|
|
173
185
|
const hudBuildingLocalEyePosition = new Vector3()
|
|
174
186
|
const hudWorldEyePosition = new Vector3()
|
|
175
187
|
const hudLevelBounds = new Box3()
|
|
@@ -430,45 +442,6 @@ function isInsideElevatorCab(
|
|
|
430
442
|
)
|
|
431
443
|
}
|
|
432
444
|
|
|
433
|
-
function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
|
|
434
|
-
const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
|
|
435
|
-
const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
|
|
436
|
-
|
|
437
|
-
const baseYByLevelId = new Map<string, number>()
|
|
438
|
-
let cumulativeY = 0
|
|
439
|
-
for (const level of allLevels) {
|
|
440
|
-
const elevation = levelElevations.get(level.id)
|
|
441
|
-
const baseY = elevation?.baseY ?? 0
|
|
442
|
-
baseYByLevelId.set(level.id, baseY)
|
|
443
|
-
cumulativeY = Math.max(cumulativeY, baseY + (elevation?.height ?? 0))
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
|
|
447
|
-
const entries = serviceLevels.map((level) => ({
|
|
448
|
-
baseY: baseYByLevelId.get(level.id) ?? 0,
|
|
449
|
-
id: level.id as AnyNodeId,
|
|
450
|
-
}))
|
|
451
|
-
const firstServedLevel = serviceLevels[0] ?? null
|
|
452
|
-
const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
|
|
453
|
-
const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
|
|
454
|
-
const lastServedIndex = lastServedLevel
|
|
455
|
-
? allLevels.findIndex((level) => level.id === lastServedLevel.id)
|
|
456
|
-
: -1
|
|
457
|
-
const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
|
|
458
|
-
const shaftTopY = nextLevel
|
|
459
|
-
? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
|
|
460
|
-
: lastServedLevel
|
|
461
|
-
? cumulativeY
|
|
462
|
-
: elevator.cabHeight + 0.3
|
|
463
|
-
|
|
464
|
-
return {
|
|
465
|
-
entries,
|
|
466
|
-
shaftBaseY,
|
|
467
|
-
shaftTopY,
|
|
468
|
-
totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
445
|
function createElevatorColliderMesh(
|
|
473
446
|
elevatorId: AnyNodeId,
|
|
474
447
|
kind: ElevatorColliderKind,
|
|
@@ -519,10 +492,7 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
|
|
|
519
492
|
const node = nodes[typedElevatorId]
|
|
520
493
|
if (node?.type !== 'elevator' || node.visible === false) continue
|
|
521
494
|
|
|
522
|
-
const { entries, shaftBaseY, shaftTopY, totalHeight } =
|
|
523
|
-
node,
|
|
524
|
-
nodes,
|
|
525
|
-
)
|
|
495
|
+
const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorLevels(node, nodes)
|
|
526
496
|
const cabWidth = getElevatorCabWidth(node)
|
|
527
497
|
const cabDepth = getElevatorCabDepth(node)
|
|
528
498
|
const shaftWidth = getElevatorShaftWidth(node, cabWidth)
|
|
@@ -696,6 +666,7 @@ export const FirstPersonControls = () => {
|
|
|
696
666
|
const { camera, gl } = useThree()
|
|
697
667
|
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
|
698
668
|
const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
|
|
669
|
+
const isDroneMode = useEditor((state) => state.firstPersonMovementMode === 'drone')
|
|
699
670
|
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
|
700
671
|
const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
|
|
701
672
|
const hadPointerLockRef = useRef(false)
|
|
@@ -704,9 +675,14 @@ export const FirstPersonControls = () => {
|
|
|
704
675
|
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
|
705
676
|
const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1)
|
|
706
677
|
const crouchKeyRef = useRef(false)
|
|
678
|
+
const droneAscendKeyRef = useRef(false)
|
|
679
|
+
const droneSlowKeyRef = useRef(false)
|
|
680
|
+
const droneDescendKeyRef = useRef(false)
|
|
681
|
+
const droneVelocityRef = useRef(new Vector3())
|
|
707
682
|
const suspendRef = useRef(false)
|
|
708
683
|
const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
|
|
709
684
|
const [crouched, setCrouched] = useState(false)
|
|
685
|
+
const captureShutterHold = useEditor((state) => state.captureShutterHold)
|
|
710
686
|
const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
|
|
711
687
|
const ridingElevatorRef = useRef<{
|
|
712
688
|
elevatorId: AnyNodeId
|
|
@@ -735,13 +711,19 @@ export const FirstPersonControls = () => {
|
|
|
735
711
|
}
|
|
736
712
|
}, [])
|
|
737
713
|
|
|
714
|
+
// While a snapshot is being framed the capture rig owns the fov (the user is
|
|
715
|
+
// driving it from the overlay's slider), so walkthrough neither applies its
|
|
716
|
+
// own nor restores one underneath it. Read imperatively: this must not re-run
|
|
717
|
+
// — and therefore restore — when capture mode toggles mid-walkthrough.
|
|
738
718
|
useEffect(() => {
|
|
739
719
|
const perspectiveCamera = camera as PerspectiveCamera
|
|
740
720
|
if (!perspectiveCamera.isPerspectiveCamera) return
|
|
721
|
+
if (useEditor.getState().isCaptureMode) return
|
|
741
722
|
const previousFov = perspectiveCamera.fov
|
|
742
723
|
perspectiveCamera.fov = WALKTHROUGH_FOV
|
|
743
724
|
perspectiveCamera.updateProjectionMatrix()
|
|
744
725
|
return () => {
|
|
726
|
+
if (useEditor.getState().isCaptureMode) return
|
|
745
727
|
perspectiveCamera.fov = previousFov
|
|
746
728
|
perspectiveCamera.updateProjectionMatrix()
|
|
747
729
|
}
|
|
@@ -974,6 +956,13 @@ export const FirstPersonControls = () => {
|
|
|
974
956
|
}, [resolveInteractableDoorId, resolveInteractableElevatorTarget, resolveInteractableWindowId])
|
|
975
957
|
|
|
976
958
|
const toggleInteractableTarget = useCallback(() => {
|
|
959
|
+
// Drone is a camera, not an avatar: the click that re-acquires pointer lock
|
|
960
|
+
// must not swing a door open under the shot being framed. (In capture
|
|
961
|
+
// mode's walk camera the CLICK path is gated at handleMouseDown — there a
|
|
962
|
+
// locked-pointer click is the shutter — but E/R still open doors, so the
|
|
963
|
+
// photographer can stage the shot.)
|
|
964
|
+
if (isDroneMode) return
|
|
965
|
+
|
|
977
966
|
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
|
978
967
|
if (!target) return
|
|
979
968
|
|
|
@@ -1027,9 +1016,11 @@ export const FirstPersonControls = () => {
|
|
|
1027
1016
|
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
|
1028
1017
|
|
|
1029
1018
|
toggleDoorOpenState(doorId, { persist: false })
|
|
1030
|
-
}, [resolveInteractableTarget])
|
|
1019
|
+
}, [isDroneMode, resolveInteractableTarget])
|
|
1031
1020
|
|
|
1032
1021
|
const closeInteractableTarget = useCallback(() => {
|
|
1022
|
+
if (isDroneMode) return
|
|
1023
|
+
|
|
1033
1024
|
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
|
1034
1025
|
if (!target) return
|
|
1035
1026
|
|
|
@@ -1053,7 +1044,7 @@ export const FirstPersonControls = () => {
|
|
|
1053
1044
|
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
|
1054
1045
|
|
|
1055
1046
|
closeDoorOpenState(target.id, { persist: false })
|
|
1056
|
-
}, [resolveInteractableTarget])
|
|
1047
|
+
}, [isDroneMode, resolveInteractableTarget])
|
|
1057
1048
|
|
|
1058
1049
|
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
|
|
1059
1050
|
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
|
|
@@ -1085,7 +1076,10 @@ export const FirstPersonControls = () => {
|
|
|
1085
1076
|
}, [placedSpawnNode])
|
|
1086
1077
|
|
|
1087
1078
|
useEffect(() => {
|
|
1088
|
-
|
|
1079
|
+
// Drone has no gravity, no floor and no collision, so the BVH collider world
|
|
1080
|
+
// (a full-scene traversal, rebuilt on every door/window animation) is pure
|
|
1081
|
+
// cost there. Switching modes disposes it and rebuilds on the way back.
|
|
1082
|
+
if (!isDroneMode) rebuildColliderWorld()
|
|
1089
1083
|
|
|
1090
1084
|
return () => {
|
|
1091
1085
|
worldRef.current?.dispose()
|
|
@@ -1095,16 +1089,39 @@ export const FirstPersonControls = () => {
|
|
|
1095
1089
|
setElevatorColliderMeshes([])
|
|
1096
1090
|
setWorld(null)
|
|
1097
1091
|
}
|
|
1098
|
-
}, [rebuildColliderWorld])
|
|
1092
|
+
}, [isDroneMode, rebuildColliderWorld])
|
|
1099
1093
|
|
|
1100
1094
|
useEffect(() => {
|
|
1095
|
+
if (isDroneMode) return
|
|
1101
1096
|
emitter.on('door:animation-completed', rebuildColliderWorld)
|
|
1102
1097
|
emitter.on('window:animation-completed', rebuildColliderWorld)
|
|
1103
1098
|
return () => {
|
|
1104
1099
|
emitter.off('door:animation-completed', rebuildColliderWorld)
|
|
1105
1100
|
emitter.off('window:animation-completed', rebuildColliderWorld)
|
|
1106
1101
|
}
|
|
1107
|
-
}, [rebuildColliderWorld])
|
|
1102
|
+
}, [isDroneMode, rebuildColliderWorld])
|
|
1103
|
+
|
|
1104
|
+
// A walk session started before a drone detour would otherwise resume at its
|
|
1105
|
+
// original spawn; drop it so the next walk re-derives one.
|
|
1106
|
+
useEffect(() => {
|
|
1107
|
+
if (isDroneMode) setControllerStart(null)
|
|
1108
|
+
}, [isDroneMode])
|
|
1109
|
+
|
|
1110
|
+
// Drone picks up wherever the camera currently is — orbit pose or walk eye.
|
|
1111
|
+
useEffect(() => {
|
|
1112
|
+
if (!isDroneMode) return
|
|
1113
|
+
droneEuler.setFromQuaternion(camera.quaternion)
|
|
1114
|
+
yawRef.current = droneEuler.y
|
|
1115
|
+
pitchRef.current = droneEuler.x
|
|
1116
|
+
droneVelocityRef.current.set(0, 0, 0)
|
|
1117
|
+
|
|
1118
|
+
// Interaction prompts belong to walk; drop whatever its frame left behind.
|
|
1119
|
+
if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
|
|
1120
|
+
useViewer.getState().setHoveredId(null)
|
|
1121
|
+
}
|
|
1122
|
+
interactableTargetRef.current = null
|
|
1123
|
+
useFirstPersonHud.getState().reset()
|
|
1124
|
+
}, [camera, isDroneMode])
|
|
1108
1125
|
|
|
1109
1126
|
useEffect(() => {
|
|
1110
1127
|
if (!world) return
|
|
@@ -1146,11 +1163,18 @@ export const FirstPersonControls = () => {
|
|
|
1146
1163
|
const canvas = gl.domElement
|
|
1147
1164
|
const handleMouseMove = (e: MouseEvent) => {
|
|
1148
1165
|
if (document.pointerLockElement !== canvas) return
|
|
1149
|
-
|
|
1150
|
-
|
|
1166
|
+
// Shutter hold: the shot is rendering — a mouse twitch must not pan it.
|
|
1167
|
+
if (useEditor.getState().captureShutterHold) return
|
|
1168
|
+
|
|
1169
|
+
const lookSensitivity =
|
|
1170
|
+
LOOK_SENSITIVITY *
|
|
1171
|
+
(useEditor.getState().firstPersonMovementMode === 'drone' && droneSlowKeyRef.current
|
|
1172
|
+
? DRONE_SLOW_MULTIPLIER
|
|
1173
|
+
: 1)
|
|
1174
|
+
yawRef.current -= e.movementX * lookSensitivity
|
|
1151
1175
|
pitchRef.current = Math.max(
|
|
1152
1176
|
-(Math.PI / 2 - 0.05),
|
|
1153
|
-
Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY *
|
|
1177
|
+
Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * lookSensitivity),
|
|
1154
1178
|
)
|
|
1155
1179
|
}
|
|
1156
1180
|
|
|
@@ -1167,6 +1191,10 @@ export const FirstPersonControls = () => {
|
|
|
1167
1191
|
if (document.pointerLockElement !== canvas) return
|
|
1168
1192
|
if (event.button !== 0) return
|
|
1169
1193
|
|
|
1194
|
+
// Capture mode: the locked-pointer click is the SHUTTER (the snapshot
|
|
1195
|
+
// overlay's window-capture listener already fired); doors stay on E/R.
|
|
1196
|
+
if (useEditor.getState().isCaptureMode) return
|
|
1197
|
+
|
|
1170
1198
|
event.preventDefault()
|
|
1171
1199
|
event.stopPropagation()
|
|
1172
1200
|
toggleInteractableTargetRef.current()
|
|
@@ -1185,6 +1213,19 @@ export const FirstPersonControls = () => {
|
|
|
1185
1213
|
// clicking the canvas re-locks.
|
|
1186
1214
|
if (suspendRef.current) return
|
|
1187
1215
|
|
|
1216
|
+
// Capture mode: Esc (the browser's own unlock — no keydown reaches us)
|
|
1217
|
+
// acts like P. Dropping back to orbit would throw away the framed pose,
|
|
1218
|
+
// which reads as a crash to anyone who never noticed P.
|
|
1219
|
+
if (
|
|
1220
|
+
hadPointerLockRef.current &&
|
|
1221
|
+
useEditor.getState().isCaptureMode &&
|
|
1222
|
+
useEditor.getState().isFirstPersonMode
|
|
1223
|
+
) {
|
|
1224
|
+
suspendRef.current = true
|
|
1225
|
+
useViewer.getState().setWalkthroughSuspended(true)
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1188
1229
|
if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
|
|
1189
1230
|
useEditor.getState().setFirstPersonMode(false)
|
|
1190
1231
|
}
|
|
@@ -1237,9 +1278,36 @@ export const FirstPersonControls = () => {
|
|
|
1237
1278
|
// While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
|
|
1238
1279
|
// screenshot) must not toggle it under the user.
|
|
1239
1280
|
if (!suspendRef.current) crouchKeyRef.current = true
|
|
1281
|
+
} else if (event.code === 'KeyQ') {
|
|
1282
|
+
// Drone descend. Space (already bound to jump) and E are the matching ascend.
|
|
1283
|
+
event.preventDefault()
|
|
1284
|
+
event.stopPropagation()
|
|
1285
|
+
if (!suspendRef.current) droneDescendKeyRef.current = true
|
|
1286
|
+
} else if (event.code === 'KeyE' && isDroneMode) {
|
|
1287
|
+
event.preventDefault()
|
|
1288
|
+
event.stopPropagation()
|
|
1289
|
+
if (!suspendRef.current) droneAscendKeyRef.current = true
|
|
1290
|
+
} else if ((event.code === 'AltLeft' || event.code === 'AltRight') && isDroneMode) {
|
|
1291
|
+
event.preventDefault()
|
|
1292
|
+
event.stopPropagation()
|
|
1293
|
+
if (!suspendRef.current) droneSlowKeyRef.current = true
|
|
1240
1294
|
} else if (event.code === 'Escape') {
|
|
1241
1295
|
event.preventDefault()
|
|
1242
1296
|
event.stopPropagation()
|
|
1297
|
+
// Capture mode, first Esc frees the cursor (see handlePointerLockChange
|
|
1298
|
+
// — while locked the browser usually unlocks without delivering the
|
|
1299
|
+
// keydown); with the cursor already free, Esc cancels the snapshot
|
|
1300
|
+
// (setCaptureMode(false) also lands the camera back on orbit).
|
|
1301
|
+
if (useEditor.getState().isCaptureMode) {
|
|
1302
|
+
if (document.pointerLockElement === canvas) {
|
|
1303
|
+
suspendRef.current = true
|
|
1304
|
+
useViewer.getState().setWalkthroughSuspended(true)
|
|
1305
|
+
document.exitPointerLock()
|
|
1306
|
+
} else {
|
|
1307
|
+
useEditor.getState().setCaptureMode(false)
|
|
1308
|
+
}
|
|
1309
|
+
return
|
|
1310
|
+
}
|
|
1243
1311
|
if (document.pointerLockElement === canvas) {
|
|
1244
1312
|
document.exitPointerLock()
|
|
1245
1313
|
}
|
|
@@ -1273,11 +1341,25 @@ export const FirstPersonControls = () => {
|
|
|
1273
1341
|
if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
|
|
1274
1342
|
crouchKeyRef.current = false
|
|
1275
1343
|
}
|
|
1344
|
+
if (event.code === 'KeyQ' && !suspendRef.current) {
|
|
1345
|
+
droneDescendKeyRef.current = false
|
|
1346
|
+
}
|
|
1347
|
+
if (event.code === 'KeyE' && !suspendRef.current) {
|
|
1348
|
+
droneAscendKeyRef.current = false
|
|
1349
|
+
}
|
|
1350
|
+
if ((event.code === 'AltLeft' || event.code === 'AltRight') && !suspendRef.current) {
|
|
1351
|
+
droneSlowKeyRef.current = false
|
|
1352
|
+
}
|
|
1276
1353
|
applyMovementKey(event, false)
|
|
1277
1354
|
}
|
|
1278
1355
|
|
|
1279
1356
|
const handleBlur = () => {
|
|
1280
|
-
if (!suspendRef.current)
|
|
1357
|
+
if (!suspendRef.current) {
|
|
1358
|
+
crouchKeyRef.current = false
|
|
1359
|
+
droneAscendKeyRef.current = false
|
|
1360
|
+
droneDescendKeyRef.current = false
|
|
1361
|
+
droneSlowKeyRef.current = false
|
|
1362
|
+
}
|
|
1281
1363
|
}
|
|
1282
1364
|
|
|
1283
1365
|
document.addEventListener('keydown', handleKeyDown, true)
|
|
@@ -1288,7 +1370,7 @@ export const FirstPersonControls = () => {
|
|
|
1288
1370
|
document.removeEventListener('keyup', handleKeyUp, true)
|
|
1289
1371
|
window.removeEventListener('blur', handleBlur)
|
|
1290
1372
|
}
|
|
1291
|
-
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
|
1373
|
+
}, [closeInteractableTarget, gl, isDroneMode, toggleInteractableTarget])
|
|
1292
1374
|
|
|
1293
1375
|
const syncElevatorColliderMeshes = useCallback(() => {
|
|
1294
1376
|
const nodes = useScene.getState().nodes
|
|
@@ -1364,6 +1446,7 @@ export const FirstPersonControls = () => {
|
|
|
1364
1446
|
}, [])
|
|
1365
1447
|
|
|
1366
1448
|
useFrame(() => {
|
|
1449
|
+
if (isDroneMode) return
|
|
1367
1450
|
syncElevatorColliderMeshes()
|
|
1368
1451
|
}, -1)
|
|
1369
1452
|
|
|
@@ -1523,7 +1606,49 @@ export const FirstPersonControls = () => {
|
|
|
1523
1606
|
return standClearanceRaycaster.intersectObjects(meshes, false).length === 0
|
|
1524
1607
|
}, [])
|
|
1525
1608
|
|
|
1609
|
+
// Drone: a free camera driven straight from the look angles — no controller,
|
|
1610
|
+
// no gravity or collision clamping. WASD move along the view axes, Space or E
|
|
1611
|
+
// rises, Q (or Ctrl) sinks, and Shift boosts.
|
|
1612
|
+
useFrame((_, delta) => {
|
|
1613
|
+
if (!isDroneMode) return
|
|
1614
|
+
// Shutter hold: freeze the drone mid-air while the shot renders.
|
|
1615
|
+
if (useEditor.getState().captureShutterHold) return
|
|
1616
|
+
|
|
1617
|
+
const step = Math.min(delta, 0.1)
|
|
1618
|
+
const movement = movementInputRef.current
|
|
1619
|
+
|
|
1620
|
+
droneEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
|
1621
|
+
camera.quaternion.setFromEuler(droneEuler)
|
|
1622
|
+
droneForward.set(0, 0, -1).applyEuler(droneEuler)
|
|
1623
|
+
droneRight.set(1, 0, 0).applyEuler(droneEuler)
|
|
1624
|
+
|
|
1625
|
+
droneDesiredVelocity.set(0, 0, 0)
|
|
1626
|
+
if (movement.forward) droneDesiredVelocity.add(droneForward)
|
|
1627
|
+
if (movement.backward) droneDesiredVelocity.sub(droneForward)
|
|
1628
|
+
if (movement.rightward) droneDesiredVelocity.add(droneRight)
|
|
1629
|
+
if (movement.leftward) droneDesiredVelocity.sub(droneRight)
|
|
1630
|
+
if (movement.jump || droneAscendKeyRef.current) droneDesiredVelocity.y += 1
|
|
1631
|
+
if (droneDescendKeyRef.current || crouchKeyRef.current) droneDesiredVelocity.y -= 1
|
|
1632
|
+
if (droneDesiredVelocity.lengthSq() > 0) {
|
|
1633
|
+
droneDesiredVelocity
|
|
1634
|
+
.normalize()
|
|
1635
|
+
.multiplyScalar(
|
|
1636
|
+
DRONE_SPEED *
|
|
1637
|
+
(droneSlowKeyRef.current
|
|
1638
|
+
? DRONE_SLOW_MULTIPLIER
|
|
1639
|
+
: movement.run
|
|
1640
|
+
? DRONE_RUN_MULTIPLIER
|
|
1641
|
+
: 1),
|
|
1642
|
+
)
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
droneVelocityRef.current.lerp(droneDesiredVelocity, 1 - Math.exp(-step * DRONE_SMOOTHING))
|
|
1646
|
+
camera.position.addScaledVector(droneVelocityRef.current, step)
|
|
1647
|
+
camera.updateMatrixWorld(true)
|
|
1648
|
+
}, 2.5)
|
|
1649
|
+
|
|
1526
1650
|
useFrame((_, delta) => {
|
|
1651
|
+
if (isDroneMode) return
|
|
1527
1652
|
if (!controllerRef.current?.group) return
|
|
1528
1653
|
|
|
1529
1654
|
const group = controllerRef.current.group
|
|
@@ -1605,7 +1730,7 @@ export const FirstPersonControls = () => {
|
|
|
1605
1730
|
[world, elevatorColliderMeshes],
|
|
1606
1731
|
)
|
|
1607
1732
|
|
|
1608
|
-
if (!world) {
|
|
1733
|
+
if (isDroneMode || !world) {
|
|
1609
1734
|
return null
|
|
1610
1735
|
}
|
|
1611
1736
|
|
|
@@ -1637,7 +1762,7 @@ export const FirstPersonControls = () => {
|
|
|
1637
1762
|
maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
|
|
1638
1763
|
maxSlope={1.2}
|
|
1639
1764
|
maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
|
|
1640
|
-
paused={isElevatorRideLocked}
|
|
1765
|
+
paused={isElevatorRideLocked || captureShutterHold}
|
|
1641
1766
|
position={controllerStart.position}
|
|
1642
1767
|
ref={setControllerApi}
|
|
1643
1768
|
/>
|
|
@@ -43,12 +43,13 @@ import * as THREE from 'three'
|
|
|
43
43
|
import { useShallow } from 'zustand/react/shallow'
|
|
44
44
|
import { useReducedMotion } from '../../hooks/use-reduced-motion'
|
|
45
45
|
import { resolveMoveActionNode } from '../../lib/direct-manipulation'
|
|
46
|
+
import { getFloatingMenuScale } from '../../lib/floating-menu-scale'
|
|
46
47
|
import {
|
|
47
48
|
createFreshPlacementSubtree,
|
|
48
49
|
duplicatesAsFreshSubtree,
|
|
49
50
|
prepareFreshPlacementRootDuplicate,
|
|
50
51
|
} from '../../lib/fresh-planar-placement'
|
|
51
|
-
import {
|
|
52
|
+
import { resolveFloatingActionMenuVisibility } from '../../lib/interaction/overlay-policy'
|
|
52
53
|
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
|
|
53
54
|
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
|
54
55
|
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
|
|
@@ -99,17 +100,6 @@ const ALLOWED_TYPES = [
|
|
|
99
100
|
const DELETE_ONLY_TYPES: string[] = []
|
|
100
101
|
const HOLE_TYPES = ['slab', 'ceiling']
|
|
101
102
|
|
|
102
|
-
// Menu scales with camera zoom so it feels anchored to the object, but is
|
|
103
|
-
// clamped on both ends so it stays readable when zoomed way out and doesn't
|
|
104
|
-
// dominate the screen when zoomed in close. Reference values are picked so
|
|
105
|
-
// scale = 1 lands near the editor's default framing.
|
|
106
|
-
const MIN_MENU_SCALE = 0.5
|
|
107
|
-
// Cap at 1 so zooming in doesn't grow the menu past its default pixel size —
|
|
108
|
-
// only zoom-out shrinks it (down to MIN_MENU_SCALE).
|
|
109
|
-
const MAX_MENU_SCALE = 1
|
|
110
|
-
const REF_ORTHO_ZOOM = 20
|
|
111
|
-
const REF_CAMERA_DISTANCE = 12
|
|
112
|
-
|
|
113
103
|
// World-space Y distance from a node's bbox top to the floating menu anchor.
|
|
114
104
|
// Per-type because in-world chrome above the node (height-resize arrows,
|
|
115
105
|
// measurement labels) varies in vertical reach.
|
|
@@ -294,16 +284,14 @@ export function FloatingActionMenu() {
|
|
|
294
284
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
295
285
|
const setSelection = useViewer((s) => s.setSelection)
|
|
296
286
|
const unit = useViewer((s) => s.unit)
|
|
287
|
+
const metricNotation = useViewer((s) => s.metricNotation)
|
|
297
288
|
// Drives the height-drag dimension pill below the menu. `activeHandleDrag`
|
|
298
289
|
// flips only at drag start / end, so subscribing here is cheap — the live
|
|
299
290
|
// height value is written imperatively in the useFrame below.
|
|
300
291
|
const activeHandleDrag = useActiveHandleDrag()
|
|
301
292
|
// R/T rotation axis for kinds with full 3D orientation (duct fittings).
|
|
302
293
|
const rotationAxis = useEditor((s) => s.rotationAxis)
|
|
303
|
-
// The floating action menu is an action-conflicting control: hard-hidden
|
|
304
|
-
// during any active interaction so it never competes with the live action.
|
|
305
294
|
const scope = useInteractionScope((s) => s.scope)
|
|
306
|
-
const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
|
|
307
295
|
|
|
308
296
|
const groupRef = useRef<THREE.Group>(null)
|
|
309
297
|
const menuScaleRef = useRef<HTMLDivElement>(null)
|
|
@@ -365,6 +353,7 @@ export function FloatingActionMenu() {
|
|
|
365
353
|
activeHandleDrag?.nodeId === selectedId &&
|
|
366
354
|
activeHandleDrag?.label === 'height'
|
|
367
355
|
const pillDims = pillNode ? getHeightPillDimensions(pillNode) : null
|
|
356
|
+
const menuVisibility = resolveFloatingActionMenuVisibility(scope, isHeightDragPill)
|
|
368
357
|
|
|
369
358
|
// Boolean selector, only re-renders when curving availability actually flips.
|
|
370
359
|
const canCurveSelectedWall = useScene((s) => {
|
|
@@ -391,12 +380,7 @@ export function FloatingActionMenu() {
|
|
|
391
380
|
// so it stays readable at extreme zoom-out and doesn't fill the screen
|
|
392
381
|
// when zoomed in close.
|
|
393
382
|
if (menuScaleRef.current) {
|
|
394
|
-
const
|
|
395
|
-
state.camera instanceof THREE.OrthographicCamera
|
|
396
|
-
? state.camera.zoom / REF_ORTHO_ZOOM
|
|
397
|
-
: REF_CAMERA_DISTANCE /
|
|
398
|
-
Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001)
|
|
399
|
-
const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
|
|
383
|
+
const scale = getFloatingMenuScale(state.camera, groupRef.current.position)
|
|
400
384
|
menuScaleRef.current.style.transform = `scale(${scale})`
|
|
401
385
|
}
|
|
402
386
|
|
|
@@ -417,7 +401,7 @@ export function FloatingActionMenu() {
|
|
|
417
401
|
? getWallEffectiveHeightForNodes(node, useScene.getState().nodes)
|
|
418
402
|
: FENCE_DEFAULT_HEIGHT
|
|
419
403
|
const liveHeight = override?.height ?? node.height ?? fallbackHeight
|
|
420
|
-
pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}`
|
|
404
|
+
pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit, metricNotation)}`
|
|
421
405
|
}
|
|
422
406
|
|
|
423
407
|
const obj = sceneRegistry.nodes.get(selectedId)
|
|
@@ -780,7 +764,7 @@ export function FloatingActionMenu() {
|
|
|
780
764
|
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
|
781
765
|
endpointReshape ||
|
|
782
766
|
isCurveReshape ||
|
|
783
|
-
|
|
767
|
+
!menuVisibility.root
|
|
784
768
|
)
|
|
785
769
|
return null
|
|
786
770
|
|
|
@@ -800,35 +784,43 @@ export function FloatingActionMenu() {
|
|
|
800
784
|
ref={menuScaleRef}
|
|
801
785
|
style={{ transformOrigin: 'center center' }}
|
|
802
786
|
>
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
787
|
+
{menuVisibility.actions ? (
|
|
788
|
+
<NodeActionMenu
|
|
789
|
+
onFind={
|
|
790
|
+
node &&
|
|
791
|
+
canFindNode &&
|
|
792
|
+
nodeRegistry.get(node.type)?.presentation?.findInCatalog !== false
|
|
793
|
+
? handleFind
|
|
794
|
+
: undefined
|
|
795
|
+
}
|
|
796
|
+
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
|
797
|
+
onCurve={
|
|
798
|
+
(node?.type === 'fence' && !isSplineFence(node) && !isCurvedWall(node)) ||
|
|
799
|
+
(node?.type === 'wall' && canCurveSelectedWall)
|
|
800
|
+
? handleCurve
|
|
801
|
+
: undefined
|
|
802
|
+
}
|
|
803
|
+
onMove={
|
|
804
|
+
// Fully registry-driven: any kind that declares
|
|
805
|
+
// `capabilities.movable`, a `floorplanMoveTarget`, or a
|
|
806
|
+
// 3D `affordanceTools.move` mover gets the Move button.
|
|
807
|
+
// Adding a new movable kind never touches this file.
|
|
808
|
+
node && isRegistryMovable(node.type) ? handleMove : undefined
|
|
809
|
+
}
|
|
810
|
+
onDelete={handleDelete}
|
|
811
|
+
onDuplicate={
|
|
812
|
+
node &&
|
|
813
|
+
node.type !== 'spawn' &&
|
|
814
|
+
!DELETE_ONLY_TYPES.includes(node.type) &&
|
|
815
|
+
!HOLE_TYPES.includes(node.type)
|
|
816
|
+
? handleDuplicate
|
|
817
|
+
: undefined
|
|
818
|
+
}
|
|
819
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
820
|
+
onPointerUp={(e) => e.stopPropagation()}
|
|
821
|
+
/>
|
|
822
|
+
) : null}
|
|
823
|
+
{menuVisibility.actions && quickActions.length > 0 ? (
|
|
832
824
|
<div
|
|
833
825
|
className="pointer-events-auto mt-1 inline-flex w-max items-center justify-center gap-0.5 rounded-lg border border-border/50 bg-background/90 px-1.5 py-1 shadow-md backdrop-blur-md"
|
|
834
826
|
onPointerDown={(e) => e.stopPropagation()}
|
|
@@ -884,7 +876,7 @@ export function FloatingActionMenu() {
|
|
|
884
876
|
under it for duct fittings. */}
|
|
885
877
|
{node && hasPorts(node.type) ? (
|
|
886
878
|
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 mb-2 flex flex-col items-center gap-1">
|
|
887
|
-
<SystemSummaryPill nodeId={node.id} unit={unit} />
|
|
879
|
+
<SystemSummaryPill metricNotation={metricNotation} nodeId={node.id} unit={unit} />
|
|
888
880
|
{hasAxisCycling(node.type) ? (
|
|
889
881
|
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
|
890
882
|
<span className="font-medium text-foreground">
|
|
@@ -918,7 +910,15 @@ export function FloatingActionMenu() {
|
|
|
918
910
|
* subscription it needs (connectivity changes when ANY joint moves) doesn't
|
|
919
911
|
* re-render the always-mounted parent menu on every unrelated scene tick.
|
|
920
912
|
*/
|
|
921
|
-
function SystemSummaryPill({
|
|
913
|
+
function SystemSummaryPill({
|
|
914
|
+
metricNotation,
|
|
915
|
+
nodeId,
|
|
916
|
+
unit,
|
|
917
|
+
}: {
|
|
918
|
+
metricNotation: 'meters' | 'millimeters'
|
|
919
|
+
nodeId: AnyNodeId
|
|
920
|
+
unit: 'metric' | 'imperial'
|
|
921
|
+
}) {
|
|
922
922
|
const allNodes = useScene((s) => s.nodes)
|
|
923
923
|
const summary = useMemo(() => summarizeSystemFor(nodeId, allNodes), [nodeId, allNodes])
|
|
924
924
|
if (!summary) return null
|
|
@@ -935,7 +935,7 @@ function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric'
|
|
|
935
935
|
·
|
|
936
936
|
</span>
|
|
937
937
|
<span className="text-muted-foreground">
|
|
938
|
-
{formatMeasurement(summary.runLengthM, unit)} · {summary.runCount}{' '}
|
|
938
|
+
{formatMeasurement(summary.runLengthM, unit, metricNotation)} · {summary.runCount}{' '}
|
|
939
939
|
{summary.runCount === 1 ? 'run' : 'runs'}
|
|
940
940
|
</span>
|
|
941
941
|
</>
|