@pascal-app/editor 0.7.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5837 -12006
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +234 -190
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +435 -158
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +2 -2
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -543
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +57 -1
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +24 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +342 -91
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/tsconfig.json +2 -1
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -230
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import '../../../three-types'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type AnyNode,
|
|
7
|
+
type AnyNodeId,
|
|
8
|
+
collectAlignmentAnchors,
|
|
9
|
+
type EventSuffix,
|
|
10
|
+
emitter,
|
|
11
|
+
type GridEvent,
|
|
12
|
+
movingFootprintAnchors,
|
|
13
|
+
type NodeEvent,
|
|
14
|
+
nodeRegistry,
|
|
15
|
+
resolveAlignment,
|
|
16
|
+
sceneRegistry,
|
|
17
|
+
spatialGridManager,
|
|
18
|
+
useLiveTransforms,
|
|
19
|
+
useScene,
|
|
20
|
+
} from '@pascal-app/core'
|
|
21
|
+
import { useAlignmentGuides } from '@pascal-app/editor'
|
|
22
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
23
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
24
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
25
|
+
import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
|
|
26
|
+
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
|
|
27
|
+
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
|
|
28
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
29
|
+
import useEditor from '../../../store/use-editor'
|
|
30
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
31
|
+
import { DragBoundingBox } from '../shared/drag-bounding-box'
|
|
32
|
+
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
|
33
|
+
import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
|
|
34
|
+
import { PlacementBox } from '../shared/placement-box'
|
|
35
|
+
|
|
36
|
+
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
|
|
37
|
+
* / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
|
|
38
|
+
const snapToGridStep = (value: number) => {
|
|
39
|
+
const step = useEditor.getState().gridSnapStep
|
|
40
|
+
return Math.round(value / step) * step
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 90° steps, matching the GLB item placement rotation. */
|
|
44
|
+
const ROTATION_STEP = Math.PI / 2
|
|
45
|
+
|
|
46
|
+
/** Figma-style alignment-snap threshold (meters), matching the 2D
|
|
47
|
+
* floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull
|
|
48
|
+
* without fighting grid snap. Fixed for v1 — no zoom-scaling in 3D. */
|
|
49
|
+
const ALIGNMENT_THRESHOLD_M = 0.08
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generic move tool for any registry-backed kind.
|
|
53
|
+
*
|
|
54
|
+
* Imperative-only motion during drag:
|
|
55
|
+
* - On every `grid:move` we mutate `sceneRegistry.nodes.get(id).position`
|
|
56
|
+
* directly. The node's store data is unchanged → the renderer doesn't
|
|
57
|
+
* re-render → R3F doesn't reapply `position={node.position}` → the
|
|
58
|
+
* imperative mutation sticks. Movement is smooth, framerate-locked,
|
|
59
|
+
* and React-free.
|
|
60
|
+
*
|
|
61
|
+
* Store update happens only on commit (single undoable action).
|
|
62
|
+
*
|
|
63
|
+
* Cancel imperatively snaps the mesh back to its original position and
|
|
64
|
+
* resumes history without ever having touched the store mid-drag.
|
|
65
|
+
*
|
|
66
|
+
* **Commit triggers**: the tool listens for `grid:click` *and* the
|
|
67
|
+
* common node click events (shelf / item / slab / ceiling / wall /
|
|
68
|
+
* fence / column / roof / stair). A click on the grid plane fires
|
|
69
|
+
* `grid:click`; a click on the moved node itself (or any other 3D
|
|
70
|
+
* geometry the ray happens to land on) fires the corresponding node
|
|
71
|
+
* click event. Without the node-click listeners, clicking on the
|
|
72
|
+
* cursor's own mesh during a move would silently drop the commit —
|
|
73
|
+
* the user perceives "click did nothing" because the click hit the
|
|
74
|
+
* vertical face of e.g. a shelf instead of the grid plane below it.
|
|
75
|
+
*
|
|
76
|
+
* The latest cursor position from `grid:move` is stored in a ref so
|
|
77
|
+
* any of these click variants commit at the same spot the cursor was
|
|
78
|
+
* indicating.
|
|
79
|
+
*/
|
|
80
|
+
type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
|
|
81
|
+
|
|
82
|
+
const CLICK_TRIGGER_KINDS = [
|
|
83
|
+
'shelf',
|
|
84
|
+
'item',
|
|
85
|
+
'slab',
|
|
86
|
+
'ceiling',
|
|
87
|
+
'wall',
|
|
88
|
+
'fence',
|
|
89
|
+
'column',
|
|
90
|
+
'roof',
|
|
91
|
+
'roof-segment',
|
|
92
|
+
'stair',
|
|
93
|
+
'stair-segment',
|
|
94
|
+
] as const
|
|
95
|
+
|
|
96
|
+
export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
97
|
+
const originalPosition: [number, number, number] = useMemo(
|
|
98
|
+
() =>
|
|
99
|
+
'position' in node && Array.isArray((node as { position?: unknown }).position)
|
|
100
|
+
? ((node as { position: [number, number, number] }).position ?? [0, 0, 0])
|
|
101
|
+
: [0, 0, 0],
|
|
102
|
+
[node],
|
|
103
|
+
)
|
|
104
|
+
/**
|
|
105
|
+
* Y-axis rotation of the node at move-start. Captured so the
|
|
106
|
+
* imperative drag preview (and the `useLiveTransforms` mirror) keeps
|
|
107
|
+
* the original orientation — otherwise hardcoding `rotation: 0` in
|
|
108
|
+
* `useLiveTransforms.set` would override `node.rotation[1]` during
|
|
109
|
+
* the drag, the shelf would visually un-rotate to 0, then snap back
|
|
110
|
+
* to its true rotation on commit (when the live transform clears).
|
|
111
|
+
* The user reads that snap as "reverts to a weird position".
|
|
112
|
+
*/
|
|
113
|
+
const originalRotationY: number = useMemo(() => {
|
|
114
|
+
if ('rotation' in node) {
|
|
115
|
+
const r = (node as { rotation?: unknown }).rotation
|
|
116
|
+
if (typeof r === 'number') return r
|
|
117
|
+
if (Array.isArray(r)) return (r as [number, number, number])[1] ?? 0
|
|
118
|
+
}
|
|
119
|
+
return 0
|
|
120
|
+
}, [node])
|
|
121
|
+
const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition)
|
|
122
|
+
const previousSnapRef = useRef<[number, number] | null>(null)
|
|
123
|
+
/**
|
|
124
|
+
* The latest snapped cursor position from `grid:move`. We commit at
|
|
125
|
+
* THIS position regardless of which event variant fires the click —
|
|
126
|
+
* a `grid:click` carries the same coords, but a node-click (e.g.
|
|
127
|
+
* `shelf:click`) carries the hit point on the clicked node's mesh,
|
|
128
|
+
* which can be slightly off-cursor when the user clicks the vertical
|
|
129
|
+
* face of the moved node itself. Reading from the ref keeps the
|
|
130
|
+
* commit position consistent with the visible cursor.
|
|
131
|
+
*/
|
|
132
|
+
const lastCursorRef = useRef<[number, number, number]>(originalPosition)
|
|
133
|
+
const dragAnchorRef = useRef<[number, number] | null>(null)
|
|
134
|
+
/**
|
|
135
|
+
* Becomes true on the first `grid:move` after this move arms. Commits are
|
|
136
|
+
* ignored until then so a click that *armed* this move (e.g. the trailing
|
|
137
|
+
* `click` event of the click that just committed the previous copy, when a
|
|
138
|
+
* preset placement immediately re-arms the next one) can't auto-drop a
|
|
139
|
+
* second copy at the spot. Every real placement moves the cursor into
|
|
140
|
+
* position before the drop click, so this never blocks a legitimate commit.
|
|
141
|
+
*/
|
|
142
|
+
const hasMovedRef = useRef(false)
|
|
143
|
+
// Live Y-rotation during the drag, seeded from the node's current rotation
|
|
144
|
+
// and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
|
|
145
|
+
// and committed to the scene on drop.
|
|
146
|
+
const rotationRef = useRef(originalRotationY)
|
|
147
|
+
|
|
148
|
+
// Shelf placement shows the same green/red footprint box GLB items use
|
|
149
|
+
// (instead of the vertical-arrow cursor) and refuses an invalid drop unless
|
|
150
|
+
// Shift forces it. The footprint comes from the kind's `floorPlaced`
|
|
151
|
+
// capability so this stays generic if we ever opt other kinds in.
|
|
152
|
+
const isShelf = node.type === 'shelf'
|
|
153
|
+
const boxDimensions = useMemo(
|
|
154
|
+
() =>
|
|
155
|
+
isShelf
|
|
156
|
+
? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
|
|
157
|
+
null)
|
|
158
|
+
: null,
|
|
159
|
+
[isShelf, node],
|
|
160
|
+
)
|
|
161
|
+
const [valid, setValid] = useState(true)
|
|
162
|
+
const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
|
|
163
|
+
const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
|
|
164
|
+
useFreshPlacementVisibility({ node })
|
|
165
|
+
// Mirrors of `valid` / Shift for the event handlers inside the effect, which
|
|
166
|
+
// can't read React state without stale closures.
|
|
167
|
+
const validRef = useRef(true)
|
|
168
|
+
const shiftRef = useRef(false)
|
|
169
|
+
|
|
170
|
+
const exitMoveMode = useCallback(() => {
|
|
171
|
+
useEditor.getState().setMovingNode(null)
|
|
172
|
+
}, [])
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
useScene.temporal.getState().pause()
|
|
176
|
+
previousSnapRef.current = null
|
|
177
|
+
dragAnchorRef.current = null
|
|
178
|
+
hasMovedRef.current = false
|
|
179
|
+
rotationRef.current = originalRotationY
|
|
180
|
+
shiftRef.current = false
|
|
181
|
+
validRef.current = true
|
|
182
|
+
// Re-sync the box transform to the (possibly new) node. `node` changes
|
|
183
|
+
// without this component remounting whenever a positioned preset re-arms a
|
|
184
|
+
// fresh clone after a drop, or the user picks a different catalog tile —
|
|
185
|
+
// and `useState` only honours its initial value, so without this the box
|
|
186
|
+
// would keep the previous clone's rotation/position until the next R/T.
|
|
187
|
+
setCursorRotationY(originalRotationY)
|
|
188
|
+
lastCursorRef.current = originalPosition
|
|
189
|
+
let committed = false
|
|
190
|
+
const isNew = isFreshPlacement
|
|
191
|
+
|
|
192
|
+
const baseRotation = (node as { rotation?: unknown }).rotation
|
|
193
|
+
const toCommitRotation = (y: number): number | [number, number, number] =>
|
|
194
|
+
Array.isArray(baseRotation)
|
|
195
|
+
? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
|
|
196
|
+
: y
|
|
197
|
+
|
|
198
|
+
const getVisualPosition = (
|
|
199
|
+
position: [number, number, number],
|
|
200
|
+
rotationY = rotationRef.current,
|
|
201
|
+
): [number, number, number] => {
|
|
202
|
+
return getFloorStackPreviewPosition({
|
|
203
|
+
node,
|
|
204
|
+
position,
|
|
205
|
+
rotation: toCommitRotation(rotationY),
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
const markMovedNodeDirty = () => {
|
|
209
|
+
if (useScene.getState().nodes[node.id]) {
|
|
210
|
+
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
|
|
215
|
+
|
|
216
|
+
// Re-run the floor-collision check at the live cursor + rotation and push
|
|
217
|
+
// the result to the box colour. Shift forces a valid (green) override so
|
|
218
|
+
// the user can drop on top of an existing item on purpose. Only shelves
|
|
219
|
+
// show the box, so this no-ops for every other movable kind.
|
|
220
|
+
const recomputeValidity = () => {
|
|
221
|
+
if (!boxDimensions) return
|
|
222
|
+
if (shiftRef.current) {
|
|
223
|
+
validRef.current = true
|
|
224
|
+
setValid(true)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
const levelId = useViewer.getState().selection.levelId ?? node.parentId
|
|
228
|
+
if (!levelId) {
|
|
229
|
+
validRef.current = true
|
|
230
|
+
setValid(true)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
const [x, y, z] = lastCursorRef.current
|
|
234
|
+
const { valid: placeable } = spatialGridManager.canPlaceOnFloor(
|
|
235
|
+
levelId,
|
|
236
|
+
[x, y, z],
|
|
237
|
+
boxDimensions,
|
|
238
|
+
[0, rotationRef.current, 0],
|
|
239
|
+
[node.id],
|
|
240
|
+
)
|
|
241
|
+
validRef.current = placeable
|
|
242
|
+
setValid(placeable)
|
|
243
|
+
}
|
|
244
|
+
recomputeValidity()
|
|
245
|
+
|
|
246
|
+
// Disable raycast on the moved node's meshes for the duration of
|
|
247
|
+
// the drag. As the shelf follows the cursor, the cursor ray would
|
|
248
|
+
// otherwise hit the moved mesh first → only `${kind}:move` fires →
|
|
249
|
+
// `grid:move` stops updating `lastCursorRef` → clicks would commit
|
|
250
|
+
// at the stale (initial) position. With raycast disabled, the ray
|
|
251
|
+
// passes through the moved mesh and continues to the grid plane,
|
|
252
|
+
// so `grid:move` keeps firing and the cursor tracks correctly.
|
|
253
|
+
// We restore the original raycast on cleanup.
|
|
254
|
+
const mesh = sceneRegistry.nodes.get(node.id)
|
|
255
|
+
const restoreRaycasts: Array<() => void> = []
|
|
256
|
+
if (mesh) {
|
|
257
|
+
mesh.traverse((child) => {
|
|
258
|
+
const original = child.raycast
|
|
259
|
+
child.raycast = () => {}
|
|
260
|
+
restoreRaycasts.push(() => {
|
|
261
|
+
child.raycast = original
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Static alignment candidates — anchors of every OTHER alignable object
|
|
267
|
+
// (items, walls, fences, slabs, ceilings, columns) ON THE SAME LEVEL,
|
|
268
|
+
// gathered once at drag start (the scene graph is stable during an
|
|
269
|
+
// imperative move). Level-scoped so a node directly below on another
|
|
270
|
+
// floor doesn't snap (alignment is XZ-only). Coords are building-local,
|
|
271
|
+
// the same frame as `event.localPosition` and the rendered cursor, so
|
|
272
|
+
// the guide dots line up with the cursor.
|
|
273
|
+
const alignmentCandidates = collectAlignmentAnchors(
|
|
274
|
+
useScene.getState().nodes,
|
|
275
|
+
node.id,
|
|
276
|
+
useViewer.getState().selection.levelId ?? node.parentId,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
const onGridMove = (event: GridEvent) => {
|
|
280
|
+
const rawX = event.localPosition[0]
|
|
281
|
+
const rawZ = event.localPosition[2]
|
|
282
|
+
revealFreshPlacement()
|
|
283
|
+
|
|
284
|
+
const resolved = resolvePlanarCursorPosition({
|
|
285
|
+
cursor: [rawX, rawZ],
|
|
286
|
+
original: [originalPosition[0], originalPosition[2]],
|
|
287
|
+
anchor: dragAnchorRef.current,
|
|
288
|
+
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
|
|
289
|
+
snap: snapToGridStep,
|
|
290
|
+
})
|
|
291
|
+
dragAnchorRef.current = resolved.anchor
|
|
292
|
+
let [x, z] = resolved.point
|
|
293
|
+
|
|
294
|
+
// Figma-style alignment snap layered on top of grid snap: when the
|
|
295
|
+
// moving item's edge lines up (on X or Z) with another item's edge,
|
|
296
|
+
// snap and publish a guide. The guide connects to the nearest real
|
|
297
|
+
// corner of the candidate (resolver tie-break), so the dot always sits
|
|
298
|
+
// on an actual point. Alt bypasses.
|
|
299
|
+
const bypass = event.nativeEvent?.altKey === true
|
|
300
|
+
if (!bypass && alignmentCandidates.length > 0) {
|
|
301
|
+
const result = resolveAlignment({
|
|
302
|
+
moving: movingFootprintAnchors(node, x, z, rotationRef.current),
|
|
303
|
+
candidates: alignmentCandidates,
|
|
304
|
+
threshold: ALIGNMENT_THRESHOLD_M,
|
|
305
|
+
})
|
|
306
|
+
if (result.snap) {
|
|
307
|
+
x += result.snap.dx
|
|
308
|
+
z += result.snap.dz
|
|
309
|
+
}
|
|
310
|
+
useAlignmentGuides.getState().set(result.guides)
|
|
311
|
+
} else {
|
|
312
|
+
useAlignmentGuides.getState().clear()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const position: [number, number, number] = [x, originalPosition[1], z]
|
|
316
|
+
const visualPosition = getVisualPosition(position)
|
|
317
|
+
hasMovedRef.current = true
|
|
318
|
+
setCursorPosition(visualPosition)
|
|
319
|
+
lastCursorRef.current = position
|
|
320
|
+
recomputeValidity()
|
|
321
|
+
|
|
322
|
+
// Pure imperative: move the mesh via its registered Object3D ref.
|
|
323
|
+
sceneRegistry.nodes.get(node.id)?.position.set(...visualPosition)
|
|
324
|
+
// Publish to `useLiveTransforms` so the 2D floor plan can mirror
|
|
325
|
+
// the drag in real-time (the floor-plan layer subscribes to this
|
|
326
|
+
// store and overrides the node's rendered position when an entry
|
|
327
|
+
// is set). Without this the 2D representation stays at the
|
|
328
|
+
// committed scene position until the move ends.
|
|
329
|
+
//
|
|
330
|
+
// For position-based kinds (shelf, item, column, spawn) we write
|
|
331
|
+
// the absolute world plan position here. Polygon-based kinds
|
|
332
|
+
// (slab / ceiling / fence) follow a different delta contract —
|
|
333
|
+
// their floor-plan move-targets handle the override themselves.
|
|
334
|
+
useLiveTransforms.getState().set(node.id, {
|
|
335
|
+
position,
|
|
336
|
+
rotation: rotationRef.current,
|
|
337
|
+
})
|
|
338
|
+
markMovedNodeDirty()
|
|
339
|
+
|
|
340
|
+
const prev = previousSnapRef.current
|
|
341
|
+
if (!prev || prev[0] !== x || prev[1] !== z) {
|
|
342
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
343
|
+
previousSnapRef.current = [x, z]
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Commit the move at the latest cursor position. Shared by every
|
|
348
|
+
* click variant — grid plane, the moved node itself, or any other
|
|
349
|
+
* 3D surface the user happens to click on during the move.
|
|
350
|
+
*
|
|
351
|
+
* Order is deliberate: write scene FIRST, then clear
|
|
352
|
+
* `useLiveTransforms`. If we cleared the live transform first,
|
|
353
|
+
* `ParametricNodeRenderer` would re-render with
|
|
354
|
+
* `position = liveTransform?.position ?? node.position` → undefined
|
|
355
|
+
* → original `node.position` (the scene write hasn't happened yet),
|
|
356
|
+
* briefly snapping the mesh back to its starting spot before the
|
|
357
|
+
* next render lands the new position. Writing scene first means
|
|
358
|
+
* every render shows either the live drag position (liveTransform
|
|
359
|
+
* still set) or the new committed position (liveTransform cleared
|
|
360
|
+
* AND scene updated) — never the original.
|
|
361
|
+
*/
|
|
362
|
+
const commitAtCursor = (event: ClickTriggerEvent) => {
|
|
363
|
+
// Ignore a commit that fires before the cursor has moved into place —
|
|
364
|
+
// it's the stray trailing click of whatever armed this move, not a
|
|
365
|
+
// deliberate drop. Prevents preset re-arm from double-placing.
|
|
366
|
+
if (!hasMovedRef.current) return
|
|
367
|
+
// Refuse a drop on an invalid (red) footprint, matching the GLB item
|
|
368
|
+
// tool — unless Shift is held to force placement. Other kinds carry no
|
|
369
|
+
// validity box (`validRef` stays true), so they're never blocked.
|
|
370
|
+
if (!validRef.current && !shiftRef.current) return
|
|
371
|
+
const position: [number, number, number] = [...lastCursorRef.current]
|
|
372
|
+
|
|
373
|
+
const rotation = toCommitRotation(rotationRef.current)
|
|
374
|
+
const visualPosition = getVisualPosition(position)
|
|
375
|
+
let committedId = node.id as AnyNodeId
|
|
376
|
+
|
|
377
|
+
if (useScene.getState().nodes[node.id]) {
|
|
378
|
+
const data = {
|
|
379
|
+
position,
|
|
380
|
+
rotation,
|
|
381
|
+
...(isNew
|
|
382
|
+
? {
|
|
383
|
+
metadata: stripPlacementMetadataFlags(node.metadata),
|
|
384
|
+
visible: true,
|
|
385
|
+
}
|
|
386
|
+
: null),
|
|
387
|
+
} as Partial<AnyNode>
|
|
388
|
+
|
|
389
|
+
if (isNew) {
|
|
390
|
+
const finalId = commitFreshPlacementSubtree(node.id as AnyNodeId, data)
|
|
391
|
+
if (finalId) {
|
|
392
|
+
committed = true
|
|
393
|
+
committedId = finalId
|
|
394
|
+
}
|
|
395
|
+
} else {
|
|
396
|
+
useScene.temporal.getState().resume()
|
|
397
|
+
useScene.getState().updateNode(node.id, data)
|
|
398
|
+
useScene.temporal.getState().pause()
|
|
399
|
+
committed = true
|
|
400
|
+
}
|
|
401
|
+
} else if (node.parentId) {
|
|
402
|
+
// Orphan re-create path: re-parse via the registry's schema.
|
|
403
|
+
const def = nodeRegistry.get(node.type)
|
|
404
|
+
if (def) {
|
|
405
|
+
const reparsed = def.schema.parse({
|
|
406
|
+
...(node as Record<string, unknown>),
|
|
407
|
+
id: undefined,
|
|
408
|
+
metadata: {},
|
|
409
|
+
position,
|
|
410
|
+
rotation,
|
|
411
|
+
})
|
|
412
|
+
useScene.temporal.getState().resume()
|
|
413
|
+
useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId)
|
|
414
|
+
useScene.temporal.getState().pause()
|
|
415
|
+
committed = true
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Clear after the scene write so React reconciles against the new
|
|
420
|
+
// canonical position, then restamp the lifted presentation Y for the
|
|
421
|
+
// current frame.
|
|
422
|
+
useLiveTransforms.getState().clear(node.id)
|
|
423
|
+
const mesh = sceneRegistry.nodes.get(node.id)
|
|
424
|
+
if (mesh) {
|
|
425
|
+
mesh.position.set(...visualPosition)
|
|
426
|
+
mesh.rotation.y = rotationRef.current
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
useAlignmentGuides.getState().clear()
|
|
430
|
+
if (isNew && committed) {
|
|
431
|
+
useViewer.getState().setSelection({ selectedIds: [committedId] })
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
sfxEmitter.emit('sfx:item-place')
|
|
435
|
+
useEditor.getState().setMovingNodeOrigin('3d')
|
|
436
|
+
exitMoveMode()
|
|
437
|
+
|
|
438
|
+
// Stop further propagation so other listeners (e.g. a selection
|
|
439
|
+
// change on the clicked node) don't fire during the commit click.
|
|
440
|
+
const native = (event as { nativeEvent?: unknown }).nativeEvent
|
|
441
|
+
if (
|
|
442
|
+
native &&
|
|
443
|
+
typeof (native as { stopPropagation?: () => void }).stopPropagation === 'function'
|
|
444
|
+
) {
|
|
445
|
+
;(native as { stopPropagation: () => void }).stopPropagation()
|
|
446
|
+
}
|
|
447
|
+
const direct = (event as { stopPropagation?: () => void }).stopPropagation
|
|
448
|
+
if (typeof direct === 'function') direct.call(event)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// R / T rotate the dragged node about Y in 90° steps — matching the GLB
|
|
452
|
+
// item placement keys (and the "Rotate" hints the move HUD shows). Applied
|
|
453
|
+
// imperatively + mirrored to the live transform; committed on drop.
|
|
454
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
455
|
+
// Hold Shift to force placement on an invalid (red) footprint, matching
|
|
456
|
+
// the GLB item tool. Recolour the box to green while held.
|
|
457
|
+
if (e.key === 'Shift') {
|
|
458
|
+
shiftRef.current = true
|
|
459
|
+
recomputeValidity()
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return
|
|
463
|
+
let delta = 0
|
|
464
|
+
if (e.key === 'r' || e.key === 'R') delta = ROTATION_STEP
|
|
465
|
+
else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
|
|
466
|
+
else return
|
|
467
|
+
e.preventDefault()
|
|
468
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
469
|
+
rotationRef.current += delta
|
|
470
|
+
setCursorRotationY(rotationRef.current)
|
|
471
|
+
const position = lastCursorRef.current
|
|
472
|
+
const visualPosition = getVisualPosition(position)
|
|
473
|
+
setCursorPosition(visualPosition)
|
|
474
|
+
const m = sceneRegistry.nodes.get(node.id)
|
|
475
|
+
if (m) {
|
|
476
|
+
m.position.set(...visualPosition)
|
|
477
|
+
m.rotation.y = rotationRef.current
|
|
478
|
+
}
|
|
479
|
+
useLiveTransforms.getState().set(node.id, {
|
|
480
|
+
position,
|
|
481
|
+
rotation: rotationRef.current,
|
|
482
|
+
})
|
|
483
|
+
markMovedNodeDirty()
|
|
484
|
+
// Rotation changes the footprint's collision span — re-check validity.
|
|
485
|
+
recomputeValidity()
|
|
486
|
+
}
|
|
487
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
488
|
+
if (e.key === 'Shift') {
|
|
489
|
+
shiftRef.current = false
|
|
490
|
+
recomputeValidity()
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
window.addEventListener('keydown', onKeyDown)
|
|
494
|
+
window.addEventListener('keyup', onKeyUp)
|
|
495
|
+
|
|
496
|
+
emitter.on('grid:move', onGridMove)
|
|
497
|
+
emitter.on('grid:click', commitAtCursor)
|
|
498
|
+
|
|
499
|
+
// Listen on every common kind's click event too. mitt's typing keeps
|
|
500
|
+
// `${kind}:click` as a fixed union so the cast is safe at runtime —
|
|
501
|
+
// we're just routing them through the shared commit path.
|
|
502
|
+
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
|
|
503
|
+
type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
|
|
504
|
+
for (const kind of CLICK_TRIGGER_KINDS) {
|
|
505
|
+
const key = `${kind}:click` as ClickKey
|
|
506
|
+
emitter.on(key, commitAtCursor as never)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const onCancel = () => {
|
|
510
|
+
useLiveTransforms.getState().clear(node.id)
|
|
511
|
+
if (isNew) {
|
|
512
|
+
useScene.getState().deleteNode(node.id as AnyNodeId)
|
|
513
|
+
} else {
|
|
514
|
+
const m = sceneRegistry.nodes.get(node.id)
|
|
515
|
+
if (m) {
|
|
516
|
+
m.position.set(...getVisualPosition(originalPosition, originalRotationY))
|
|
517
|
+
m.rotation.y = originalRotationY
|
|
518
|
+
}
|
|
519
|
+
markMovedNodeDirty()
|
|
520
|
+
}
|
|
521
|
+
useAlignmentGuides.getState().clear()
|
|
522
|
+
useScene.temporal.getState().resume()
|
|
523
|
+
markToolCancelConsumed()
|
|
524
|
+
exitMoveMode()
|
|
525
|
+
}
|
|
526
|
+
emitter.on('tool:cancel', onCancel)
|
|
527
|
+
|
|
528
|
+
return () => {
|
|
529
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
530
|
+
window.removeEventListener('keyup', onKeyUp)
|
|
531
|
+
emitter.off('grid:move', onGridMove)
|
|
532
|
+
emitter.off('grid:click', commitAtCursor)
|
|
533
|
+
for (const kind of CLICK_TRIGGER_KINDS) {
|
|
534
|
+
const key = `${kind}:click` as ClickKey
|
|
535
|
+
emitter.off(key, commitAtCursor as never)
|
|
536
|
+
}
|
|
537
|
+
emitter.off('tool:cancel', onCancel)
|
|
538
|
+
// Restore the moved meshes' raycast so they're hoverable / selectable
|
|
539
|
+
// again after the drag ends.
|
|
540
|
+
for (const restore of restoreRaycasts) restore()
|
|
541
|
+
// Drop any alignment guides this drag published — covers Esc / mid-drag
|
|
542
|
+
// unmount / commit paths uniformly.
|
|
543
|
+
useAlignmentGuides.getState().clear()
|
|
544
|
+
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
|
|
545
|
+
if (!(committed || isNew || finalisedBy2D)) {
|
|
546
|
+
useLiveTransforms.getState().clear(node.id)
|
|
547
|
+
sceneRegistry.nodes
|
|
548
|
+
.get(node.id)
|
|
549
|
+
?.position.set(...getVisualPosition(originalPosition, originalRotationY))
|
|
550
|
+
markMovedNodeDirty()
|
|
551
|
+
}
|
|
552
|
+
useScene.temporal.getState().resume()
|
|
553
|
+
}
|
|
554
|
+
}, [
|
|
555
|
+
boxDimensions,
|
|
556
|
+
exitMoveMode,
|
|
557
|
+
isFreshPlacement,
|
|
558
|
+
node,
|
|
559
|
+
originalPosition,
|
|
560
|
+
originalRotationY,
|
|
561
|
+
revealFreshPlacement,
|
|
562
|
+
useAbsoluteCursorPlacement,
|
|
563
|
+
])
|
|
564
|
+
|
|
565
|
+
// Snapshot the scene once at drag-start — bounds depend on `node` (locked
|
|
566
|
+
// for the lifetime of this tool) and any sibling state the kind reads. If a
|
|
567
|
+
// future kind needs live sibling state mid-drag, switch to a subscribed
|
|
568
|
+
// selector; for v1 (elevator shaft height from level set) start-time is
|
|
569
|
+
// correct and avoids subscribing the whole `nodes` map.
|
|
570
|
+
const dragBounds = useMemo(
|
|
571
|
+
() =>
|
|
572
|
+
nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) ??
|
|
573
|
+
null,
|
|
574
|
+
[node],
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
if (!previewVisible) return null
|
|
578
|
+
|
|
579
|
+
if (boxDimensions) {
|
|
580
|
+
return (
|
|
581
|
+
<PlacementBox
|
|
582
|
+
dimensions={boxDimensions}
|
|
583
|
+
position={cursorPosition}
|
|
584
|
+
rotationY={cursorRotationY}
|
|
585
|
+
valid={valid}
|
|
586
|
+
/>
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return (
|
|
591
|
+
<>
|
|
592
|
+
<CursorSphere color="#a78bfa" height={2.5} position={cursorPosition} />
|
|
593
|
+
<DragBoundingBox
|
|
594
|
+
centerY={dragBounds?.centerY}
|
|
595
|
+
nodeId={node.id}
|
|
596
|
+
position={cursorPosition}
|
|
597
|
+
rotationY={cursorRotationY}
|
|
598
|
+
size={dragBounds?.size}
|
|
599
|
+
/>
|
|
600
|
+
</>
|
|
601
|
+
)
|
|
602
|
+
}
|