@pascal-app/editor 1.0.0-beta.5 → 1.0.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/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/bake-exporter.tsx +5 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- 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 +9 -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 +180 -12
- package/src/components/editor/floating-action-menu.tsx +55 -51
- package/src/components/editor/floorplan-panel.tsx +99 -15
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- 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/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +78 -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 +23 -3
- package/src/components/editor/index.tsx +151 -24
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +67 -119
- package/src/components/editor/selection-manager.tsx +153 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- 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 -239
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- 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-action-menu.tsx +12 -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 +226 -35
- package/src/components/procedural-items/inspector.tsx +223 -0
- 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/ceiling/ceiling-system.tsx +3 -1
- 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/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -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 +331 -4
- package/src/components/tools/item/placement-strategies.ts +304 -171
- 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 +10 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -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 +116 -44
- package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
- 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 +57 -7
- 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 +56 -17
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- 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 +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- 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 +63 -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/metric-control.tsx +29 -13
- 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 +44 -21
- 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 +103 -10
- 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 +266 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- 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/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 +35 -164
- package/src/components/ui/panels/reference-panel.tsx +34 -4
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- 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/settings-panel/index.tsx +502 -31
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- 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/shelf-tree-node.tsx +3 -8
- 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 +20 -7
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +5 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-overlay.tsx +1 -22
- 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 +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +144 -0
- package/src/hooks/use-ceiling-events.ts +5 -1
- 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 +132 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +87 -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/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/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- 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 +536 -2
- package/src/lib/floorplan/floorplan-export.tsx +120 -19
- 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.ts +5 -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/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +1375 -21
- package/src/lib/glb-export.ts +781 -106
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -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-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- 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/model-export.ts +29 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- 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 +455 -0
- package/src/lib/portable-export.ts +1042 -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 +379 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +200 -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 +38 -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-clipboard.ts +1 -1
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -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/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +65 -0
- package/src/lib/use-linear-display.ts +8 -32
- 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-handle-group.ts +4 -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-send-to-app.ts +21 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -8,14 +8,18 @@ import {
|
|
|
8
8
|
type AnyNodeId,
|
|
9
9
|
analyzePortConnectivity,
|
|
10
10
|
bboxCornerAnchors,
|
|
11
|
+
cascadeDirty,
|
|
11
12
|
collectAlignmentAnchors,
|
|
12
13
|
createSceneApi,
|
|
13
|
-
type EventSuffix,
|
|
14
14
|
emitter,
|
|
15
|
+
findLevelAncestorId,
|
|
15
16
|
footprintAABBFrom,
|
|
16
17
|
type GridEvent,
|
|
18
|
+
type GroupMoveSnapResult,
|
|
17
19
|
getFloorPlacedFootprints,
|
|
20
|
+
type MovableConfig,
|
|
18
21
|
movingFootprintAnchors,
|
|
22
|
+
NON_PHYSICAL_HOST_KINDS,
|
|
19
23
|
type NodeEvent,
|
|
20
24
|
nodeRegistry,
|
|
21
25
|
type ParentFrameSnapMatch,
|
|
@@ -23,7 +27,9 @@ import {
|
|
|
23
27
|
resolveAlignment,
|
|
24
28
|
resolveConnectivityUpdates,
|
|
25
29
|
resolveFacingIndicator,
|
|
30
|
+
resolveFrozenFloorPlacementPatch,
|
|
26
31
|
resolveSupportSlabPatch,
|
|
32
|
+
type SurfaceRejectReason,
|
|
27
33
|
sceneRegistry,
|
|
28
34
|
spatialGridManager,
|
|
29
35
|
useLiveNodeOverrides,
|
|
@@ -31,16 +37,25 @@ import {
|
|
|
31
37
|
useScene,
|
|
32
38
|
} from '@pascal-app/core'
|
|
33
39
|
import { useViewer } from '@pascal-app/viewer'
|
|
34
|
-
import { useThree } from '@react-three/fiber'
|
|
40
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
35
41
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
42
|
+
import { type Group, Vector3 } from 'three'
|
|
36
43
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
37
44
|
import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
|
|
38
45
|
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
|
|
39
|
-
import {
|
|
40
|
-
|
|
46
|
+
import {
|
|
47
|
+
offsetPlanPositionByLocalCenter,
|
|
48
|
+
resolvePrioritizedPlanarCursorPosition,
|
|
49
|
+
} from '../../../lib/planar-cursor-placement'
|
|
50
|
+
import { resolveAttachmentPreviewRotation } from '../../../lib/rigid-plan-svg-transform'
|
|
51
|
+
import { createMovementSfxTick } from '../../../lib/sfx/movement-tick'
|
|
41
52
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
42
53
|
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
|
43
|
-
|
|
54
|
+
import {
|
|
55
|
+
surfaceAttachmentId,
|
|
56
|
+
surfaceAttachmentUpdates,
|
|
57
|
+
updateSurfaceNode,
|
|
58
|
+
} from '../../../lib/surface-attachment'
|
|
44
59
|
import useAlignmentGuides from '../../../store/use-alignment-guides'
|
|
45
60
|
import useEditor, {
|
|
46
61
|
getActiveSnappingMode,
|
|
@@ -48,7 +63,6 @@ import useEditor, {
|
|
|
48
63
|
isGridSnapActive,
|
|
49
64
|
isMagneticSnapActive,
|
|
50
65
|
} from '../../../store/use-editor'
|
|
51
|
-
|
|
52
66
|
import useFacingPose from '../../../store/use-facing-pose'
|
|
53
67
|
import { swallowNextClick } from '../../editor/node-arrow-handles'
|
|
54
68
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
@@ -56,7 +70,11 @@ import { DragBoundingBox } from '../shared/drag-bounding-box'
|
|
|
56
70
|
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
|
57
71
|
import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
|
|
58
72
|
import { PlacementBox } from '../shared/placement-box'
|
|
59
|
-
import {
|
|
73
|
+
import {
|
|
74
|
+
type PointerSupportSurface,
|
|
75
|
+
resolvePointerSupportSurface,
|
|
76
|
+
} from '../shared/pointer-support-cap'
|
|
77
|
+
import { createItemSurfaceGridDispatch, createRegistryItemSurfaceMove } from './item-surface-move'
|
|
60
78
|
|
|
61
79
|
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
|
|
62
80
|
* / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
|
|
@@ -69,6 +87,15 @@ const snapToGridStep = (value: number) => {
|
|
|
69
87
|
/** 45° steps, matching the GLB item placement rotation. */
|
|
70
88
|
const ROTATION_STEP = Math.PI / 4
|
|
71
89
|
|
|
90
|
+
export function resolveMoveRotationStep(
|
|
91
|
+
freeRotation: number,
|
|
92
|
+
delta: number,
|
|
93
|
+
attachmentRotation: number | null,
|
|
94
|
+
): number | null {
|
|
95
|
+
if (attachmentRotation !== null) return null
|
|
96
|
+
return freeRotation + delta
|
|
97
|
+
}
|
|
98
|
+
|
|
72
99
|
/** Default magnetic radius (meters, XZ) for `movable.portSnap`. */
|
|
73
100
|
const PORT_SNAP_RADIUS_M = 0.5
|
|
74
101
|
const VALID_COLOR = 0x22_c5_5e
|
|
@@ -80,20 +107,6 @@ type DragBoundsOverride = {
|
|
|
80
107
|
centerY?: number
|
|
81
108
|
}
|
|
82
109
|
|
|
83
|
-
function offsetPlanPositionByLocalCenter(
|
|
84
|
-
position: [number, number, number],
|
|
85
|
-
center: [number, number, number],
|
|
86
|
-
rotationY: number,
|
|
87
|
-
): [number, number, number] {
|
|
88
|
-
const cos = Math.cos(rotationY)
|
|
89
|
-
const sin = Math.sin(rotationY)
|
|
90
|
-
return [
|
|
91
|
-
position[0] + center[0] * cos + center[2] * sin,
|
|
92
|
-
position[1] + center[1],
|
|
93
|
-
position[2] - center[0] * sin + center[2] * cos,
|
|
94
|
-
]
|
|
95
|
-
}
|
|
96
|
-
|
|
97
110
|
/**
|
|
98
111
|
* Alignment anchors for the moving node. When the kind declares
|
|
99
112
|
* `capabilities.dragBounds` with an off-origin `center` (a composite cabinet
|
|
@@ -191,24 +204,10 @@ const ALIGNMENT_THRESHOLD_M = 0.08
|
|
|
191
204
|
/**
|
|
192
205
|
* Generic move tool for any registry-backed kind.
|
|
193
206
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* imperative mutation sticks. Movement is smooth, framerate-locked,
|
|
199
|
-
* and React-free.
|
|
200
|
-
*
|
|
201
|
-
* Store update happens only on commit (single undoable action).
|
|
202
|
-
*
|
|
203
|
-
* Cancel imperatively snaps the mesh back to its original position and
|
|
204
|
-
* resumes history without ever having touched the store mid-drag.
|
|
205
|
-
*
|
|
206
|
-
* **Commit triggers**: the tool listens for `grid:click` *and* the
|
|
207
|
-
* common node click events (shelf / item / slab / ceiling / wall /
|
|
208
|
-
* fence / column / roof / stair). A click on the grid plane fires
|
|
209
|
-
* `grid:click`; a click on the moved node itself (or any other 3D
|
|
210
|
-
* geometry the ray happens to land on) fires the corresponding node
|
|
211
|
-
* click event. Without the node-click listeners, clicking on the
|
|
207
|
+
* **Commit triggers**: the tool listens for `grid:click` and the generic
|
|
208
|
+
* `node:click` event. A click on the grid plane fires `grid:click`; a click
|
|
209
|
+
* on the moved node itself (or any other 3D geometry the ray happens to land
|
|
210
|
+
* on) fires `node:click`. Without the node-click listener, clicking on the
|
|
212
211
|
* cursor's own mesh during a move would silently drop the commit —
|
|
213
212
|
* the user perceives "click did nothing" because the click hit the
|
|
214
213
|
* vertical face of e.g. a shelf instead of the grid plane below it.
|
|
@@ -219,21 +218,24 @@ const ALIGNMENT_THRESHOLD_M = 0.08
|
|
|
219
218
|
*/
|
|
220
219
|
type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
|
|
221
220
|
|
|
222
|
-
const CLICK_TRIGGER_KINDS = [
|
|
223
|
-
'shelf',
|
|
224
|
-
'item',
|
|
225
|
-
'slab',
|
|
226
|
-
'ceiling',
|
|
227
|
-
'wall',
|
|
228
|
-
'fence',
|
|
229
|
-
'column',
|
|
230
|
-
'roof',
|
|
231
|
-
'roof-segment',
|
|
232
|
-
'stair',
|
|
233
|
-
'stair-segment',
|
|
234
|
-
] as const
|
|
235
|
-
|
|
236
221
|
export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
222
|
+
const itemSurfaceMove = useMemo(() => createRegistryItemSurfaceMove(node), [node])
|
|
223
|
+
const suppressRaycastsRef = useRef<(() => void) | null>(null)
|
|
224
|
+
const previewGroupRef = useRef<Group>(null)
|
|
225
|
+
useFrame(() => {
|
|
226
|
+
suppressRaycastsRef.current?.()
|
|
227
|
+
if (!previewGroupRef.current) return
|
|
228
|
+
const nodes = useScene.getState().nodes
|
|
229
|
+
const parentId = nodes[node.id]?.parentId ?? node.parentId
|
|
230
|
+
const parent = parentId ? nodes[parentId as AnyNodeId] : undefined
|
|
231
|
+
// Building-parented kinds already preview in the tool group's frame.
|
|
232
|
+
const levelId =
|
|
233
|
+
(parentId ? findLevelAncestorId(parentId as AnyNodeId, nodes) : null) ??
|
|
234
|
+
(parent?.type === 'building' ? null : useViewer.getState().selection.levelId)
|
|
235
|
+
previewGroupRef.current.position.y = levelId
|
|
236
|
+
? (sceneRegistry.nodes.get(levelId)?.position.y ?? 0)
|
|
237
|
+
: 0
|
|
238
|
+
})
|
|
237
239
|
// Live camera ref — the pointer-surface cap reconstructs the cursor world
|
|
238
240
|
// ray (camera → grid hit) to find which walking surface is aimed at.
|
|
239
241
|
const camera = useThree((s) => s.camera)
|
|
@@ -243,6 +245,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
243
245
|
// refreshed per grid move. Caps the floor-support election so a deck
|
|
244
246
|
// hanging above the aimed-at floor never lifts the dragged node.
|
|
245
247
|
const supportCapRef = useRef<number | null>(null)
|
|
248
|
+
const supportSurfaceRef = useRef<PointerSupportSurface | null>(null)
|
|
246
249
|
// Kinds whose `position` lives in a host parent's local frame declare
|
|
247
250
|
// `movable.parentFrame` (cabinet module ↔ its run). The tool converts the
|
|
248
251
|
// plan-frame cursor through the capability's hooks and previews via
|
|
@@ -277,7 +280,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
277
280
|
return 0
|
|
278
281
|
}, [node])
|
|
279
282
|
const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition)
|
|
280
|
-
const previousSnapRef = useRef<string | null>(null)
|
|
281
283
|
/**
|
|
282
284
|
* The latest snapped cursor position from `grid:move`. We commit at
|
|
283
285
|
* THIS position regardless of which event variant fires the click —
|
|
@@ -302,12 +304,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
302
304
|
// and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
|
|
303
305
|
// and committed to the scene on drop.
|
|
304
306
|
const rotationRef = useRef(originalRotationY)
|
|
307
|
+
const freeRotationRef = useRef(originalRotationY)
|
|
308
|
+
const attachmentRotationRef = useRef<number | null>(null)
|
|
305
309
|
// Snapshot of which ducts / fittings are mated to this node's ports at
|
|
306
310
|
// drag-start (duct fittings only). Drives the "connected ductwork follows"
|
|
307
311
|
// behaviour: connected nodes preview through `useLiveNodeOverrides` during
|
|
308
312
|
// the drag and commit alongside the moved node on drop. Null for kinds with
|
|
309
313
|
// no ports, so every other movable kind is unaffected.
|
|
310
314
|
const connectivityRef = useRef<PortConnectivity | null>(null)
|
|
315
|
+
// Node ids touched by a parent-frame kind's derived live preview, such as
|
|
316
|
+
// linked cabinet corner runs. This is separate from port connectivity so
|
|
317
|
+
// each preview channel can be cleared independently.
|
|
318
|
+
const parentFramePreviewIdsRef = useRef<AnyNodeId[]>([])
|
|
311
319
|
// Node ids this drag has pushed live overrides onto — cleared on
|
|
312
320
|
// commit / cancel / unmount so a follow-on drag starts clean.
|
|
313
321
|
const overriddenIdsRef = useRef<AnyNodeId[]>([])
|
|
@@ -317,8 +325,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
317
325
|
// refuse an invalid drop unless Alt forces it. The gate + footprint both come
|
|
318
326
|
// from the kind's declarative `floorPlaced` capability, so opting a new kind
|
|
319
327
|
// in is just `collides: true` — no change here.
|
|
320
|
-
// Parent-frame kinds skip the world-frame floor-collision
|
|
321
|
-
// position isn't in the level frame the spatial grid indexes.
|
|
328
|
+
// Parent-frame kinds skip the world-frame floor-collision check — their
|
|
329
|
+
// position isn't in the level frame the spatial grid indexes. They may
|
|
330
|
+
// still provide a parent-frame collision check and use the same bounds box.
|
|
322
331
|
const collides =
|
|
323
332
|
!frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
|
|
324
333
|
// Snapshot the scene once at drag-start — bounds depend on `node` (locked
|
|
@@ -333,6 +342,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
333
342
|
| undefined) ?? null,
|
|
334
343
|
[node],
|
|
335
344
|
)
|
|
345
|
+
const parentFrameCollides = Boolean(
|
|
346
|
+
frameParent && parentFrame?.isValidPosition && dragBounds?.size,
|
|
347
|
+
)
|
|
336
348
|
// Collision extents: the declared drag bounds (composite kinds — a cabinet
|
|
337
349
|
// run spans its modules) win over the single-node footprint.
|
|
338
350
|
const resolvedFootprint = useMemo(
|
|
@@ -343,24 +355,28 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
343
355
|
[dragBounds, node],
|
|
344
356
|
)
|
|
345
357
|
const boxDimensions = useMemo(
|
|
346
|
-
() => (collides ? resolvedFootprint : null),
|
|
347
|
-
[collides, resolvedFootprint],
|
|
358
|
+
() => (collides || parentFrameCollides ? resolvedFootprint : null),
|
|
359
|
+
[collides, parentFrameCollides, resolvedFootprint],
|
|
348
360
|
)
|
|
349
361
|
const [valid, setValid] = useState(true)
|
|
362
|
+
const [surfaceRejection, setSurfaceRejection] = useState<SurfaceRejectReason | null>(null)
|
|
350
363
|
const previewRotationY = useCallback(
|
|
351
364
|
(rotationY = rotationRef.current) =>
|
|
352
365
|
parentFrame && frameParent
|
|
353
366
|
? parentFrame.parentRotationY(frameParent, useScene.getState().nodes) + rotationY
|
|
354
|
-
:
|
|
355
|
-
|
|
367
|
+
: itemSurfaceMove?.hosted
|
|
368
|
+
? itemSurfaceMove.planPose(lastCursorRef.current, rotationY).rotationY
|
|
369
|
+
: rotationY,
|
|
370
|
+
[parentFrame, frameParent, itemSurfaceMove],
|
|
356
371
|
)
|
|
357
372
|
const visualPositionFor = useCallback(
|
|
358
373
|
(position: [number, number, number], rotationY = rotationRef.current) => {
|
|
359
374
|
if (parentFrame && frameParent) {
|
|
360
375
|
return parentFrame.localToPlan(frameParent, position, useScene.getState().nodes)
|
|
361
376
|
}
|
|
377
|
+
if (itemSurfaceMove?.hosted) return itemSurfaceMove.planPose(position, rotationY).position
|
|
362
378
|
return getFloorStackPreviewPosition({
|
|
363
|
-
node,
|
|
379
|
+
node: useScene.getState().nodes[node.id] ?? node,
|
|
364
380
|
position,
|
|
365
381
|
rotation: (() => {
|
|
366
382
|
const r = (node as { rotation?: unknown }).rotation
|
|
@@ -371,7 +387,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
371
387
|
maxElevation: supportCapRef.current,
|
|
372
388
|
})
|
|
373
389
|
},
|
|
374
|
-
[parentFrame, frameParent, node],
|
|
390
|
+
[parentFrame, frameParent, node, itemSurfaceMove],
|
|
375
391
|
)
|
|
376
392
|
const canonicalPositionFromPlan = useCallback(
|
|
377
393
|
(planX: number, localY: number, planZ: number): [number, number, number] =>
|
|
@@ -400,6 +416,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
400
416
|
// to settle a dragged run flush against a wall without forking the move tool.
|
|
401
417
|
const groupMoveSnapConfig =
|
|
402
418
|
nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null
|
|
419
|
+
const groupMoveSnapPoseConfig =
|
|
420
|
+
nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnapPose ?? null
|
|
421
|
+
const movableValidityConfig =
|
|
422
|
+
(nodeRegistry.get(node.type)?.capabilities?.movable as MovableConfig | undefined) ?? null
|
|
423
|
+
const gridSnapPositionConfig =
|
|
424
|
+
nodeRegistry.get(node.type)?.capabilities?.movable?.gridSnapPosition ?? null
|
|
403
425
|
// Mirrors of `valid` / Alt for the event handlers inside the effect, which
|
|
404
426
|
// can't read React state without stale closures.
|
|
405
427
|
const validRef = useRef(true)
|
|
@@ -411,15 +433,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
411
433
|
|
|
412
434
|
useEffect(() => {
|
|
413
435
|
useScene.temporal.getState().pause()
|
|
414
|
-
|
|
436
|
+
const movementSfx = createMovementSfxTick()
|
|
415
437
|
dragAnchorRef.current = null
|
|
416
438
|
hasMovedRef.current = false
|
|
417
439
|
rotationRef.current = originalRotationY
|
|
440
|
+
freeRotationRef.current = originalRotationY
|
|
441
|
+
attachmentRotationRef.current = null
|
|
418
442
|
altRef.current = false
|
|
419
443
|
validRef.current = true
|
|
420
444
|
// No pointer surface known yet — uncapped election (the node keeps its
|
|
421
445
|
// persisted host / committed elevation until the first grid move).
|
|
422
446
|
supportCapRef.current = null
|
|
447
|
+
supportSurfaceRef.current = null
|
|
423
448
|
// Re-sync the box transform to the (possibly new) node. `node` changes
|
|
424
449
|
// without this component remounting whenever a positioned preset re-arms a
|
|
425
450
|
// fresh clone after a drop, or the user picks a different catalog tile —
|
|
@@ -427,14 +452,22 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
427
452
|
// would keep the previous clone's rotation/position until the next R/T.
|
|
428
453
|
setCursorRotationY(previewRotationY(originalRotationY))
|
|
429
454
|
lastCursorRef.current = originalPosition
|
|
455
|
+
let floorY = originalPosition[1]
|
|
456
|
+
let centeredAfterHost = false
|
|
430
457
|
let committed = false
|
|
431
458
|
const isNew = isFreshPlacement
|
|
432
459
|
|
|
433
460
|
const baseRotation = (node as { rotation?: unknown }).rotation
|
|
434
|
-
const toCommitRotation = (y: number): number | [number, number, number] =>
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
:
|
|
461
|
+
const toCommitRotation = (y: number): number | [number, number, number] => {
|
|
462
|
+
const live = useScene.getState().nodes[node.id]
|
|
463
|
+
const rotation =
|
|
464
|
+
live && surfaceAttachmentId(live) && 'rotation' in live ? live.rotation : baseRotation
|
|
465
|
+
return Array.isArray(rotation) ? [rotation[0] ?? 0, y, rotation[2] ?? 0] : y
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const currentLevelId = () =>
|
|
469
|
+
useViewer.getState().selection.levelId ??
|
|
470
|
+
(itemSurfaceMove ? findLevelAncestorId(node.id, useScene.getState().nodes) : node.parentId)
|
|
438
471
|
|
|
439
472
|
const getVisualPosition = visualPositionFor
|
|
440
473
|
const applyMeshPose = (position: [number, number, number], rotationY = rotationRef.current) => {
|
|
@@ -445,7 +478,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
445
478
|
}
|
|
446
479
|
const markMovedNodeDirty = () => {
|
|
447
480
|
if (useScene.getState().nodes[node.id]) {
|
|
448
|
-
|
|
481
|
+
for (const id of cascadeDirty(node.id as AnyNodeId, { scene: createSceneApi(useScene) }))
|
|
482
|
+
useScene.getState().markDirty(id)
|
|
449
483
|
}
|
|
450
484
|
}
|
|
451
485
|
|
|
@@ -490,17 +524,46 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
490
524
|
|
|
491
525
|
const syncParentFramePreview = (position: [number, number, number]) => {
|
|
492
526
|
if (!frameParent) return
|
|
493
|
-
|
|
527
|
+
const entries: Array<readonly [AnyNodeId, Record<string, unknown>]> = [
|
|
528
|
+
[node.id as AnyNodeId, { position, rotation: rotationRef.current }],
|
|
529
|
+
]
|
|
530
|
+
const derivedEntries = parentFrame?.previewOverrides?.({
|
|
531
|
+
node,
|
|
532
|
+
parent: frameParent,
|
|
494
533
|
position,
|
|
495
|
-
|
|
534
|
+
sceneApi: createSceneApi(useScene),
|
|
496
535
|
})
|
|
497
|
-
|
|
536
|
+
if (derivedEntries) {
|
|
537
|
+
for (const [id, values] of derivedEntries) {
|
|
538
|
+
if (id === node.id) continue
|
|
539
|
+
entries.push([id, values as Record<string, unknown>])
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const nextIds = new Set(entries.map(([id]) => id))
|
|
544
|
+
for (const id of parentFramePreviewIdsRef.current) {
|
|
545
|
+
if (!nextIds.has(id)) useLiveNodeOverrides.getState().clear(id)
|
|
546
|
+
}
|
|
547
|
+
useLiveNodeOverrides.getState().setMany(entries)
|
|
548
|
+
parentFramePreviewIdsRef.current = [...nextIds]
|
|
549
|
+
|
|
550
|
+
const scene = useScene.getState()
|
|
551
|
+
for (const [id] of entries) {
|
|
552
|
+
if (scene.nodes[id]) scene.markDirty(id)
|
|
553
|
+
}
|
|
554
|
+
if (frameParent.id !== node.id) scene.markDirty(frameParent.id as AnyNodeId)
|
|
498
555
|
}
|
|
499
556
|
|
|
500
557
|
const clearParentFramePreview = () => {
|
|
501
558
|
if (!frameParent) return
|
|
502
|
-
|
|
503
|
-
useScene.getState()
|
|
559
|
+
const ids = new Set<AnyNodeId>([node.id as AnyNodeId, ...parentFramePreviewIdsRef.current])
|
|
560
|
+
const scene = useScene.getState()
|
|
561
|
+
for (const id of ids) {
|
|
562
|
+
useLiveNodeOverrides.getState().clear(id)
|
|
563
|
+
if (scene.nodes[id]) scene.markDirty(id)
|
|
564
|
+
}
|
|
565
|
+
parentFramePreviewIdsRef.current = []
|
|
566
|
+
scene.markDirty(frameParent.id as AnyNodeId)
|
|
504
567
|
}
|
|
505
568
|
|
|
506
569
|
setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
|
|
@@ -510,13 +573,36 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
510
573
|
// override so the user can drop on top of an existing item on purpose. Only
|
|
511
574
|
// shelves show the box, so this no-ops for every other movable kind.
|
|
512
575
|
const recomputeValidity = () => {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
576
|
+
const rejection = itemSurfaceMove?.rejection ?? null
|
|
577
|
+
setSurfaceRejection(rejection)
|
|
578
|
+
if (rejection) {
|
|
579
|
+
validRef.current = false
|
|
580
|
+
setValid(false)
|
|
581
|
+
return
|
|
582
|
+
}
|
|
583
|
+
if (!boxDimensions && !movableValidityConfig) return
|
|
584
|
+
if (altRef.current || itemSurfaceMove?.hosted) {
|
|
585
|
+
const valid = !itemSurfaceMove?.hosted || itemSurfaceMove.valid
|
|
586
|
+
validRef.current = valid
|
|
587
|
+
setValid(valid)
|
|
517
588
|
return
|
|
518
589
|
}
|
|
519
|
-
|
|
590
|
+
if (parentFrameCollides && frameParent && parentFrame?.isValidPosition) {
|
|
591
|
+
const candidate = {
|
|
592
|
+
...(node as Record<string, unknown>),
|
|
593
|
+
position: lastCursorRef.current,
|
|
594
|
+
} as AnyNode
|
|
595
|
+
const validPosition = parentFrame.isValidPosition({
|
|
596
|
+
node: candidate,
|
|
597
|
+
parent: frameParent,
|
|
598
|
+
position: lastCursorRef.current,
|
|
599
|
+
nodes: useScene.getState().nodes as Record<string, AnyNode>,
|
|
600
|
+
})
|
|
601
|
+
validRef.current = validPosition
|
|
602
|
+
setValid(validPosition)
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
const levelId = currentLevelId()
|
|
520
606
|
if (!levelId) {
|
|
521
607
|
validRef.current = true
|
|
522
608
|
setValid(true)
|
|
@@ -526,7 +612,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
526
612
|
const liveRotation = previewRotationY(rotationRef.current)
|
|
527
613
|
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
|
|
528
614
|
const effectiveNode = {
|
|
529
|
-
...(node as Record<string, unknown>),
|
|
615
|
+
...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
|
|
530
616
|
position: livePosition,
|
|
531
617
|
rotation: Array.isArray((node as { rotation?: unknown }).rotation)
|
|
532
618
|
? [
|
|
@@ -551,15 +637,27 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
551
637
|
const { valid: placeable } =
|
|
552
638
|
resolvedFootprints.length > 0
|
|
553
639
|
? spatialGridManager.canPlaceOnFloorFootprints(levelId, resolvedFootprints, [node.id])
|
|
554
|
-
:
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
640
|
+
: boxDimensions
|
|
641
|
+
? spatialGridManager.canPlaceOnFloor(
|
|
642
|
+
levelId,
|
|
643
|
+
getVisualPosition(livePosition),
|
|
644
|
+
boxDimensions,
|
|
645
|
+
[0, liveRotation, 0],
|
|
646
|
+
[node.id],
|
|
647
|
+
)
|
|
648
|
+
: { valid: true }
|
|
649
|
+
const kindValid = movableValidityConfig?.isValidPosition
|
|
650
|
+
? movableValidityConfig.isValidPosition({
|
|
651
|
+
node: effectiveNode,
|
|
652
|
+
position: livePosition,
|
|
653
|
+
rotation: rotationRef.current,
|
|
654
|
+
levelId: levelId as AnyNodeId | null,
|
|
655
|
+
nodes: useScene.getState().nodes as Record<string, AnyNode>,
|
|
656
|
+
})
|
|
657
|
+
: true
|
|
658
|
+
const positionValid = placeable && kindValid
|
|
659
|
+
validRef.current = positionValid
|
|
660
|
+
setValid(positionValid)
|
|
563
661
|
}
|
|
564
662
|
recomputeValidity()
|
|
565
663
|
|
|
@@ -571,10 +669,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
571
669
|
// passes through the moved mesh and continues to the grid plane,
|
|
572
670
|
// so `grid:move` keeps firing and the cursor tracks correctly.
|
|
573
671
|
// We restore the original raycast on cleanup.
|
|
574
|
-
const mesh = sceneRegistry.nodes.get(node.id)
|
|
575
672
|
const restoreRaycasts: Array<() => void> = []
|
|
576
|
-
|
|
577
|
-
|
|
673
|
+
const suppressed = new WeakSet<object>()
|
|
674
|
+
const suppressRaycasts = () => {
|
|
675
|
+
sceneRegistry.nodes.get(node.id)?.traverse((child) => {
|
|
676
|
+
if (suppressed.has(child)) return
|
|
677
|
+
suppressed.add(child)
|
|
578
678
|
const original = child.raycast
|
|
579
679
|
child.raycast = () => {}
|
|
580
680
|
restoreRaycasts.push(() => {
|
|
@@ -582,6 +682,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
582
682
|
})
|
|
583
683
|
})
|
|
584
684
|
}
|
|
685
|
+
// Reparenting remounts the renderer, so new meshes must also let the cursor ray through.
|
|
686
|
+
suppressRaycastsRef.current = suppressRaycasts
|
|
687
|
+
suppressRaycasts()
|
|
585
688
|
|
|
586
689
|
// Static alignment candidates — anchors of every OTHER alignable object
|
|
587
690
|
// (items, walls, fences, slabs, ceilings, columns) ON THE SAME LEVEL,
|
|
@@ -593,7 +696,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
593
696
|
const alignmentCandidates = collectAlignmentAnchors(
|
|
594
697
|
useScene.getState().nodes,
|
|
595
698
|
node.id,
|
|
596
|
-
|
|
699
|
+
currentLevelId(),
|
|
597
700
|
)
|
|
598
701
|
|
|
599
702
|
// Connectivity snapshot (existing port-bearing nodes only — fresh
|
|
@@ -606,7 +709,76 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
606
709
|
if (snapshot.connections.length > 0) connectivityRef.current = snapshot
|
|
607
710
|
}
|
|
608
711
|
|
|
712
|
+
const applySurfacePose = (pose: { position: [number, number, number]; rotationY: number }) => {
|
|
713
|
+
lastCursorRef.current = pose.position
|
|
714
|
+
rotationRef.current = pose.rotationY
|
|
715
|
+
freeRotationRef.current = pose.rotationY
|
|
716
|
+
attachmentRotationRef.current = null
|
|
717
|
+
centeredAfterHost = true
|
|
718
|
+
dragAnchorRef.current = null
|
|
719
|
+
hasMovedRef.current = true
|
|
720
|
+
revealFreshPlacement()
|
|
721
|
+
setCursorPosition(getVisualPosition(pose.position))
|
|
722
|
+
setCursorRotationY(previewRotationY(pose.rotationY))
|
|
723
|
+
useAlignmentGuides.getState().clear()
|
|
724
|
+
recomputeValidity()
|
|
725
|
+
}
|
|
726
|
+
const detachSurface = (
|
|
727
|
+
worldPosition: [number, number, number],
|
|
728
|
+
leaveEvent?: NodeEvent<AnyNode>,
|
|
729
|
+
) => {
|
|
730
|
+
const pose = leaveEvent
|
|
731
|
+
? itemSurfaceMove?.leave(leaveEvent, rotationRef.current)
|
|
732
|
+
: itemSurfaceMove?.detach(worldPosition, rotationRef.current)
|
|
733
|
+
if (!pose) return
|
|
734
|
+
floorY = 0
|
|
735
|
+
const pointed = resolvePointerSupportSurface(cameraRef.current, worldPosition)
|
|
736
|
+
supportCapRef.current = pointed?.elevation ?? null
|
|
737
|
+
supportSurfaceRef.current = pointed
|
|
738
|
+
applySurfacePose(pose)
|
|
739
|
+
applyMeshPose(pose.position)
|
|
740
|
+
}
|
|
741
|
+
let lastSurfaceEvent: NodeEvent<AnyNode> | null = null
|
|
742
|
+
const onItemMove = (event: NodeEvent<AnyNode>) => {
|
|
743
|
+
if (committed || !resolvedFootprint || !itemSurfaceMove) return
|
|
744
|
+
lastSurfaceEvent = event
|
|
745
|
+
const pose = itemSurfaceMove.enter(event, resolvedFootprint, rotationRef.current)
|
|
746
|
+
if (pose) {
|
|
747
|
+
applySurfacePose(pose)
|
|
748
|
+
// Compare floor and host movement in the same plan frame, never host-local storage.
|
|
749
|
+
const point = new Vector3(...pose.worldPosition)
|
|
750
|
+
const levelId = currentLevelId()
|
|
751
|
+
if (levelId) sceneRegistry.nodes.get(levelId)?.worldToLocal(point)
|
|
752
|
+
movementSfx.tick({
|
|
753
|
+
coords: [point.x, point.z],
|
|
754
|
+
gridSnapActive: isGridSnapActive(),
|
|
755
|
+
gridStep: useEditor.getState().gridSnapStep,
|
|
756
|
+
})
|
|
757
|
+
} else {
|
|
758
|
+
if (
|
|
759
|
+
itemSurfaceMove.hosted &&
|
|
760
|
+
(itemSurfaceMove.rejection === 'footprint-outside-surface' ||
|
|
761
|
+
itemSurfaceMove.rejection === 'footprint-exceeds-host' ||
|
|
762
|
+
itemSurfaceMove.rejection === 'no-surface')
|
|
763
|
+
)
|
|
764
|
+
detachSurface(event.position)
|
|
765
|
+
recomputeValidity()
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
const onItemLeave = (event: NodeEvent<AnyNode>) => {
|
|
769
|
+
if (!itemSurfaceMove?.hosted || committed) return
|
|
770
|
+
if (event.node.id !== useScene.getState().nodes[node.id]?.parentId) return
|
|
771
|
+
event.stopPropagation()
|
|
772
|
+
detachSurface(event.position, event)
|
|
773
|
+
}
|
|
774
|
+
|
|
609
775
|
const onGridMove = (event: GridEvent) => {
|
|
776
|
+
const blocked = Boolean(itemSurfaceMove?.blocksGrid(event, cameraRef.current))
|
|
777
|
+
if (!committed && !blocked) {
|
|
778
|
+
itemSurfaceMove?.clearRejectionForGrid(event)
|
|
779
|
+
detachSurface(event.position)
|
|
780
|
+
}
|
|
781
|
+
if (committed || blocked) return
|
|
610
782
|
// The pointer decides the target surface AND the cursor plan point,
|
|
611
783
|
// both resolved from the true camera ray in one place. The event's
|
|
612
784
|
// own hit can't be used directly: its plane rides at the ghost's
|
|
@@ -620,20 +792,99 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
620
792
|
// single fixed point per pointer ray.
|
|
621
793
|
const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
|
|
622
794
|
supportCapRef.current = pointed?.elevation ?? null
|
|
795
|
+
supportSurfaceRef.current = pointed
|
|
623
796
|
const rawX = pointed?.localPoint?.[0] ?? event.localPosition[0]
|
|
624
797
|
const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2]
|
|
625
798
|
revealFreshPlacement()
|
|
626
799
|
|
|
627
|
-
const
|
|
800
|
+
const magnetic = isMagneticSnapActive()
|
|
801
|
+
const attachmentEnabled = magnetic || isGridSnapActive()
|
|
802
|
+
const absolute = useAbsoluteCursorPlacement || cursorAttached || centeredAfterHost
|
|
803
|
+
const centerOffset: [number, number, number] =
|
|
804
|
+
absolute && dragBounds?.center
|
|
805
|
+
? offsetPlanPositionByLocalCenter(
|
|
806
|
+
[0, 0, 0],
|
|
807
|
+
dragBounds.center,
|
|
808
|
+
previewRotationY(freeRotationRef.current),
|
|
809
|
+
)
|
|
810
|
+
: [0, 0, 0]
|
|
811
|
+
let attachmentRotationY: number | null = null
|
|
812
|
+
const resolved = resolvePrioritizedPlanarCursorPosition({
|
|
628
813
|
cursor: [rawX, rawZ],
|
|
629
814
|
original: [originalPlanPosition[0], originalPlanPosition[2]],
|
|
630
815
|
anchor: dragAnchorRef.current,
|
|
631
|
-
mode:
|
|
816
|
+
mode: absolute ? 'absolute' : 'relative',
|
|
817
|
+
localCenter: dragBounds?.center,
|
|
818
|
+
rotationY: previewRotationY(freeRotationRef.current),
|
|
632
819
|
// Snap follows the mode (raw in Off via snapToGridStep); Alt = force only.
|
|
633
|
-
snap: snapToGridStep,
|
|
820
|
+
snap: gridSnapPositionConfig ? undefined : snapToGridStep,
|
|
821
|
+
snapPoint:
|
|
822
|
+
isGridSnapActive() && gridSnapPositionConfig
|
|
823
|
+
? ([planX, planZ]) => {
|
|
824
|
+
const snappedPosition = gridSnapPositionConfig({
|
|
825
|
+
node,
|
|
826
|
+
// Kind-owned grid hooks exchange origins and apply their own footprint offsets.
|
|
827
|
+
candidatePosition: canonicalPositionFromPlan(
|
|
828
|
+
planX - centerOffset[0],
|
|
829
|
+
floorY,
|
|
830
|
+
planZ - centerOffset[2],
|
|
831
|
+
),
|
|
832
|
+
candidateRotation: freeRotationRef.current,
|
|
833
|
+
movingIds: [node.id as AnyNodeId],
|
|
834
|
+
nodes: useScene.getState().nodes as Record<string, AnyNode>,
|
|
835
|
+
levelId: (currentLevelId() as AnyNodeId | null) ?? null,
|
|
836
|
+
gridStep: useEditor.getState().gridSnapStep,
|
|
837
|
+
})
|
|
838
|
+
const snappedPlanPosition = getVisualPosition(
|
|
839
|
+
snappedPosition,
|
|
840
|
+
freeRotationRef.current,
|
|
841
|
+
)
|
|
842
|
+
return [
|
|
843
|
+
snappedPlanPosition[0] + centerOffset[0],
|
|
844
|
+
snappedPlanPosition[2] + centerOffset[2],
|
|
845
|
+
]
|
|
846
|
+
}
|
|
847
|
+
: undefined,
|
|
848
|
+
resolveAttachment:
|
|
849
|
+
attachmentEnabled && (groupMoveSnapPoseConfig || groupMoveSnapConfig)
|
|
850
|
+
? ([planX, planZ]) => {
|
|
851
|
+
const snapArgs: Parameters<NonNullable<typeof groupMoveSnapPoseConfig>>[0] = {
|
|
852
|
+
node,
|
|
853
|
+
candidatePosition: canonicalPositionFromPlan(planX, floorY, planZ),
|
|
854
|
+
candidateRotation:
|
|
855
|
+
absolute && dragBounds?.center ? freeRotationRef.current : rotationRef.current,
|
|
856
|
+
movingIds: [node.id as AnyNodeId],
|
|
857
|
+
nodes: useScene.getState().nodes as Record<string, AnyNode>,
|
|
858
|
+
levelId: (currentLevelId() as AnyNodeId | null) ?? null,
|
|
859
|
+
}
|
|
860
|
+
const snappedPosition: GroupMoveSnapResult | null = groupMoveSnapPoseConfig
|
|
861
|
+
? groupMoveSnapPoseConfig(snapArgs)
|
|
862
|
+
: (() => {
|
|
863
|
+
const position = groupMoveSnapConfig?.(snapArgs)
|
|
864
|
+
return position ? { position } : null
|
|
865
|
+
})()
|
|
866
|
+
if (!snappedPosition) return null
|
|
867
|
+
attachmentRotationY = snappedPosition.rotation ?? null
|
|
868
|
+
const snappedPlanPosition = getVisualPosition(
|
|
869
|
+
snappedPosition.position,
|
|
870
|
+
snappedPosition.rotation ?? rotationRef.current,
|
|
871
|
+
)
|
|
872
|
+
return [snappedPlanPosition[0], snappedPlanPosition[2]]
|
|
873
|
+
}
|
|
874
|
+
: undefined,
|
|
634
875
|
})
|
|
635
876
|
dragAnchorRef.current = resolved.anchor
|
|
636
877
|
let [x, z] = resolved.point
|
|
878
|
+
const attachmentSnapped = resolved.attachmentSnapped
|
|
879
|
+
attachmentRotationRef.current = attachmentSnapped ? attachmentRotationY : null
|
|
880
|
+
const nextRotationY = resolveAttachmentPreviewRotation(
|
|
881
|
+
freeRotationRef.current,
|
|
882
|
+
attachmentRotationRef.current,
|
|
883
|
+
)
|
|
884
|
+
if (nextRotationY !== rotationRef.current) {
|
|
885
|
+
rotationRef.current = nextRotationY
|
|
886
|
+
setCursorRotationY(previewRotationY(nextRotationY))
|
|
887
|
+
}
|
|
637
888
|
|
|
638
889
|
// Figma-style alignment snap layered on top of grid snap: when the
|
|
639
890
|
// moving item's edge lines up (on X or Z) with another item's edge,
|
|
@@ -642,8 +893,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
642
893
|
// point. Alignment "lines" are DISPLAYED in every mode except Off
|
|
643
894
|
// (isAlignmentGuideActive); the magnetic pull toward them applies only in
|
|
644
895
|
// 'lines' mode (magnetic). Alt is force-place, not a snap bypass.
|
|
645
|
-
|
|
646
|
-
if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
|
|
896
|
+
if (!attachmentSnapped && isAlignmentGuideActive() && alignmentCandidates.length > 0) {
|
|
647
897
|
const result = resolveAlignment({
|
|
648
898
|
moving: movingDragBoundsAnchors(
|
|
649
899
|
node,
|
|
@@ -664,25 +914,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
664
914
|
useAlignmentGuides.getState().clear()
|
|
665
915
|
}
|
|
666
916
|
|
|
667
|
-
// Kind-owned attachment snap (cabinet → wall): an attach behavior like
|
|
668
|
-
// door/window wall placement, not an alignment guide — active in every
|
|
669
|
-
// snapping mode except Off.
|
|
670
|
-
if ((magnetic || isGridSnapActive()) && groupMoveSnapConfig) {
|
|
671
|
-
const snappedPosition = groupMoveSnapConfig({
|
|
672
|
-
node,
|
|
673
|
-
candidatePosition: canonicalPositionFromPlan(x, originalPosition[1], z),
|
|
674
|
-
movingIds: [node.id as AnyNodeId],
|
|
675
|
-
nodes: useScene.getState().nodes as Record<string, AnyNode>,
|
|
676
|
-
levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? null,
|
|
677
|
-
})
|
|
678
|
-
if (snappedPosition) {
|
|
679
|
-
const snappedPlanPosition = getVisualPosition(snappedPosition)
|
|
680
|
-
x = snappedPlanPosition[0]
|
|
681
|
-
z = snappedPlanPosition[2]
|
|
682
|
-
useAlignmentGuides.getState().clear()
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
917
|
// Magnetic port snap (duct terminals): mate a collar onto a nearby
|
|
687
918
|
// duct run end. Takes precedence over grid / alignment snap; Alt
|
|
688
919
|
// bypasses. Only kinds that opted in via `movable.portSnap`.
|
|
@@ -700,8 +931,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
700
931
|
}
|
|
701
932
|
}
|
|
702
933
|
|
|
703
|
-
let position = canonicalPositionFromPlan(x,
|
|
704
|
-
if (
|
|
934
|
+
let position = canonicalPositionFromPlan(x, floorY, z)
|
|
935
|
+
if (
|
|
936
|
+
!attachmentSnapped &&
|
|
937
|
+
(magnetic || isGridSnapActive()) &&
|
|
938
|
+
parentFrame?.magneticSnap &&
|
|
939
|
+
frameParent
|
|
940
|
+
) {
|
|
705
941
|
const preSnapPosition = position
|
|
706
942
|
const snappedPosition = parentFrame.magneticSnap(
|
|
707
943
|
node,
|
|
@@ -732,6 +968,24 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
732
968
|
if (guides.length > 0) useAlignmentGuides.getState().set(guides)
|
|
733
969
|
}
|
|
734
970
|
}
|
|
971
|
+
if (!parentFrame && pointed?.sourceNodeId) {
|
|
972
|
+
const rotation = toCommitRotation(rotationRef.current)
|
|
973
|
+
const effectiveNode = {
|
|
974
|
+
...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
|
|
975
|
+
position,
|
|
976
|
+
rotation,
|
|
977
|
+
} as AnyNode
|
|
978
|
+
position = resolveFrozenFloorPlacementPatch(
|
|
979
|
+
effectiveNode,
|
|
980
|
+
{ ...useScene.getState().nodes, [node.id]: effectiveNode },
|
|
981
|
+
{
|
|
982
|
+
position,
|
|
983
|
+
rotation,
|
|
984
|
+
elevation: pointed.elevation,
|
|
985
|
+
preferredSlabId: pointed.supportSlabId,
|
|
986
|
+
},
|
|
987
|
+
).position
|
|
988
|
+
}
|
|
735
989
|
const visualPosition = getVisualPosition(position)
|
|
736
990
|
hasMovedRef.current = true
|
|
737
991
|
setCursorPosition(visualPosition)
|
|
@@ -762,15 +1016,26 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
762
1016
|
// Carry connected ductwork along (preview only — committed on drop).
|
|
763
1017
|
previewConnectivity(position, rotationRef.current)
|
|
764
1018
|
|
|
765
|
-
const
|
|
1019
|
+
const soundStep = {
|
|
766
1020
|
coords: [x, z],
|
|
767
|
-
gridSnapActive: isGridSnapActive(),
|
|
1021
|
+
gridSnapActive: isGridSnapActive() && !attachmentSnapped,
|
|
768
1022
|
gridStep: useEditor.getState().gridSnapStep,
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1023
|
+
}
|
|
1024
|
+
if (itemSurfaceMove) {
|
|
1025
|
+
movementSfx.schedule(
|
|
1026
|
+
soundStep,
|
|
1027
|
+
() => !itemSurfaceMove.blocksGrid(event, cameraRef.current),
|
|
1028
|
+
true,
|
|
1029
|
+
)
|
|
1030
|
+
} else movementSfx.tick(soundStep, true)
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const gridDispatch = createItemSurfaceGridDispatch(onGridMove)
|
|
1034
|
+
const receiveGridMove = (event: GridEvent) => {
|
|
1035
|
+
if (itemSurfaceMove?.hosted) gridDispatch.schedule(event)
|
|
1036
|
+
else {
|
|
1037
|
+
gridDispatch.cancel()
|
|
1038
|
+
onGridMove(event)
|
|
774
1039
|
}
|
|
775
1040
|
}
|
|
776
1041
|
|
|
@@ -790,8 +1055,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
790
1055
|
* AND scene updated) — never the original.
|
|
791
1056
|
*/
|
|
792
1057
|
const commitAtCursor = (event: ClickTriggerEvent) => {
|
|
793
|
-
// One physical click can reach here twice: node
|
|
794
|
-
//
|
|
1058
|
+
// One physical click can reach here twice: `node:click` is synthesized
|
|
1059
|
+
// on *pointerup* (`use-node-events`),
|
|
795
1060
|
// while `grid:click` rides the browser's native *click* event from a
|
|
796
1061
|
// canvas DOM listener (`use-grid-events`) that deliberately ignores
|
|
797
1062
|
// stopPropagation — and this effect stays subscribed until React
|
|
@@ -799,6 +1064,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
799
1064
|
// finds the fresh draft already deleted and takes the orphan re-create
|
|
800
1065
|
// path below, minting a hidden ghost copy and replaying the SFX.
|
|
801
1066
|
if (committed) return
|
|
1067
|
+
gridDispatch.flush()
|
|
1068
|
+
movementSfx.flush()
|
|
1069
|
+
if (itemSurfaceMove?.rejection || (itemSurfaceMove?.hosted && !itemSurfaceMove.valid)) return
|
|
802
1070
|
// Ignore a commit that fires before the cursor has moved into place —
|
|
803
1071
|
// it's the stray trailing click of whatever armed this move, not a
|
|
804
1072
|
// deliberate drop. Prevents preset re-arm from double-placing.
|
|
@@ -814,11 +1082,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
814
1082
|
|
|
815
1083
|
if (useScene.getState().nodes[node.id]) {
|
|
816
1084
|
const effectiveNode = {
|
|
817
|
-
...(node as Record<string, unknown>),
|
|
1085
|
+
...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
|
|
818
1086
|
position,
|
|
819
1087
|
rotation,
|
|
820
1088
|
} as AnyNode
|
|
821
1089
|
const data = {
|
|
1090
|
+
...(itemSurfaceMove ? { parentId: effectiveNode.parentId } : {}),
|
|
822
1091
|
position,
|
|
823
1092
|
rotation,
|
|
824
1093
|
// The pointer cap makes the persisted host reproduce the capped
|
|
@@ -830,7 +1099,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
830
1099
|
...useScene.getState().nodes,
|
|
831
1100
|
[node.id]: effectiveNode,
|
|
832
1101
|
},
|
|
833
|
-
{
|
|
1102
|
+
{
|
|
1103
|
+
maxElevation: supportCapRef.current,
|
|
1104
|
+
preferredSlabId: supportSurfaceRef.current?.supportSlabId,
|
|
1105
|
+
pinSupport: supportSurfaceRef.current?.sourceNodeId != null,
|
|
1106
|
+
},
|
|
834
1107
|
),
|
|
835
1108
|
...(isNew
|
|
836
1109
|
? {
|
|
@@ -855,10 +1128,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
855
1128
|
buildPreviewNode(position, rotationRef.current),
|
|
856
1129
|
).filter((u) => useScene.getState().nodes[u.id])
|
|
857
1130
|
: []
|
|
1131
|
+
const surfaceId = surfaceAttachmentId(effectiveNode)
|
|
1132
|
+
itemSurfaceMove?.restore()
|
|
858
1133
|
useScene.temporal.getState().resume()
|
|
859
1134
|
useScene
|
|
860
1135
|
.getState()
|
|
861
|
-
.updateNodes([
|
|
1136
|
+
.updateNodes([
|
|
1137
|
+
{ id: node.id as AnyNodeId, data },
|
|
1138
|
+
...surfaceAttachmentUpdates(node.id, effectiveNode.parentId, surfaceId),
|
|
1139
|
+
...connectivityUpdates,
|
|
1140
|
+
])
|
|
862
1141
|
// Kind-owned derived-state maintenance after a parent-frame move
|
|
863
1142
|
// (cabinet run re-flow + linked corner-run re-anchor). Runs in the
|
|
864
1143
|
// resumed window so its writes are undoable alongside the move.
|
|
@@ -902,7 +1181,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
902
1181
|
...useScene.getState().nodes,
|
|
903
1182
|
[reparsed.id]: reparsed,
|
|
904
1183
|
},
|
|
905
|
-
{
|
|
1184
|
+
{
|
|
1185
|
+
maxElevation: supportCapRef.current,
|
|
1186
|
+
preferredSlabId: supportSurfaceRef.current?.supportSlabId,
|
|
1187
|
+
pinSupport: supportSurfaceRef.current?.sourceNodeId != null,
|
|
1188
|
+
},
|
|
906
1189
|
),
|
|
907
1190
|
}) as AnyNode
|
|
908
1191
|
useScene.temporal.getState().resume()
|
|
@@ -961,24 +1244,54 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
961
1244
|
else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
|
|
962
1245
|
else return
|
|
963
1246
|
e.preventDefault()
|
|
1247
|
+
const nextFreeRotation = resolveMoveRotationStep(
|
|
1248
|
+
freeRotationRef.current,
|
|
1249
|
+
delta,
|
|
1250
|
+
attachmentRotationRef.current,
|
|
1251
|
+
)
|
|
1252
|
+
if (nextFreeRotation === null) return
|
|
964
1253
|
sfxEmitter.emit('sfx:item-rotate')
|
|
965
|
-
|
|
1254
|
+
const hostedPose = itemSurfaceMove?.rotate(nextFreeRotation)
|
|
1255
|
+
let position = hostedPose?.position ?? lastCursorRef.current
|
|
1256
|
+
if (
|
|
1257
|
+
!hostedPose &&
|
|
1258
|
+
hasMovedRef.current &&
|
|
1259
|
+
(useAbsoluteCursorPlacement || cursorAttached) &&
|
|
1260
|
+
dragBounds?.center
|
|
1261
|
+
) {
|
|
1262
|
+
const planCenter = offsetPlanPositionByLocalCenter(
|
|
1263
|
+
getVisualPosition(position),
|
|
1264
|
+
dragBounds.center,
|
|
1265
|
+
previewRotationY(rotationRef.current),
|
|
1266
|
+
)
|
|
1267
|
+
const planOrigin = offsetPlanPositionByLocalCenter(
|
|
1268
|
+
planCenter,
|
|
1269
|
+
[-dragBounds.center[0], 0, -dragBounds.center[2]],
|
|
1270
|
+
previewRotationY(nextFreeRotation),
|
|
1271
|
+
)
|
|
1272
|
+
position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2])
|
|
1273
|
+
}
|
|
1274
|
+
lastCursorRef.current = position
|
|
1275
|
+
freeRotationRef.current = hostedPose?.rotationY ?? nextFreeRotation
|
|
1276
|
+
rotationRef.current = freeRotationRef.current
|
|
966
1277
|
setCursorRotationY(previewRotationY(rotationRef.current))
|
|
967
|
-
const position = lastCursorRef.current
|
|
968
1278
|
const visualPosition = getVisualPosition(position)
|
|
969
1279
|
setCursorPosition(visualPosition)
|
|
970
1280
|
applyMeshPose(position)
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1281
|
+
if (!itemSurfaceMove?.hosted)
|
|
1282
|
+
useLiveTransforms.getState().set(node.id, {
|
|
1283
|
+
position,
|
|
1284
|
+
rotation: rotationRef.current,
|
|
1285
|
+
supportElevationCap: supportCapRef.current ?? undefined,
|
|
1286
|
+
})
|
|
976
1287
|
syncParentFramePreview(position)
|
|
977
1288
|
markMovedNodeDirty()
|
|
978
1289
|
// Rotating the fitting swings its collars — connected ducts follow.
|
|
979
1290
|
previewConnectivity(position, rotationRef.current)
|
|
980
1291
|
// Rotation changes the footprint's collision span — re-check validity.
|
|
981
|
-
|
|
1292
|
+
if (!itemSurfaceMove?.hosted && itemSurfaceMove?.rejection && lastSurfaceEvent)
|
|
1293
|
+
onItemMove(lastSurfaceEvent)
|
|
1294
|
+
else recomputeValidity()
|
|
982
1295
|
}
|
|
983
1296
|
const onKeyUp = (e: KeyboardEvent) => {
|
|
984
1297
|
if (e.key === 'Alt') {
|
|
@@ -989,7 +1302,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
989
1302
|
window.addEventListener('keydown', onKeyDown)
|
|
990
1303
|
window.addEventListener('keyup', onKeyUp)
|
|
991
1304
|
|
|
992
|
-
|
|
1305
|
+
const surfaceKinds = Array.from(nodeRegistry.entries(), ([kind]) => kind).filter(
|
|
1306
|
+
(kind) => !NON_PHYSICAL_HOST_KINDS.includes(kind),
|
|
1307
|
+
)
|
|
1308
|
+
for (const kind of surfaceKinds) {
|
|
1309
|
+
emitter.on(`${kind}:enter` as never, onItemMove)
|
|
1310
|
+
emitter.on(`${kind}:move` as never, onItemMove)
|
|
1311
|
+
emitter.on(`${kind}:leave` as never, onItemLeave)
|
|
1312
|
+
emitter.on(`${kind}:click` as never, commitAtCursor)
|
|
1313
|
+
}
|
|
1314
|
+
emitter.on('grid:move', receiveGridMove)
|
|
993
1315
|
emitter.on('grid:click', commitAtCursor)
|
|
994
1316
|
|
|
995
1317
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
|
@@ -1007,23 +1329,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
1007
1329
|
}
|
|
1008
1330
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
|
1009
1331
|
|
|
1010
|
-
|
|
1011
|
-
// `${kind}:click` as a fixed union so the cast is safe at runtime —
|
|
1012
|
-
// we're just routing them through the shared commit path.
|
|
1013
|
-
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
|
|
1014
|
-
type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
|
|
1015
|
-
for (const kind of CLICK_TRIGGER_KINDS) {
|
|
1016
|
-
const key = `${kind}:click` as ClickKey
|
|
1017
|
-
emitter.on(key, commitAtCursor as never)
|
|
1018
|
-
}
|
|
1332
|
+
emitter.on('node:click', commitAtCursor)
|
|
1019
1333
|
|
|
1020
1334
|
const onCancel = () => {
|
|
1335
|
+
movementSfx.cancel()
|
|
1336
|
+
gridDispatch.cancel()
|
|
1021
1337
|
useLiveTransforms.getState().clear(node.id)
|
|
1022
1338
|
clearConnectivityOverrides()
|
|
1023
1339
|
clearParentFramePreview()
|
|
1024
1340
|
if (isNew) {
|
|
1341
|
+
updateSurfaceNode(node.id, {}, null)
|
|
1025
1342
|
useScene.getState().deleteNode(node.id as AnyNodeId)
|
|
1026
1343
|
} else {
|
|
1344
|
+
itemSurfaceMove?.restore()
|
|
1027
1345
|
applyMeshPose(originalPosition, originalRotationY)
|
|
1028
1346
|
markMovedNodeDirty()
|
|
1029
1347
|
}
|
|
@@ -1035,15 +1353,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
1035
1353
|
emitter.on('tool:cancel', onCancel)
|
|
1036
1354
|
|
|
1037
1355
|
return () => {
|
|
1356
|
+
movementSfx.cancel()
|
|
1357
|
+
gridDispatch.cancel()
|
|
1038
1358
|
window.removeEventListener('keydown', onKeyDown)
|
|
1039
1359
|
window.removeEventListener('keyup', onKeyUp)
|
|
1040
|
-
|
|
1360
|
+
suppressRaycastsRef.current = null
|
|
1361
|
+
for (const kind of surfaceKinds) {
|
|
1362
|
+
emitter.off(`${kind}:enter` as never, onItemMove)
|
|
1363
|
+
emitter.off(`${kind}:move` as never, onItemMove)
|
|
1364
|
+
emitter.off(`${kind}:leave` as never, onItemLeave)
|
|
1365
|
+
emitter.off(`${kind}:click` as never, commitAtCursor)
|
|
1366
|
+
}
|
|
1367
|
+
emitter.off('grid:move', receiveGridMove)
|
|
1041
1368
|
emitter.off('grid:click', commitAtCursor)
|
|
1042
1369
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
|
1043
|
-
|
|
1044
|
-
const key = `${kind}:click` as ClickKey
|
|
1045
|
-
emitter.off(key, commitAtCursor as never)
|
|
1046
|
-
}
|
|
1370
|
+
emitter.off('node:click', commitAtCursor)
|
|
1047
1371
|
emitter.off('tool:cancel', onCancel)
|
|
1048
1372
|
// Restore the moved meshes' raycast so they're hoverable / selectable
|
|
1049
1373
|
// again after the drag ends.
|
|
@@ -1052,10 +1376,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
1052
1376
|
// unmount / commit paths uniformly.
|
|
1053
1377
|
useAlignmentGuides.getState().clear()
|
|
1054
1378
|
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
|
|
1055
|
-
if (!
|
|
1379
|
+
if (!committed && !finalisedBy2D && (!isNew || itemSurfaceMove)) {
|
|
1056
1380
|
useLiveTransforms.getState().clear(node.id)
|
|
1057
1381
|
clearConnectivityOverrides()
|
|
1058
1382
|
clearParentFramePreview()
|
|
1383
|
+
itemSurfaceMove?.restore()
|
|
1059
1384
|
applyMeshPose(originalPosition, originalRotationY)
|
|
1060
1385
|
markMovedNodeDirty()
|
|
1061
1386
|
}
|
|
@@ -1063,13 +1388,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
1063
1388
|
}
|
|
1064
1389
|
}, [
|
|
1065
1390
|
boxDimensions,
|
|
1391
|
+
resolvedFootprint,
|
|
1392
|
+
itemSurfaceMove,
|
|
1066
1393
|
dragBounds,
|
|
1067
1394
|
canonicalPositionFromPlan,
|
|
1068
1395
|
parentFrame,
|
|
1069
1396
|
frameParent,
|
|
1397
|
+
parentFrameCollides,
|
|
1070
1398
|
cursorAttached,
|
|
1071
1399
|
portSnapConfig,
|
|
1072
1400
|
groupMoveSnapConfig,
|
|
1401
|
+
groupMoveSnapPoseConfig,
|
|
1402
|
+
movableValidityConfig,
|
|
1403
|
+
gridSnapPositionConfig,
|
|
1073
1404
|
exitMoveMode,
|
|
1074
1405
|
isFreshPlacement,
|
|
1075
1406
|
node,
|
|
@@ -1104,12 +1435,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
1104
1435
|
|
|
1105
1436
|
if (boxDimensions && !dragBounds?.center) {
|
|
1106
1437
|
return (
|
|
1107
|
-
<
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1438
|
+
<group ref={previewGroupRef}>
|
|
1439
|
+
<PlacementBox
|
|
1440
|
+
dimensions={boxDimensions}
|
|
1441
|
+
position={cursorPosition}
|
|
1442
|
+
rotationY={cursorRotationY}
|
|
1443
|
+
valid={valid}
|
|
1444
|
+
/>
|
|
1445
|
+
</group>
|
|
1113
1446
|
)
|
|
1114
1447
|
}
|
|
1115
1448
|
|
|
@@ -1119,17 +1452,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|
|
1119
1452
|
: cursorPosition
|
|
1120
1453
|
|
|
1121
1454
|
return (
|
|
1122
|
-
|
|
1455
|
+
<group ref={previewGroupRef}>
|
|
1123
1456
|
<CursorSphere color="#a78bfa" height={2.5} position={dragCenterPosition} />
|
|
1124
1457
|
<DragBoundingBox
|
|
1125
1458
|
center={dragBounds?.center}
|
|
1126
1459
|
centerY={dragBounds?.centerY}
|
|
1127
|
-
color={
|
|
1460
|
+
color={
|
|
1461
|
+
surfaceRejection
|
|
1462
|
+
? INVALID_COLOR
|
|
1463
|
+
: boxDimensions
|
|
1464
|
+
? valid
|
|
1465
|
+
? VALID_COLOR
|
|
1466
|
+
: INVALID_COLOR
|
|
1467
|
+
: undefined
|
|
1468
|
+
}
|
|
1128
1469
|
nodeId={node.id}
|
|
1129
1470
|
position={cursorPosition}
|
|
1130
1471
|
rotationY={cursorRotationY}
|
|
1131
1472
|
size={dragBounds?.size}
|
|
1132
1473
|
/>
|
|
1133
|
-
|
|
1474
|
+
</group>
|
|
1134
1475
|
)
|
|
1135
1476
|
}
|