@pascal-app/editor 0.9.1 → 0.9.2
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 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/level-name.ts +0 -11
|
@@ -2,13 +2,11 @@ import {
|
|
|
2
2
|
type AnyNode,
|
|
3
3
|
type AnyNodeId,
|
|
4
4
|
type BuildingNode,
|
|
5
|
-
|
|
6
|
-
type ColumnNode,
|
|
5
|
+
createSceneApi,
|
|
7
6
|
emitter,
|
|
8
|
-
type
|
|
7
|
+
type GridEvent,
|
|
9
8
|
getEffectiveRoofSurfaceMaterial,
|
|
10
9
|
getEffectiveSegmentSurfaceMaterial,
|
|
11
|
-
getMaterialPresetByRef,
|
|
12
10
|
getRoofSegmentSurfaceY,
|
|
13
11
|
getSelectableKinds,
|
|
14
12
|
type ItemNode,
|
|
@@ -20,44 +18,69 @@ import {
|
|
|
20
18
|
type RoofSegmentEvent,
|
|
21
19
|
type RoofSegmentNode,
|
|
22
20
|
resolveLevelId,
|
|
23
|
-
resolveMaterial,
|
|
24
|
-
type ShelfNode,
|
|
25
|
-
type SlabNode,
|
|
26
21
|
type StairEvent,
|
|
27
|
-
type StairNode,
|
|
28
22
|
type StairSegmentEvent,
|
|
29
23
|
type StairSurfaceMaterialRole,
|
|
30
24
|
sceneRegistry,
|
|
25
|
+
useLiveNodeOverrides,
|
|
31
26
|
useScene,
|
|
32
27
|
} from '@pascal-app/core'
|
|
33
28
|
|
|
34
29
|
import {
|
|
35
|
-
applyMaterialPresetToMaterials,
|
|
36
30
|
createMaterial,
|
|
37
31
|
createMaterialFromPresetRef,
|
|
38
32
|
getRoofMaterialArray,
|
|
39
|
-
getStairBodyMaterials,
|
|
40
|
-
getStairRailingMaterial,
|
|
41
33
|
useViewer,
|
|
42
34
|
} from '@pascal-app/viewer'
|
|
35
|
+
import { useThree } from '@react-three/fiber'
|
|
43
36
|
import { useCallback, useEffect, useRef } from 'react'
|
|
44
37
|
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
|
|
38
|
+
import {
|
|
39
|
+
canDirectMoveNode,
|
|
40
|
+
canDirectRotateNode,
|
|
41
|
+
resolveDirectRotationDragDelta,
|
|
42
|
+
resolveDirectRotationPatch,
|
|
43
|
+
} from '../../lib/direct-manipulation'
|
|
44
|
+
import { createEditorApi } from '../../lib/editor-api'
|
|
45
45
|
import {
|
|
46
46
|
type ActivePaintMaterial,
|
|
47
47
|
buildRoofSegmentSurfaceMaterialPatch,
|
|
48
48
|
buildRoofSurfaceMaterialPatch,
|
|
49
|
-
buildSingleSurfaceMaterialPatch,
|
|
50
|
-
buildStairSurfaceMaterialPatch,
|
|
51
49
|
hasActivePaintMaterial,
|
|
52
50
|
resolveActivePaintMaterialFromSelection,
|
|
53
51
|
} from '../../lib/material-paint'
|
|
54
|
-
import {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
type
|
|
59
|
-
|
|
60
|
-
|
|
52
|
+
import {
|
|
53
|
+
availablePaintScopes,
|
|
54
|
+
commitPaintScopeFanout,
|
|
55
|
+
nodeSlotRoles,
|
|
56
|
+
type PaintHoverInfo,
|
|
57
|
+
resolvePaintScopeTargets,
|
|
58
|
+
slotDisplayLabel,
|
|
59
|
+
type WallPaintHit,
|
|
60
|
+
} from '../../lib/paint-scope'
|
|
61
|
+
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
|
|
62
|
+
import {
|
|
63
|
+
resolveCanvasSelectionNode,
|
|
64
|
+
resolveNodeSelectionTarget,
|
|
65
|
+
resolveSelectedIdsForNodeClick,
|
|
66
|
+
type SelectionModifierKeys,
|
|
67
|
+
selectionModifiersFromEvent,
|
|
68
|
+
shouldPreserveSelectedRoofHostTarget,
|
|
69
|
+
} from '../../lib/selection-routing'
|
|
70
|
+
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
|
71
|
+
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
|
72
|
+
import useEditor, { type MaterialTargetRole } from './../../store/use-editor'
|
|
73
|
+
import useInteractionScope, {
|
|
74
|
+
getEditingHole,
|
|
75
|
+
getMovingNode,
|
|
76
|
+
useIsCurveReshape,
|
|
77
|
+
useMovingNode,
|
|
78
|
+
} from '../../store/use-interaction-scope'
|
|
79
|
+
import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
|
80
|
+
import { armGroupMove3d } from './group-move-3d'
|
|
81
|
+
import { classifyParticipant } from './group-transform-shared'
|
|
82
|
+
import { swallowNextClick } from './node-arrow-handles'
|
|
83
|
+
import { setEditorThreeContext } from './three-context-bridge'
|
|
61
84
|
|
|
62
85
|
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
|
63
86
|
// Elevators are building-scoped, so they stay selectable across level filters.
|
|
@@ -86,11 +109,6 @@ type SelectableNodeType =
|
|
|
86
109
|
| 'window'
|
|
87
110
|
| 'door'
|
|
88
111
|
|
|
89
|
-
type ModifierKeys = {
|
|
90
|
-
meta: boolean
|
|
91
|
-
ctrl: boolean
|
|
92
|
-
}
|
|
93
|
-
|
|
94
112
|
type PaintPreviewCleanup = () => void
|
|
95
113
|
|
|
96
114
|
type PaintInteraction = {
|
|
@@ -99,18 +117,40 @@ type PaintInteraction = {
|
|
|
99
117
|
hoverMode: HoverHighlightMode
|
|
100
118
|
hoveredId: AnyNodeId
|
|
101
119
|
preview: (() => PaintPreviewCleanup | null) | null
|
|
120
|
+
// What the paint HUD chip should show for this hover (scopes + labels), or
|
|
121
|
+
// null when the surface isn't paintable.
|
|
122
|
+
paintHover: PaintHoverInfo | null
|
|
102
123
|
}
|
|
103
124
|
|
|
104
125
|
interface SelectionStrategy {
|
|
105
126
|
types: SelectableNodeType[]
|
|
106
|
-
handleSelect: (
|
|
127
|
+
handleSelect: (
|
|
128
|
+
node: AnyNode,
|
|
129
|
+
nativeEvent?: any,
|
|
130
|
+
modifierKeys?: SelectionModifierKeys,
|
|
131
|
+
baseSelectedIds?: readonly string[],
|
|
132
|
+
) => void
|
|
107
133
|
handleDeselect: () => void
|
|
108
134
|
isValid: (node: AnyNode) => boolean
|
|
109
135
|
}
|
|
110
136
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
137
|
+
const DIRECT_DRAG_THRESHOLD_PX = 4
|
|
138
|
+
const DIRECT_ROTATE_EPSILON = 1e-6
|
|
139
|
+
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
|
|
140
|
+
|
|
141
|
+
function pointerEventFromNodeEvent(event: NodeEvent): PointerEvent {
|
|
142
|
+
const threeEvent = event.nativeEvent as unknown as PointerEvent & {
|
|
143
|
+
nativeEvent?: PointerEvent
|
|
144
|
+
}
|
|
145
|
+
return threeEvent.nativeEvent ?? threeEvent
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isCommandModifier(event: Pick<PointerEvent, 'metaKey' | 'ctrlKey'>): boolean {
|
|
149
|
+
return event.metaKey || event.ctrlKey
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function pointerDistancePx(event: PointerEvent, startX: number, startY: number): number {
|
|
153
|
+
return Math.hypot(event.clientX - startX, event.clientY - startY)
|
|
114
154
|
}
|
|
115
155
|
|
|
116
156
|
export const resolveBuildingId = (
|
|
@@ -180,6 +220,26 @@ function getEventObject(event: NodeEvent): Object3D {
|
|
|
180
220
|
return eventWithObject.object ?? event.nativeEvent.object
|
|
181
221
|
}
|
|
182
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Registry-driven in-scene click actions (`capabilities.sceneAction`): walk
|
|
225
|
+
* the pointer hit's object chain, ask the clicked kind to resolve an action
|
|
226
|
+
* target from each object's userData, and run it. Returns `true` when the
|
|
227
|
+
* kind consumed the click (no selection change should happen).
|
|
228
|
+
*/
|
|
229
|
+
function dispatchSceneAction(node: AnyNode, object: Object3D | null): boolean {
|
|
230
|
+
const sceneAction = nodeRegistry.get(node.type)?.capabilities?.sceneAction
|
|
231
|
+
if (!sceneAction) return false
|
|
232
|
+
let current: Object3D | null = object
|
|
233
|
+
while (current) {
|
|
234
|
+
const target = sceneAction.resolveTarget(current)
|
|
235
|
+
if (target !== null) {
|
|
236
|
+
return sceneAction.activate(node, target, createSceneApi(useScene))
|
|
237
|
+
}
|
|
238
|
+
current = current.parent
|
|
239
|
+
}
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
|
|
183
243
|
function getIntersectionMaterialIndex(
|
|
184
244
|
object: Object3D,
|
|
185
245
|
faceIndex: number | undefined,
|
|
@@ -206,7 +266,52 @@ function getRegisteredMesh(nodeId: string): Mesh | null {
|
|
|
206
266
|
return object && (object as Mesh).isMesh ? (object as Mesh) : null
|
|
207
267
|
}
|
|
208
268
|
|
|
269
|
+
// Every distinct slot role on a node, read off the registered mesh subtree's
|
|
270
|
+
// `userData.slotId` tags (a tag may be a single role or an array, one per
|
|
271
|
+
// material group). The mesh-derived fallback behind `nodeSlotRoles` for kinds
|
|
272
|
+
// whose slots come from a GLB (items) rather than a `capabilities.slots`
|
|
273
|
+
// declaration; returns `[]` when the subtree isn't mounted.
|
|
274
|
+
function meshSlotRoles(node: AnyNode): string[] {
|
|
275
|
+
const root = getRegisteredNodeObject(node.id)
|
|
276
|
+
if (!root) return []
|
|
277
|
+
const roles = new Set<string>()
|
|
278
|
+
root.traverse((object) => {
|
|
279
|
+
const mesh = object as Mesh
|
|
280
|
+
if (!mesh.isMesh) return
|
|
281
|
+
const tag = (mesh.userData as { slotId?: string | null | (string | null)[] }).slotId
|
|
282
|
+
if (Array.isArray(tag)) {
|
|
283
|
+
for (const entry of tag) if (typeof entry === 'string') roles.add(entry)
|
|
284
|
+
} else if (typeof tag === 'string') {
|
|
285
|
+
roles.add(tag)
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
return [...roles]
|
|
289
|
+
}
|
|
290
|
+
|
|
209
291
|
const roofSelectionWorldPoint = new Vector3()
|
|
292
|
+
const wallPaintWorldPoint = new Vector3()
|
|
293
|
+
|
|
294
|
+
function resolveWallPaintHit(event: NodeEvent): WallPaintHit | undefined {
|
|
295
|
+
const wall = event.node
|
|
296
|
+
if (wall.type !== 'wall') return undefined
|
|
297
|
+
const root = getRegisteredNodeObject(wall.id)
|
|
298
|
+
if (!root) return undefined
|
|
299
|
+
|
|
300
|
+
root.updateWorldMatrix(true, false)
|
|
301
|
+
wallPaintWorldPoint.set(...event.position)
|
|
302
|
+
const local = root.worldToLocal(wallPaintWorldPoint)
|
|
303
|
+
const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
|
|
304
|
+
const cos = Math.cos(angle)
|
|
305
|
+
const sin = Math.sin(angle)
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
face: local.z >= 0 ? 'front' : 'back',
|
|
309
|
+
point: [
|
|
310
|
+
wall.start[0] + local.x * cos - local.z * sin,
|
|
311
|
+
wall.start[1] + local.x * sin + local.z * cos,
|
|
312
|
+
],
|
|
313
|
+
}
|
|
314
|
+
}
|
|
210
315
|
|
|
211
316
|
function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
|
|
212
317
|
const roof = event.node
|
|
@@ -245,18 +350,21 @@ function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode |
|
|
|
245
350
|
return bestSegment?.node ?? firstSegment
|
|
246
351
|
}
|
|
247
352
|
|
|
248
|
-
function
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
353
|
+
function resolveSelectModeNodeTarget(event: NodeEvent): AnyNode {
|
|
354
|
+
if (event.node.type === 'roof') {
|
|
355
|
+
if (
|
|
356
|
+
shouldPreserveSelectedRoofHostTarget({
|
|
357
|
+
node: event.node,
|
|
358
|
+
selectedIds: useViewer.getState().selection.selectedIds,
|
|
359
|
+
armedRoofId: useEditor.getState().roofHostDragArmedId,
|
|
360
|
+
})
|
|
361
|
+
) {
|
|
362
|
+
return event.node
|
|
363
|
+
}
|
|
364
|
+
return resolveRoofSegmentSelectionTarget(event) ?? event.node
|
|
365
|
+
}
|
|
255
366
|
|
|
256
|
-
return
|
|
257
|
-
const selectedNode = nodes[selectedId]
|
|
258
|
-
return selectedNode?.type === 'roof-segment' && selectedNode.parentId === segment.parentId
|
|
259
|
-
})
|
|
367
|
+
return event.node
|
|
260
368
|
}
|
|
261
369
|
|
|
262
370
|
function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
|
|
@@ -275,20 +383,6 @@ function previewCursor(cursor: string): PaintPreviewCleanup {
|
|
|
275
383
|
}
|
|
276
384
|
}
|
|
277
385
|
|
|
278
|
-
function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
|
|
279
|
-
const shading = useViewer.getState().shading
|
|
280
|
-
|
|
281
|
-
if (material.materialPreset) {
|
|
282
|
-
return createMaterialFromPresetRef(material.materialPreset, shading)
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
if (material.material) {
|
|
286
|
-
return createMaterial(material.material, shading)
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
return null
|
|
290
|
-
}
|
|
291
|
-
|
|
292
386
|
function applyRoofPaintPreview(
|
|
293
387
|
node: RoofNode,
|
|
294
388
|
role: 'top' | 'edge' | 'wall',
|
|
@@ -354,164 +448,6 @@ function applyRoofSegmentPaintPreview(
|
|
|
354
448
|
return previewMeshMaterial(mesh, arr)
|
|
355
449
|
}
|
|
356
450
|
|
|
357
|
-
function applyStairPaintPreview(
|
|
358
|
-
node: StairNode,
|
|
359
|
-
role: StairSurfaceMaterialRole,
|
|
360
|
-
material: ActivePaintMaterial,
|
|
361
|
-
): PaintPreviewCleanup | null {
|
|
362
|
-
const root = getRegisteredNodeObject(node.id)
|
|
363
|
-
if (!root) return null
|
|
364
|
-
|
|
365
|
-
const previewNode = {
|
|
366
|
-
...node,
|
|
367
|
-
...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
|
368
|
-
}
|
|
369
|
-
const shading = useViewer.getState().shading
|
|
370
|
-
const bodyMaterials = getStairBodyMaterials(previewNode, shading)
|
|
371
|
-
const railingMaterial = getStairRailingMaterial(previewNode, shading)
|
|
372
|
-
const restores: PaintPreviewCleanup[] = []
|
|
373
|
-
|
|
374
|
-
root.traverse((object) => {
|
|
375
|
-
if (!(object as Mesh).isMesh) return
|
|
376
|
-
const mesh = object as Mesh
|
|
377
|
-
if (mesh.name.startsWith('stair-railing')) {
|
|
378
|
-
restores.push(previewMeshMaterial(mesh, railingMaterial))
|
|
379
|
-
return
|
|
380
|
-
}
|
|
381
|
-
if (Array.isArray(mesh.material) && mesh.material.length === 2) {
|
|
382
|
-
restores.push(previewMeshMaterial(mesh, bodyMaterials))
|
|
383
|
-
return
|
|
384
|
-
}
|
|
385
|
-
if (mesh.name === 'merged-stair') {
|
|
386
|
-
restores.push(previewMeshMaterial(mesh, bodyMaterials))
|
|
387
|
-
return
|
|
388
|
-
}
|
|
389
|
-
if (mesh.name.startsWith('stair-side')) {
|
|
390
|
-
restores.push(previewMeshMaterial(mesh, bodyMaterials[1]))
|
|
391
|
-
}
|
|
392
|
-
})
|
|
393
|
-
|
|
394
|
-
if (restores.length === 0) return null
|
|
395
|
-
|
|
396
|
-
return () => {
|
|
397
|
-
for (let index = restores.length - 1; index >= 0; index -= 1) {
|
|
398
|
-
restores[index]?.()
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function applySingleSurfacePaintPreview(
|
|
404
|
-
node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
|
|
405
|
-
material: ActivePaintMaterial,
|
|
406
|
-
): PaintPreviewCleanup | null {
|
|
407
|
-
if (node.type === 'ceiling') {
|
|
408
|
-
const root = getRegisteredMesh(node.id)
|
|
409
|
-
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
|
|
410
|
-
if (!(root && overlay)) return null
|
|
411
|
-
|
|
412
|
-
const previewColor =
|
|
413
|
-
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
|
|
414
|
-
resolveMaterial(material.material).color ??
|
|
415
|
-
'#999999'
|
|
416
|
-
|
|
417
|
-
const previousRootMaterial = root.material
|
|
418
|
-
const previousOverlayMaterial = overlay.material
|
|
419
|
-
const rootPreviewMaterial = Array.isArray(previousRootMaterial)
|
|
420
|
-
? previousRootMaterial.map((entry) => entry.clone())
|
|
421
|
-
: previousRootMaterial.clone()
|
|
422
|
-
const overlayPreviewMaterial = Array.isArray(previousOverlayMaterial)
|
|
423
|
-
? previousOverlayMaterial.map((entry) => entry.clone())
|
|
424
|
-
: previousOverlayMaterial.clone()
|
|
425
|
-
|
|
426
|
-
const applyColor = (input: Material | Material[]) => {
|
|
427
|
-
const materials = Array.isArray(input) ? input : [input]
|
|
428
|
-
for (const entry of materials) {
|
|
429
|
-
const materialWithColor = entry as Material & { color?: Color; needsUpdate?: boolean }
|
|
430
|
-
if (materialWithColor.color instanceof Color) {
|
|
431
|
-
materialWithColor.color = new Color(previewColor)
|
|
432
|
-
}
|
|
433
|
-
materialWithColor.needsUpdate = true
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
applyColor(rootPreviewMaterial)
|
|
438
|
-
applyColor(overlayPreviewMaterial)
|
|
439
|
-
root.material = rootPreviewMaterial
|
|
440
|
-
overlay.material = overlayPreviewMaterial
|
|
441
|
-
|
|
442
|
-
return () => {
|
|
443
|
-
root.material = previousRootMaterial
|
|
444
|
-
overlay.material = previousOverlayMaterial
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
const registeredObject = getRegisteredNodeObject(node.id)
|
|
449
|
-
const mesh =
|
|
450
|
-
registeredObject && (registeredObject as Mesh).isMesh ? (registeredObject as Mesh) : null
|
|
451
|
-
|
|
452
|
-
const previewMaterial = getSingleSurfacePreviewMaterial(material)
|
|
453
|
-
if (!previewMaterial) return null
|
|
454
|
-
|
|
455
|
-
if (node.type === 'column') {
|
|
456
|
-
if (!registeredObject) return null
|
|
457
|
-
const restores: PaintPreviewCleanup[] = []
|
|
458
|
-
|
|
459
|
-
registeredObject.traverse((object) => {
|
|
460
|
-
if (!(object as Mesh).isMesh) return
|
|
461
|
-
restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
|
|
462
|
-
})
|
|
463
|
-
|
|
464
|
-
if (restores.length === 0) return null
|
|
465
|
-
return () => {
|
|
466
|
-
for (let index = restores.length - 1; index >= 0; index -= 1) {
|
|
467
|
-
restores[index]?.()
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
if (node.type === 'shelf') {
|
|
473
|
-
// Shelf registers a `<group>` (not a Mesh) with `useRegistry`, so we walk
|
|
474
|
-
// the subtree and preview-swap every child mesh — same approach `column`
|
|
475
|
-
// uses. (The roof vents previously shared this arm; they now route through
|
|
476
|
-
// their `capabilities.paint` dispatcher.)
|
|
477
|
-
if (!registeredObject) return null
|
|
478
|
-
const restores: PaintPreviewCleanup[] = []
|
|
479
|
-
registeredObject.traverse((object) => {
|
|
480
|
-
if (!(object as Mesh).isMesh) return
|
|
481
|
-
restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
|
|
482
|
-
})
|
|
483
|
-
if (restores.length === 0) return null
|
|
484
|
-
return () => {
|
|
485
|
-
for (let index = restores.length - 1; index >= 0; index -= 1) {
|
|
486
|
-
restores[index]?.()
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
if (!mesh) return null
|
|
492
|
-
|
|
493
|
-
if (node.type === 'slab') {
|
|
494
|
-
const slabMaterial = previewMaterial.clone()
|
|
495
|
-
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
|
|
496
|
-
const previewMeshMaterialInput = slabMaterial as Material & {
|
|
497
|
-
alphaMap?: unknown
|
|
498
|
-
depthWrite?: boolean
|
|
499
|
-
needsUpdate?: boolean
|
|
500
|
-
opacity?: number
|
|
501
|
-
side?: number
|
|
502
|
-
transparent?: boolean
|
|
503
|
-
}
|
|
504
|
-
previewMeshMaterialInput.transparent = false
|
|
505
|
-
previewMeshMaterialInput.opacity = 1
|
|
506
|
-
previewMeshMaterialInput.alphaMap = null
|
|
507
|
-
previewMeshMaterialInput.depthWrite = true
|
|
508
|
-
previewMeshMaterialInput.needsUpdate = true
|
|
509
|
-
return previewMeshMaterial(mesh, slabMaterial)
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
return previewMeshMaterial(mesh, previewMaterial)
|
|
513
|
-
}
|
|
514
|
-
|
|
515
451
|
// Chimney + dormer paint dispatch lives on their NodeDefinition's
|
|
516
452
|
// `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/
|
|
517
453
|
// paint.ts). The generic registry-driven arm in this file consults
|
|
@@ -540,10 +476,12 @@ const HIGHLIGHT_PROFILES = {
|
|
|
540
476
|
emissiveIntensity: 0.46,
|
|
541
477
|
},
|
|
542
478
|
selection: {
|
|
479
|
+
// Keep the real material/texture readable: no albedo tint, just a gentle
|
|
480
|
+
// indigo emissive glow so it reads as selected.
|
|
543
481
|
color: new Color('#818cf8'),
|
|
544
|
-
blend: 0
|
|
545
|
-
emissiveBlend: 0.
|
|
546
|
-
emissiveIntensity: 0.
|
|
482
|
+
blend: 0,
|
|
483
|
+
emissiveBlend: 0.4,
|
|
484
|
+
emissiveIntensity: 0.12,
|
|
547
485
|
},
|
|
548
486
|
} as const
|
|
549
487
|
|
|
@@ -568,8 +506,30 @@ function isHighlightableMesh(object: Object3D): object is Mesh {
|
|
|
568
506
|
)
|
|
569
507
|
}
|
|
570
508
|
|
|
509
|
+
const TEXTURE_MAP_KEYS = [
|
|
510
|
+
'map',
|
|
511
|
+
'normalMap',
|
|
512
|
+
'roughnessMap',
|
|
513
|
+
'metalnessMap',
|
|
514
|
+
'aoMap',
|
|
515
|
+
'emissiveMap',
|
|
516
|
+
'bumpMap',
|
|
517
|
+
'displacementMap',
|
|
518
|
+
'alphaMap',
|
|
519
|
+
'lightMap',
|
|
520
|
+
] as const
|
|
521
|
+
|
|
571
522
|
function createHighlightedMaterial(material: Material, kind: HighlightKind): Material {
|
|
572
523
|
const highlightedMaterial = material.clone() as HighlightableMaterial
|
|
524
|
+
// `NodeMaterial.clone()` on the WebGPU backend drops the texture-map node
|
|
525
|
+
// assignments, so the clone renders flat. Re-attach the maps from the source
|
|
526
|
+
// material (they're shared by reference — same texture object) so the
|
|
527
|
+
// selected object keeps its texture under the highlight.
|
|
528
|
+
const src = material as unknown as Record<string, unknown>
|
|
529
|
+
const dst = highlightedMaterial as unknown as Record<string, unknown>
|
|
530
|
+
for (const key of TEXTURE_MAP_KEYS) {
|
|
531
|
+
if (src[key]) dst[key] = src[key]
|
|
532
|
+
}
|
|
573
533
|
const profile = HIGHLIGHT_PROFILES[kind]
|
|
574
534
|
|
|
575
535
|
if (highlightedMaterial.color instanceof Color) {
|
|
@@ -619,22 +579,17 @@ function disposeHighlightedMaterials(material: Material | Material[]) {
|
|
|
619
579
|
|
|
620
580
|
const computeNextIds = (
|
|
621
581
|
node: AnyNode,
|
|
622
|
-
selectedIds: string[],
|
|
582
|
+
selectedIds: readonly string[],
|
|
623
583
|
event?: any,
|
|
624
|
-
modifierKeys?:
|
|
584
|
+
modifierKeys?: SelectionModifierKeys,
|
|
585
|
+
baseSelectedIds?: readonly string[],
|
|
625
586
|
): string[] => {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
}
|
|
633
|
-
return [...selectedIds, node.id]
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// Not holding modifiers: select only this node
|
|
637
|
-
return [node.id]
|
|
587
|
+
return resolveSelectedIdsForNodeClick({
|
|
588
|
+
baseSelectedIds,
|
|
589
|
+
currentSelectedIds: selectedIds,
|
|
590
|
+
modifierKeys: selectionModifiersFromEvent(event, modifierKeys),
|
|
591
|
+
nodeId: node.id,
|
|
592
|
+
})
|
|
638
593
|
}
|
|
639
594
|
|
|
640
595
|
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
@@ -667,7 +622,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
667
622
|
'window',
|
|
668
623
|
'door',
|
|
669
624
|
],
|
|
670
|
-
handleSelect: (node, nativeEvent, modifierKeys) => {
|
|
625
|
+
handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
|
|
671
626
|
const { selection, setSelection } = useViewer.getState()
|
|
672
627
|
const nodes = useScene.getState().nodes
|
|
673
628
|
const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes)
|
|
@@ -694,7 +649,13 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
694
649
|
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
|
|
695
650
|
setSelection(updates)
|
|
696
651
|
} else {
|
|
697
|
-
updates.selectedIds = computeNextIds(
|
|
652
|
+
updates.selectedIds = computeNextIds(
|
|
653
|
+
node,
|
|
654
|
+
selection.selectedIds,
|
|
655
|
+
nativeEvent,
|
|
656
|
+
modifierKeys,
|
|
657
|
+
baseSelectedIds,
|
|
658
|
+
)
|
|
698
659
|
setSelection(updates)
|
|
699
660
|
}
|
|
700
661
|
},
|
|
@@ -746,7 +707,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
746
707
|
|
|
747
708
|
furnish: {
|
|
748
709
|
types: ['item'],
|
|
749
|
-
handleSelect: (node, nativeEvent, modifierKeys) => {
|
|
710
|
+
handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
|
|
750
711
|
const { selection, setSelection } = useViewer.getState()
|
|
751
712
|
const nodes = useScene.getState().nodes
|
|
752
713
|
const nodeLevelId = resolveLevelId(node, nodes)
|
|
@@ -760,7 +721,13 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
760
721
|
updates.buildingId = buildingId
|
|
761
722
|
}
|
|
762
723
|
|
|
763
|
-
updates.selectedIds = computeNextIds(
|
|
724
|
+
updates.selectedIds = computeNextIds(
|
|
725
|
+
node,
|
|
726
|
+
selection.selectedIds,
|
|
727
|
+
nativeEvent,
|
|
728
|
+
modifierKeys,
|
|
729
|
+
baseSelectedIds,
|
|
730
|
+
)
|
|
764
731
|
setSelection(updates)
|
|
765
732
|
},
|
|
766
733
|
handleDeselect: () => {
|
|
@@ -776,7 +743,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
776
743
|
// Registry-driven kinds with `category: 'furnish'` (shelf today,
|
|
777
744
|
// future furniture kinds): selectable in furnish phase if their
|
|
778
745
|
// definition declares the `selectable` capability. Without this
|
|
779
|
-
// branch, shelf clicks routed to furnish phase via
|
|
746
|
+
// branch, shelf clicks routed to furnish phase via resolveNodeSelectionTarget
|
|
780
747
|
// would be rejected here — single-click selection broken.
|
|
781
748
|
const def = nodeRegistry.get(node.type)
|
|
782
749
|
if (def && def.category === 'furnish' && def.capabilities.selectable) return true
|
|
@@ -785,82 +752,32 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
785
752
|
},
|
|
786
753
|
}
|
|
787
754
|
|
|
788
|
-
const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
|
789
|
-
// Item is checked FIRST so its asset.category-driven routing (door/
|
|
790
|
-
// window items land in structure phase, everything else in furnish)
|
|
791
|
-
// beats the generic registry fallback below. Without this, registering
|
|
792
|
-
// `item` (Phase 5) made isRegistrySelectable('item') match the
|
|
793
|
-
// structure branch first, breaking single-click selection: first click
|
|
794
|
-
// switched the editor to structure phase, second click selected.
|
|
795
|
-
if (node.type === 'item') {
|
|
796
|
-
const item = node as ItemNode
|
|
797
|
-
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
|
798
|
-
return {
|
|
799
|
-
phase: 'structure',
|
|
800
|
-
structureLayer: 'elements',
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
return {
|
|
804
|
-
phase: 'furnish',
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
if (node.type === 'zone') {
|
|
809
|
-
return {
|
|
810
|
-
phase: 'structure',
|
|
811
|
-
structureLayer: 'zones',
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
if (
|
|
816
|
-
node.type === 'wall' ||
|
|
817
|
-
node.type === 'fence' ||
|
|
818
|
-
node.type === 'column' ||
|
|
819
|
-
node.type === 'elevator' ||
|
|
820
|
-
node.type === 'slab' ||
|
|
821
|
-
node.type === 'ceiling' ||
|
|
822
|
-
node.type === 'roof' ||
|
|
823
|
-
node.type === 'roof-segment' ||
|
|
824
|
-
node.type === 'stair' ||
|
|
825
|
-
node.type === 'stair-segment' ||
|
|
826
|
-
node.type === 'spawn' ||
|
|
827
|
-
node.type === 'window' ||
|
|
828
|
-
node.type === 'door'
|
|
829
|
-
) {
|
|
830
|
-
return {
|
|
831
|
-
phase: 'structure',
|
|
832
|
-
structureLayer: 'elements',
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
// Registry-driven kinds (Phase 5+): route by `def.category`. Built-ins
|
|
837
|
-
// above match before this fallback. Furnish-category kinds (shelf,
|
|
838
|
-
// item — already handled above) land on the furnish phase; structure-
|
|
839
|
-
// category kinds (everything else) on structure/elements.
|
|
840
|
-
const def = nodeRegistry.get(node.type)
|
|
841
|
-
if (def) {
|
|
842
|
-
if (def.category === 'furnish') {
|
|
843
|
-
return { phase: 'furnish' }
|
|
844
|
-
}
|
|
845
|
-
return { phase: 'structure', structureLayer: 'elements' }
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
return null
|
|
849
|
-
}
|
|
850
|
-
|
|
851
755
|
export const SelectionManager = () => {
|
|
852
756
|
const phase = useEditor((s) => s.phase)
|
|
853
757
|
const mode = useEditor((s) => s.mode)
|
|
758
|
+
// The canvas element — cursor styling must land here, not on `document.body`:
|
|
759
|
+
// the editor wraps the canvas in a div with a custom `cursor: url(...)`, which
|
|
760
|
+
// (being a closer ancestor) overrides any body cursor over the canvas.
|
|
761
|
+
const glDomElement = useThree((s) => s.gl.domElement)
|
|
762
|
+
const camera = useThree((s) => s.camera)
|
|
763
|
+
const raycaster = useThree((s) => s.raycaster)
|
|
854
764
|
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
|
|
855
|
-
|
|
765
|
+
|
|
766
|
+
// Publish the live three context for DOM-level sessions (group pick-up
|
|
767
|
+
// move) that raycast the 3D view from outside the R3F tree.
|
|
768
|
+
useEffect(() => {
|
|
769
|
+
setEditorThreeContext({ camera, raycaster, domElement: glDomElement })
|
|
770
|
+
return () => setEditorThreeContext(null)
|
|
771
|
+
}, [camera, raycaster, glDomElement])
|
|
772
|
+
const modifierKeysRef = useRef<SelectionModifierKeys>({
|
|
856
773
|
meta: false,
|
|
857
774
|
ctrl: false,
|
|
775
|
+
shift: false,
|
|
858
776
|
})
|
|
859
777
|
const clickHandledRef = useRef(false)
|
|
860
778
|
|
|
861
|
-
const movingNode =
|
|
862
|
-
const
|
|
863
|
-
const curvingFence = useEditor((s) => s.curvingFence)
|
|
779
|
+
const movingNode = useMovingNode()
|
|
780
|
+
const isCurveReshape = useIsCurveReshape()
|
|
864
781
|
|
|
865
782
|
useEffect(() => {
|
|
866
783
|
const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
|
|
@@ -873,9 +790,12 @@ export const SelectionManager = () => {
|
|
|
873
790
|
|
|
874
791
|
useEffect(() => {
|
|
875
792
|
if (mode !== 'material-paint') return
|
|
876
|
-
if (movingNode ||
|
|
793
|
+
if (movingNode || isCurveReshape) return
|
|
877
794
|
|
|
878
795
|
let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null
|
|
796
|
+
// The last hover event, replayed when the application scope cycles so the
|
|
797
|
+
// preview + chip update under a stationary cursor (Shift fires no pointer move).
|
|
798
|
+
let lastEnterEvent: NodeEvent | null = null
|
|
879
799
|
|
|
880
800
|
const clearActivePreview = () => {
|
|
881
801
|
activePreview?.restore()
|
|
@@ -933,38 +853,110 @@ export const SelectionManager = () => {
|
|
|
933
853
|
normal: event.normal,
|
|
934
854
|
localPosition: event.localPosition as readonly [number, number, number] | undefined,
|
|
935
855
|
hitObjectName: event.nativeEvent.object?.name,
|
|
856
|
+
hitObject: getEventObject(event),
|
|
857
|
+
ray: event.nativeEvent.ray,
|
|
936
858
|
})
|
|
937
859
|
const compatible = role !== null && paintEnabled
|
|
860
|
+
// Derive the node's slots (declared, else mesh tags) once — drives both
|
|
861
|
+
// the chip's available scopes and the whole-object fan-out.
|
|
862
|
+
const slotRoles = compatible && role ? nodeSlotRoles(node, meshSlotRoles) : []
|
|
863
|
+
// Resolve the application-scope fan-out once (this surface / whole object
|
|
864
|
+
// / all matching / room). The scope is part of the key so cycling it
|
|
865
|
+
// (Shift) re-keys the interaction → the preview re-applies for the new
|
|
866
|
+
// spread instead of being deduped to the single-surface preview.
|
|
867
|
+
const scope = useEditor.getState().paintScope
|
|
868
|
+
const wallHit = resolveWallPaintHit(event)
|
|
869
|
+
const scopeTargets =
|
|
870
|
+
compatible && role
|
|
871
|
+
? resolvePaintScopeTargets({
|
|
872
|
+
node,
|
|
873
|
+
role,
|
|
874
|
+
scope,
|
|
875
|
+
nodes: useScene.getState().nodes,
|
|
876
|
+
spaces: useEditor.getState().spaces,
|
|
877
|
+
slotRolesOf: () => slotRoles,
|
|
878
|
+
wallHit,
|
|
879
|
+
})
|
|
880
|
+
: []
|
|
881
|
+
const scopeTargetKey = scopeTargets
|
|
882
|
+
.map((target) => `${target.nodeId}:${target.role}`)
|
|
883
|
+
.sort()
|
|
884
|
+
.join(',')
|
|
938
885
|
return {
|
|
939
|
-
key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
|
|
886
|
+
key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}:${scope}:${scopeTargetKey}`,
|
|
940
887
|
hoveredId: node.id as AnyNodeId,
|
|
941
888
|
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
|
|
942
|
-
|
|
889
|
+
paintHover:
|
|
943
890
|
compatible && role
|
|
944
|
-
?
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
node,
|
|
949
|
-
role,
|
|
950
|
-
material: paintSpec.material,
|
|
951
|
-
materialPreset: paintSpec.materialPreset,
|
|
952
|
-
}) as Partial<AnyNode>,
|
|
953
|
-
)
|
|
891
|
+
? {
|
|
892
|
+
scopes: availablePaintScopes({ node, slotRoles }),
|
|
893
|
+
slotLabel: slotDisplayLabel(node, role),
|
|
894
|
+
nodeNoun: node.type,
|
|
954
895
|
}
|
|
955
896
|
: null,
|
|
956
|
-
|
|
897
|
+
apply:
|
|
957
898
|
compatible && role
|
|
958
899
|
? () => {
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
900
|
+
// Spread targets are all the same slot-model kind, so one
|
|
901
|
+
// batched commit writes them in a single undo step; the
|
|
902
|
+
// single-surface case keeps the kind's own commit (covers
|
|
903
|
+
// non-slot kinds too).
|
|
904
|
+
if (scopeTargets.length > 1) {
|
|
905
|
+
commitPaintScopeFanout(
|
|
906
|
+
scopeTargets,
|
|
907
|
+
paintSpec.material,
|
|
908
|
+
paintSpec.materialPreset,
|
|
909
|
+
)
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
const args = {
|
|
962
913
|
node,
|
|
963
914
|
role,
|
|
964
915
|
material: paintSpec.material,
|
|
965
916
|
materialPreset: paintSpec.materialPreset,
|
|
966
|
-
|
|
967
|
-
|
|
917
|
+
}
|
|
918
|
+
if (paintCap.commit) {
|
|
919
|
+
paintCap.commit(args)
|
|
920
|
+
} else {
|
|
921
|
+
useScene
|
|
922
|
+
.getState()
|
|
923
|
+
.updateNode(
|
|
924
|
+
node.id as AnyNodeId,
|
|
925
|
+
paintCap.buildPatch(args) as Partial<AnyNode>,
|
|
926
|
+
)
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
: null,
|
|
930
|
+
preview:
|
|
931
|
+
compatible && role
|
|
932
|
+
? () => {
|
|
933
|
+
// Preview every surface the click would paint, so room /
|
|
934
|
+
// whole-item / all-matching show the full spread, not just the
|
|
935
|
+
// hovered surface. Each target is the same kind, so its own
|
|
936
|
+
// paint capability builds the preview; restores combine.
|
|
937
|
+
const restores: PaintPreviewCleanup[] = []
|
|
938
|
+
const sceneNodes = useScene.getState().nodes
|
|
939
|
+
for (const target of scopeTargets) {
|
|
940
|
+
const targetNode = sceneNodes[target.nodeId]
|
|
941
|
+
const targetRoot = getRegisteredNodeObject(target.nodeId)
|
|
942
|
+
const targetCap = targetNode
|
|
943
|
+
? nodeRegistry.get(targetNode.type)?.capabilities?.paint
|
|
944
|
+
: null
|
|
945
|
+
if (!(targetNode && targetRoot && targetCap)) continue
|
|
946
|
+
const restore = targetCap.applyPreview({
|
|
947
|
+
node: targetNode,
|
|
948
|
+
role: target.role,
|
|
949
|
+
material: paintSpec.material,
|
|
950
|
+
materialPreset: paintSpec.materialPreset,
|
|
951
|
+
root: targetRoot,
|
|
952
|
+
})
|
|
953
|
+
if (restore) restores.push(restore)
|
|
954
|
+
}
|
|
955
|
+
if (restores.length === 0) return null
|
|
956
|
+
return () => {
|
|
957
|
+
for (let index = restores.length - 1; index >= 0; index -= 1)
|
|
958
|
+
restores[index]?.()
|
|
959
|
+
}
|
|
968
960
|
}
|
|
969
961
|
: () => previewCursor('not-allowed'),
|
|
970
962
|
}
|
|
@@ -978,7 +970,7 @@ export const SelectionManager = () => {
|
|
|
978
970
|
: node.parentId
|
|
979
971
|
? useScene.getState().nodes[node.parentId as AnyNodeId]
|
|
980
972
|
: null
|
|
981
|
-
if (
|
|
973
|
+
if (roofNode?.type !== 'roof') return null
|
|
982
974
|
|
|
983
975
|
const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
|
|
984
976
|
const compatible = role !== null && paintEnabled
|
|
@@ -993,6 +985,16 @@ export const SelectionManager = () => {
|
|
|
993
985
|
}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
|
|
994
986
|
hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId,
|
|
995
987
|
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
|
|
988
|
+
// Roof isn't on the slot model (role-specific fields, custom commit),
|
|
989
|
+
// so it offers only the single surface — but still labels it.
|
|
990
|
+
paintHover:
|
|
991
|
+
compatible && role
|
|
992
|
+
? {
|
|
993
|
+
scopes: ['single'],
|
|
994
|
+
slotLabel: slotDisplayLabel(roofNode, role),
|
|
995
|
+
nodeNoun: 'roof',
|
|
996
|
+
}
|
|
997
|
+
: null,
|
|
996
998
|
apply:
|
|
997
999
|
compatible && role
|
|
998
1000
|
? () => {
|
|
@@ -1035,90 +1037,17 @@ export const SelectionManager = () => {
|
|
|
1035
1037
|
}
|
|
1036
1038
|
}
|
|
1037
1039
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
? node
|
|
1042
|
-
: node.parentId
|
|
1043
|
-
? useScene.getState().nodes[node.parentId as AnyNodeId]
|
|
1044
|
-
: null
|
|
1045
|
-
if (!stairNode || stairNode.type !== 'stair') return null
|
|
1046
|
-
|
|
1047
|
-
const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent)
|
|
1048
|
-
const compatible = role !== null && paintEnabled
|
|
1049
|
-
return {
|
|
1050
|
-
key: `stair:${stairNode.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
|
|
1051
|
-
hoveredId: stairNode.id as AnyNodeId,
|
|
1052
|
-
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
|
|
1053
|
-
apply:
|
|
1054
|
-
compatible && role
|
|
1055
|
-
? () => {
|
|
1056
|
-
useScene
|
|
1057
|
-
.getState()
|
|
1058
|
-
.updateNode(
|
|
1059
|
-
stairNode.id as AnyNodeId,
|
|
1060
|
-
buildStairSurfaceMaterialPatch(
|
|
1061
|
-
stairNode as StairNode,
|
|
1062
|
-
role,
|
|
1063
|
-
paintSpec.material,
|
|
1064
|
-
paintSpec.materialPreset,
|
|
1065
|
-
),
|
|
1066
|
-
)
|
|
1067
|
-
}
|
|
1068
|
-
: null,
|
|
1069
|
-
preview:
|
|
1070
|
-
compatible && role
|
|
1071
|
-
? () => applyStairPaintPreview(stairNode as StairNode, role, paintSpec)
|
|
1072
|
-
: () => previewCursor('not-allowed'),
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
// Registry-driven paint dispatch handled at the top of this
|
|
1077
|
-
// function — kinds declaring `capabilities.paint` return there
|
|
1078
|
-
// before any of the legacy roof / stair / single-surface arms
|
|
1079
|
-
// below run.
|
|
1080
|
-
|
|
1081
|
-
if (
|
|
1082
|
-
node.type === 'fence' ||
|
|
1083
|
-
node.type === 'column' ||
|
|
1084
|
-
node.type === 'slab' ||
|
|
1085
|
-
node.type === 'ceiling' ||
|
|
1086
|
-
node.type === 'shelf'
|
|
1087
|
-
) {
|
|
1088
|
-
const compatible = paintEnabled
|
|
1040
|
+
// Only `roof` / `roof-segment` reach a legacy paint arm (above) — every
|
|
1041
|
+
// other paintable kind declares `capabilities.paint` and returns from the
|
|
1042
|
+
// registry-driven dispatch at the top of this function.
|
|
1089
1043
|
|
|
1090
|
-
|
|
1091
|
-
key: `${node.type}:${node.id}:surface:${eraser ? 'erase' : 'paint'}`,
|
|
1092
|
-
hoveredId: node.id as AnyNodeId,
|
|
1093
|
-
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
|
|
1094
|
-
apply: compatible
|
|
1095
|
-
? () => {
|
|
1096
|
-
useScene
|
|
1097
|
-
.getState()
|
|
1098
|
-
.updateNode(
|
|
1099
|
-
node.id as AnyNodeId,
|
|
1100
|
-
buildSingleSurfaceMaterialPatch<
|
|
1101
|
-
FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode
|
|
1102
|
-
>(paintSpec.material, paintSpec.materialPreset),
|
|
1103
|
-
)
|
|
1104
|
-
}
|
|
1105
|
-
: null,
|
|
1106
|
-
preview: compatible
|
|
1107
|
-
? () =>
|
|
1108
|
-
applySingleSurfacePaintPreview(
|
|
1109
|
-
node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
|
|
1110
|
-
paintSpec,
|
|
1111
|
-
)
|
|
1112
|
-
: () => previewCursor('not-allowed'),
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
const disabledNodeTypes = ['item', 'window', 'door', 'zone']
|
|
1044
|
+
const disabledNodeTypes = ['zone']
|
|
1117
1045
|
if (disabledNodeTypes.includes(node.type)) {
|
|
1118
1046
|
return {
|
|
1119
1047
|
key: `${node.type}:${node.id}:unsupported`,
|
|
1120
1048
|
hoveredId: node.id as AnyNodeId,
|
|
1121
1049
|
hoverMode: 'paint-disabled',
|
|
1050
|
+
paintHover: null,
|
|
1122
1051
|
apply: null,
|
|
1123
1052
|
preview: () => previewCursor('not-allowed'),
|
|
1124
1053
|
}
|
|
@@ -1128,12 +1057,22 @@ export const SelectionManager = () => {
|
|
|
1128
1057
|
}
|
|
1129
1058
|
|
|
1130
1059
|
const onEnter = (event: NodeEvent) => {
|
|
1131
|
-
|
|
1060
|
+
// A host-driven drag (handle resize/rotate) sets `inputDragging`.
|
|
1061
|
+
// useNodeEvents now emits hover events during such a drag so surface
|
|
1062
|
+
// move tools keep tracking the cursor — but paint preview must not fire
|
|
1063
|
+
// mid-drag, so gate on `inputDragging` here too.
|
|
1064
|
+
if (boxSelectHandled || useViewer.getState().inputDragging) return
|
|
1132
1065
|
|
|
1133
1066
|
const interaction = getPaintInteraction(event)
|
|
1134
1067
|
if (!interaction) return
|
|
1135
1068
|
|
|
1136
1069
|
event.stopPropagation()
|
|
1070
|
+
lastEnterEvent = event
|
|
1071
|
+
|
|
1072
|
+
// Drive the paint HUD off this hover: the interaction carries the scopes +
|
|
1073
|
+
// labels for the painted surface (`null` when it isn't paintable — no
|
|
1074
|
+
// slots, etc. — which makes the HUD show the "hover a surface" hint).
|
|
1075
|
+
useEditor.getState().setPaintHover(interaction.paintHover)
|
|
1137
1076
|
|
|
1138
1077
|
if (activePreview?.key === interaction.key) {
|
|
1139
1078
|
return
|
|
@@ -1153,6 +1092,10 @@ export const SelectionManager = () => {
|
|
|
1153
1092
|
const interaction = getPaintInteraction(event)
|
|
1154
1093
|
if (!interaction) return
|
|
1155
1094
|
|
|
1095
|
+
// Leaving any surface → the HUD shows the "hover a surface" hint again.
|
|
1096
|
+
lastEnterEvent = null
|
|
1097
|
+
useEditor.getState().setPaintHover(null)
|
|
1098
|
+
|
|
1156
1099
|
if (activePreview?.key !== interaction.key) {
|
|
1157
1100
|
return
|
|
1158
1101
|
}
|
|
@@ -1177,6 +1120,7 @@ export const SelectionManager = () => {
|
|
|
1177
1120
|
}
|
|
1178
1121
|
|
|
1179
1122
|
interaction.apply()
|
|
1123
|
+
sfxEmitter.emit('sfx:paint-apply')
|
|
1180
1124
|
if (activePreview?.key === interaction.key) {
|
|
1181
1125
|
activePreview = null
|
|
1182
1126
|
} else {
|
|
@@ -1210,36 +1154,55 @@ export const SelectionManager = () => {
|
|
|
1210
1154
|
|
|
1211
1155
|
for (const type of subscribedKinds) {
|
|
1212
1156
|
emitter.on(`${type}:enter` as any, onEnter as any)
|
|
1157
|
+
// Re-evaluate on move so the hover preview tracks the cursor across a
|
|
1158
|
+
// kind's sub-parts (door/window panel↔frame↔glass↔hardware, wall
|
|
1159
|
+
// interior↔exterior) — not just on the initial enter. onEnter is
|
|
1160
|
+
// idempotent (no-ops when the resolved part is unchanged).
|
|
1161
|
+
emitter.on(`${type}:move` as any, onEnter as any)
|
|
1213
1162
|
emitter.on(`${type}:leave` as any, onLeave as any)
|
|
1214
1163
|
emitter.on(`${type}:click` as any, onClick as any)
|
|
1215
1164
|
}
|
|
1216
1165
|
|
|
1166
|
+
// Cycling the application scope (Shift) fires no pointer event, so replay
|
|
1167
|
+
// the last hover to re-resolve the spread and re-apply the preview at once.
|
|
1168
|
+
const unsubscribePaintScope = useEditor.subscribe((state, prev) => {
|
|
1169
|
+
if (state.paintScope === prev.paintScope || !lastEnterEvent) return
|
|
1170
|
+
clearActivePreview()
|
|
1171
|
+
onEnter(lastEnterEvent)
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1217
1174
|
return () => {
|
|
1175
|
+
unsubscribePaintScope()
|
|
1218
1176
|
for (const type of subscribedKinds) {
|
|
1219
1177
|
emitter.off(`${type}:enter` as any, onEnter as any)
|
|
1178
|
+
emitter.off(`${type}:move` as any, onEnter as any)
|
|
1220
1179
|
emitter.off(`${type}:leave` as any, onLeave as any)
|
|
1221
1180
|
emitter.off(`${type}:click` as any, onClick as any)
|
|
1222
1181
|
}
|
|
1223
1182
|
clearActivePreview()
|
|
1224
1183
|
useViewer.setState({ hoveredId: null })
|
|
1225
1184
|
setHoverHighlightMode('default')
|
|
1185
|
+
useEditor.getState().setPaintHover(null)
|
|
1226
1186
|
}
|
|
1227
|
-
}, [
|
|
1187
|
+
}, [isCurveReshape, mode, movingNode, setHoverHighlightMode])
|
|
1228
1188
|
|
|
1229
1189
|
useEffect(() => {
|
|
1230
1190
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
1231
1191
|
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
|
1232
1192
|
if (event.key === 'Control') modifierKeysRef.current.ctrl = true
|
|
1193
|
+
if (event.key === 'Shift') modifierKeysRef.current.shift = true
|
|
1233
1194
|
}
|
|
1234
1195
|
|
|
1235
1196
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
1236
1197
|
if (event.key === 'Meta') modifierKeysRef.current.meta = false
|
|
1237
1198
|
if (event.key === 'Control') modifierKeysRef.current.ctrl = false
|
|
1199
|
+
if (event.key === 'Shift') modifierKeysRef.current.shift = false
|
|
1238
1200
|
}
|
|
1239
1201
|
|
|
1240
1202
|
const clearModifiers = () => {
|
|
1241
1203
|
modifierKeysRef.current.meta = false
|
|
1242
1204
|
modifierKeysRef.current.ctrl = false
|
|
1205
|
+
modifierKeysRef.current.shift = false
|
|
1243
1206
|
}
|
|
1244
1207
|
|
|
1245
1208
|
window.addEventListener('keydown', onKeyDown)
|
|
@@ -1255,13 +1218,341 @@ export const SelectionManager = () => {
|
|
|
1255
1218
|
|
|
1256
1219
|
useEffect(() => {
|
|
1257
1220
|
if (mode !== 'select') return
|
|
1258
|
-
if (movingNode ||
|
|
1221
|
+
if (movingNode || isCurveReshape) return
|
|
1222
|
+
|
|
1223
|
+
const onPointerDown = (event: NodeEvent) => {
|
|
1224
|
+
const pointer = pointerEventFromNodeEvent(event)
|
|
1225
|
+
if (pointer.button !== 0) return
|
|
1226
|
+
|
|
1227
|
+
// Plain press on a transformable member of a multi-selection arms the
|
|
1228
|
+
// group move — dragging slides the whole selection on the ground plane
|
|
1229
|
+
// (the 3D sibling of the 2D floorplan group drag, replacing the removed
|
|
1230
|
+
// group-move gizmo cross). A plain click (no drag) still falls through
|
|
1231
|
+
// to the normal click handling, which collapses to the pressed node.
|
|
1232
|
+
if (
|
|
1233
|
+
!(pointer.shiftKey || pointer.altKey || isCommandModifier(pointer)) &&
|
|
1234
|
+
armGroupMove3d({
|
|
1235
|
+
nodeId: event.node.id as AnyNodeId,
|
|
1236
|
+
clientX: pointer.clientX,
|
|
1237
|
+
clientY: pointer.clientY,
|
|
1238
|
+
pointerId: pointer.pointerId,
|
|
1239
|
+
nativeEvent: pointer,
|
|
1240
|
+
camera,
|
|
1241
|
+
raycaster,
|
|
1242
|
+
domElement: glDomElement,
|
|
1243
|
+
})
|
|
1244
|
+
) {
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (!isCommandModifier(pointer)) return
|
|
1249
|
+
|
|
1250
|
+
const eventNode = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node
|
|
1251
|
+
const node = resolveCanvasSelectionNode({
|
|
1252
|
+
node: eventNode,
|
|
1253
|
+
nodes: useScene.getState().nodes,
|
|
1254
|
+
selectedIds: useViewer.getState().selection.selectedIds,
|
|
1255
|
+
})
|
|
1256
|
+
if (!canDirectMoveNode(node)) return
|
|
1257
|
+
// Sole selection only: per-node direct manipulation stands down for a
|
|
1258
|
+
// multi-selection (the group sessions own plain drags there, and Cmd is
|
|
1259
|
+
// the selection-toggle key — a wobbly Cmd+click must not yank one
|
|
1260
|
+
// member out of the group).
|
|
1261
|
+
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
|
1262
|
+
if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== node.id) return
|
|
1263
|
+
|
|
1264
|
+
const startX = pointer.clientX
|
|
1265
|
+
const startY = pointer.clientY
|
|
1266
|
+
const pointerId = pointer.pointerId
|
|
1267
|
+
const pointerTarget = pointer.target instanceof EventTarget ? pointer.target : null
|
|
1268
|
+
let engaged = false
|
|
1269
|
+
|
|
1270
|
+
const cleanup = () => {
|
|
1271
|
+
window.removeEventListener('pointermove', onMove)
|
|
1272
|
+
window.removeEventListener('pointerup', onEnd)
|
|
1273
|
+
window.removeEventListener('pointercancel', onEnd)
|
|
1274
|
+
if (engaged) {
|
|
1275
|
+
useViewer.getState().setInputDragging(false)
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
1280
|
+
if (moveEvent.pointerId !== pointerId) return
|
|
1281
|
+
if (engaged) return
|
|
1282
|
+
if (pointerDistancePx(moveEvent, startX, startY) < DIRECT_DRAG_THRESHOLD_PX) return
|
|
1283
|
+
|
|
1284
|
+
engaged = true
|
|
1285
|
+
event.stopPropagation()
|
|
1286
|
+
suppressBoxSelectForPointer(event.nativeEvent)
|
|
1287
|
+
useViewer.getState().setInputDragging(true)
|
|
1288
|
+
swallowNextClick()
|
|
1289
|
+
createEditorApi().engageMoveDrag(node)
|
|
1290
|
+
requestAnimationFrame(() => {
|
|
1291
|
+
if (getMovingNode()?.id !== node.id) return
|
|
1292
|
+
pointerTarget?.dispatchEvent(
|
|
1293
|
+
new PointerEvent('pointermove', {
|
|
1294
|
+
altKey: moveEvent.altKey,
|
|
1295
|
+
bubbles: true,
|
|
1296
|
+
buttons: moveEvent.buttons,
|
|
1297
|
+
clientX: moveEvent.clientX,
|
|
1298
|
+
clientY: moveEvent.clientY,
|
|
1299
|
+
ctrlKey: moveEvent.ctrlKey,
|
|
1300
|
+
metaKey: moveEvent.metaKey,
|
|
1301
|
+
pointerId,
|
|
1302
|
+
pointerType: moveEvent.pointerType,
|
|
1303
|
+
shiftKey: moveEvent.shiftKey,
|
|
1304
|
+
}),
|
|
1305
|
+
)
|
|
1306
|
+
})
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const onEnd = (endEvent: PointerEvent) => {
|
|
1310
|
+
if (endEvent.pointerId !== pointerId) return
|
|
1311
|
+
cleanup()
|
|
1312
|
+
if (engaged) {
|
|
1313
|
+
requestAnimationFrame(() => {
|
|
1314
|
+
const editor = useEditor.getState()
|
|
1315
|
+
if (getMovingNode()?.id !== node.id || !editor.placementDragMode) return
|
|
1316
|
+
editor.setMovingNode(null)
|
|
1317
|
+
})
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
window.addEventListener('pointermove', onMove)
|
|
1322
|
+
window.addEventListener('pointerup', onEnd)
|
|
1323
|
+
window.addEventListener('pointercancel', onEnd)
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
const allTypes = [
|
|
1327
|
+
'wall',
|
|
1328
|
+
'fence',
|
|
1329
|
+
'item',
|
|
1330
|
+
'column',
|
|
1331
|
+
'slab',
|
|
1332
|
+
'ceiling',
|
|
1333
|
+
'roof',
|
|
1334
|
+
'roof-segment',
|
|
1335
|
+
'stair',
|
|
1336
|
+
'stair-segment',
|
|
1337
|
+
'window',
|
|
1338
|
+
'door',
|
|
1339
|
+
'zone',
|
|
1340
|
+
'shelf',
|
|
1341
|
+
'spawn',
|
|
1342
|
+
'elevator',
|
|
1343
|
+
'building',
|
|
1344
|
+
] as const
|
|
1345
|
+
const registryKinds = getSelectableKinds().filter(
|
|
1346
|
+
(kind) => !(allTypes as readonly string[]).includes(kind),
|
|
1347
|
+
)
|
|
1348
|
+
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
|
|
1349
|
+
|
|
1350
|
+
for (const type of subscribedKinds) {
|
|
1351
|
+
emitter.on(`${type}:pointerdown` as any, onPointerDown as any)
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
return () => {
|
|
1355
|
+
for (const type of subscribedKinds) {
|
|
1356
|
+
emitter.off(`${type}:pointerdown` as any, onPointerDown as any)
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}, [isCurveReshape, mode, movingNode, camera, raycaster, glDomElement])
|
|
1360
|
+
|
|
1361
|
+
// Move cursor over the selected movable node: the visual cue that clicking it
|
|
1362
|
+
// picks it up (replaces the removed move-cross gizmo). Reacts only when the
|
|
1363
|
+
// hovered/selected node changes (not on every camera move) so it doesn't fight
|
|
1364
|
+
// the rotate/resize gizmos' own hover cursors. Clears only the cursor it owns.
|
|
1365
|
+
useEffect(() => {
|
|
1366
|
+
if (mode !== 'select') return
|
|
1367
|
+
let owns = false
|
|
1368
|
+
let prevKey = ''
|
|
1369
|
+
const applyCursor = () => {
|
|
1370
|
+
const { selection, hoveredId } = useViewer.getState()
|
|
1371
|
+
const selectedIds = selection.selectedIds
|
|
1372
|
+
const sole = selectedIds.length === 1 ? selectedIds[0] : null
|
|
1373
|
+
const key = `${hoveredId ?? ''}|${sole ?? ''}|${selectedIds.length}`
|
|
1374
|
+
if (key === prevKey) return
|
|
1375
|
+
prevKey = key
|
|
1376
|
+
let wantsMove = false
|
|
1377
|
+
if (hoveredId && !getMovingNode()) {
|
|
1378
|
+
if (sole === hoveredId) {
|
|
1379
|
+
const node = useScene.getState().nodes[sole as AnyNodeId]
|
|
1380
|
+
wantsMove = !!node && canDirectMoveNode(node)
|
|
1381
|
+
} else if (selectedIds.length > 1 && selectedIds.includes(hoveredId)) {
|
|
1382
|
+
// Group member: dragging it slides the whole selection.
|
|
1383
|
+
const nodes = useScene.getState().nodes
|
|
1384
|
+
wantsMove =
|
|
1385
|
+
classifyParticipant(nodes[hoveredId as AnyNodeId], selection.levelId, nodes) !== null
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (wantsMove) {
|
|
1389
|
+
glDomElement.style.cursor = 'move'
|
|
1390
|
+
owns = true
|
|
1391
|
+
} else if (owns) {
|
|
1392
|
+
glDomElement.style.cursor = ''
|
|
1393
|
+
owns = false
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
applyCursor()
|
|
1397
|
+
const unsub = useViewer.subscribe(applyCursor)
|
|
1398
|
+
return () => {
|
|
1399
|
+
unsub()
|
|
1400
|
+
if (owns) glDomElement.style.cursor = ''
|
|
1401
|
+
}
|
|
1402
|
+
}, [mode, glDomElement])
|
|
1403
|
+
|
|
1404
|
+
// While a node is actively being moved (click-to-move / Move button, or a
|
|
1405
|
+
// fresh preset placement), show a grabbing hand. Mode-independent: presets
|
|
1406
|
+
// move in build mode. Overrides the hover 'move' cursor (which bails while a
|
|
1407
|
+
// movingNode exists), and clears back to the canvas's custom cursor on drop.
|
|
1408
|
+
useEffect(() => {
|
|
1409
|
+
if (!movingNode) return
|
|
1410
|
+
glDomElement.style.cursor = 'grabbing'
|
|
1411
|
+
return () => {
|
|
1412
|
+
glDomElement.style.cursor = ''
|
|
1413
|
+
}
|
|
1414
|
+
}, [movingNode, glDomElement])
|
|
1415
|
+
|
|
1416
|
+
useEffect(() => {
|
|
1417
|
+
if (mode !== 'select') return
|
|
1418
|
+
if (movingNode || isCurveReshape) return
|
|
1419
|
+
|
|
1420
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
1421
|
+
if (event.button !== 2 || !isCommandModifier(event)) return
|
|
1422
|
+
if (!(event.target instanceof HTMLCanvasElement)) return
|
|
1423
|
+
|
|
1424
|
+
// Sole selection only — same stand-down as the Cmd-drag move above.
|
|
1425
|
+
const selectedIds = useViewer.getState().selection.selectedIds
|
|
1426
|
+
const hoveredId = useViewer.getState().hoveredId as AnyNodeId | null
|
|
1427
|
+
if (!hoveredId || selectedIds.length !== 1 || selectedIds[0] !== hoveredId) return
|
|
1428
|
+
|
|
1429
|
+
const node = useScene.getState().nodes[hoveredId]
|
|
1430
|
+
if (!node || !canDirectRotateNode(node)) return
|
|
1431
|
+
|
|
1432
|
+
event.preventDefault()
|
|
1433
|
+
event.stopPropagation()
|
|
1434
|
+
|
|
1435
|
+
const nodeId = node.id as AnyNodeId
|
|
1436
|
+
const pointerId = event.pointerId
|
|
1437
|
+
const startX = event.clientX
|
|
1438
|
+
const sceneApi = createSceneApi(useScene)
|
|
1439
|
+
let lastPatch: Partial<AnyNode> | null = null
|
|
1440
|
+
|
|
1441
|
+
const applyDelta = (moveEvent: PointerEvent) => {
|
|
1442
|
+
const delta = resolveDirectRotationDragDelta(
|
|
1443
|
+
startX,
|
|
1444
|
+
moveEvent.clientX,
|
|
1445
|
+
DIRECT_ROTATE_RADIANS_PER_PIXEL,
|
|
1446
|
+
moveEvent.shiftKey,
|
|
1447
|
+
)
|
|
1448
|
+
if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
|
|
1449
|
+
lastPatch = null
|
|
1450
|
+
useLiveNodeOverrides.getState().clear(nodeId)
|
|
1451
|
+
useScene.getState().markDirty(nodeId)
|
|
1452
|
+
return
|
|
1453
|
+
}
|
|
1454
|
+
const patch = resolveDirectRotationPatch(node, delta, sceneApi)
|
|
1455
|
+
if (!patch) return
|
|
1456
|
+
lastPatch = patch
|
|
1457
|
+
useLiveNodeOverrides.getState().set(nodeId, patch as Record<string, unknown>)
|
|
1458
|
+
useScene.getState().markDirty(nodeId)
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const cleanup = () => {
|
|
1462
|
+
window.removeEventListener('pointermove', onMove, true)
|
|
1463
|
+
window.removeEventListener('pointerup', onUp, true)
|
|
1464
|
+
window.removeEventListener('pointercancel', onCancel, true)
|
|
1465
|
+
window.removeEventListener('contextmenu', preventContextMenu, true)
|
|
1466
|
+
useLiveNodeOverrides.getState().clear(nodeId)
|
|
1467
|
+
useScene.getState().markDirty(nodeId)
|
|
1468
|
+
useDirectManipulationFeedback.getState().clearActiveRotateNodeId(nodeId)
|
|
1469
|
+
useScene.temporal.getState().resume()
|
|
1470
|
+
useViewer.getState().setInputDragging(false)
|
|
1471
|
+
if (document.body.style.cursor === 'ew-resize') {
|
|
1472
|
+
document.body.style.cursor = ''
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
1477
|
+
if (moveEvent.pointerId !== pointerId) return
|
|
1478
|
+
moveEvent.preventDefault()
|
|
1479
|
+
moveEvent.stopPropagation()
|
|
1480
|
+
applyDelta(moveEvent)
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
const onUp = (upEvent: PointerEvent) => {
|
|
1484
|
+
if (upEvent.pointerId !== pointerId) return
|
|
1485
|
+
upEvent.preventDefault()
|
|
1486
|
+
upEvent.stopPropagation()
|
|
1487
|
+
swallowNextClick()
|
|
1488
|
+
if (lastPatch) {
|
|
1489
|
+
sceneApi.update(nodeId, lastPatch)
|
|
1490
|
+
sfxEmitter.emit('sfx:item-place')
|
|
1491
|
+
}
|
|
1492
|
+
cleanup()
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const onCancel = (cancelEvent: PointerEvent) => {
|
|
1496
|
+
if (cancelEvent.pointerId !== pointerId) return
|
|
1497
|
+
cleanup()
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const preventContextMenu = (contextEvent: Event) => {
|
|
1501
|
+
contextEvent.preventDefault()
|
|
1502
|
+
contextEvent.stopPropagation()
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
useViewer.getState().setInputDragging(true)
|
|
1506
|
+
useDirectManipulationFeedback.getState().setActiveRotateNodeId(nodeId)
|
|
1507
|
+
useScene.temporal.getState().pause()
|
|
1508
|
+
document.body.style.cursor = 'ew-resize'
|
|
1509
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
1510
|
+
applyDelta(event)
|
|
1511
|
+
|
|
1512
|
+
window.addEventListener('pointermove', onMove, true)
|
|
1513
|
+
window.addEventListener('pointerup', onUp, true)
|
|
1514
|
+
window.addEventListener('pointercancel', onCancel, true)
|
|
1515
|
+
window.addEventListener('contextmenu', preventContextMenu, true)
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
window.addEventListener('pointerdown', onPointerDown, true)
|
|
1519
|
+
return () => {
|
|
1520
|
+
window.removeEventListener('pointerdown', onPointerDown, true)
|
|
1521
|
+
}
|
|
1522
|
+
}, [isCurveReshape, mode, movingNode])
|
|
1523
|
+
|
|
1524
|
+
useEffect(() => {
|
|
1525
|
+
if (mode !== 'select') return
|
|
1526
|
+
if (movingNode || isCurveReshape) return
|
|
1259
1527
|
|
|
1260
1528
|
const onClick = (event: NodeEvent) => {
|
|
1261
1529
|
// Skip if box-select just completed (drag ended over a node)
|
|
1262
1530
|
if (boxSelectHandled) return
|
|
1263
1531
|
|
|
1264
|
-
|
|
1532
|
+
// node:click is synthesized on pointer-up (use-node-events). A wall/fence
|
|
1533
|
+
// endpoint handle sits ON the wall body, so from a 3D angle the wall mesh
|
|
1534
|
+
// is raycast-hit behind it and the SAME pointer-up also emits the wall's
|
|
1535
|
+
// click — which would select + arm the wall move tool on top of the
|
|
1536
|
+
// endpoint move. While an endpoint reshape owns the pointer, ignore the
|
|
1537
|
+
// body click so only the reshape tool handles the release. (Scoped to
|
|
1538
|
+
// `endpoint`: hole-edit relies on node clicks to exit, just below.)
|
|
1539
|
+
const activeScope = useInteractionScope.getState().scope
|
|
1540
|
+
if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return
|
|
1541
|
+
|
|
1542
|
+
if (dispatchSceneAction(event.node, getEventObject(event))) {
|
|
1543
|
+
event.stopPropagation()
|
|
1544
|
+
clickHandledRef.current = true
|
|
1545
|
+
setTimeout(() => {
|
|
1546
|
+
clickHandledRef.current = false
|
|
1547
|
+
}, 50)
|
|
1548
|
+
return
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
const node = resolveCanvasSelectionNode({
|
|
1552
|
+
node: resolveSelectModeNodeTarget(event),
|
|
1553
|
+
nodes: useScene.getState().nodes,
|
|
1554
|
+
selectedIds: useViewer.getState().selection.selectedIds,
|
|
1555
|
+
})
|
|
1265
1556
|
|
|
1266
1557
|
// A ceiling is selectable only through its corner handles, never via
|
|
1267
1558
|
// the `ceiling-grid` body mesh. When the grid is revealed (ceiling
|
|
@@ -1274,12 +1565,13 @@ export const SelectionManager = () => {
|
|
|
1274
1565
|
|
|
1275
1566
|
let currentPhase = useEditor.getState().phase
|
|
1276
1567
|
let currentStructureLayer = useEditor.getState().structureLayer
|
|
1568
|
+
const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
|
|
1277
1569
|
|
|
1278
1570
|
// Auto-switch between zones, structure, and furnish when clicking elements on the same level.
|
|
1279
1571
|
// Also auto-switch from site phase when clicking structural/furnish elements (e.g. 2D floorplan).
|
|
1280
1572
|
if (currentPhase === 'structure' || currentPhase === 'furnish' || currentPhase === 'site') {
|
|
1281
1573
|
if (isNodeInCurrentLevel(node)) {
|
|
1282
|
-
const target =
|
|
1574
|
+
const target = resolveNodeSelectionTarget(node)
|
|
1283
1575
|
if (target) {
|
|
1284
1576
|
if (target.phase !== currentPhase) {
|
|
1285
1577
|
useEditor.getState().setPhase(target.phase)
|
|
@@ -1302,35 +1594,60 @@ export const SelectionManager = () => {
|
|
|
1302
1594
|
if (activeStrategy?.isValid(node)) {
|
|
1303
1595
|
event.stopPropagation()
|
|
1304
1596
|
clickHandledRef.current = true
|
|
1597
|
+
// Reset the handled flag after a short delay so the grid:click that the
|
|
1598
|
+
// SAME DOM click also raycasts is ignored (it fires synchronously, before
|
|
1599
|
+
// this 50ms macrotask). Scheduled here — right after the flag is set — so
|
|
1600
|
+
// EVERY branch below clears it, including the click-to-move early return
|
|
1601
|
+
// (which previously skipped the reset and left empty-click deselect stuck
|
|
1602
|
+
// until the next normal select).
|
|
1603
|
+
setTimeout(() => {
|
|
1604
|
+
clickHandledRef.current = false
|
|
1605
|
+
}, 50)
|
|
1305
1606
|
|
|
1306
1607
|
let nodeToSelect = node
|
|
1307
|
-
if (node.type === 'roof-segment' && node.parentId) {
|
|
1308
|
-
const nodes = useScene.getState().nodes
|
|
1309
|
-
const parentNode = nodes[node.parentId as AnyNodeId]
|
|
1310
|
-
const selectedIds = useViewer.getState().selection.selectedIds
|
|
1311
|
-
if (
|
|
1312
|
-
parentNode &&
|
|
1313
|
-
parentNode.type === 'roof' &&
|
|
1314
|
-
!isInActiveRoofContext(node, selectedIds, nodes)
|
|
1315
|
-
) {
|
|
1316
|
-
nodeToSelect = parentNode
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
1608
|
if (node.type === 'stair-segment' && node.parentId) {
|
|
1320
1609
|
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
|
|
1321
1610
|
if (parentNode && parentNode.type === 'stair') {
|
|
1322
1611
|
nodeToSelect = parentNode
|
|
1323
1612
|
}
|
|
1324
1613
|
}
|
|
1325
|
-
|
|
1614
|
+
nodeToSelect = resolveCanvasSelectionNode({
|
|
1615
|
+
node: nodeToSelect,
|
|
1616
|
+
nodes: useScene.getState().nodes,
|
|
1617
|
+
selectedIds: selectedIdsBeforeRouting,
|
|
1618
|
+
})
|
|
1326
1619
|
// Clicking any node (e.g. the slab surface outside a hole) exits slab
|
|
1327
1620
|
// hole-edit mode. The hole handles + hit mesh stopPropagation, so a
|
|
1328
1621
|
// click reaching here means the user clicked outside the hole.
|
|
1329
|
-
if (
|
|
1330
|
-
|
|
1622
|
+
if (getEditingHole()) {
|
|
1623
|
+
useInteractionScope
|
|
1624
|
+
.getState()
|
|
1625
|
+
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// Click-to-move: clicking the already-selected sole movable node with
|
|
1629
|
+
// no modifiers picks it up instead of re-selecting — the move-cross
|
|
1630
|
+
// gizmo's old job, now on the node body. `setMovingNode` arms the
|
|
1631
|
+
// registry move tool in click-to-commit mode, exactly like the floating
|
|
1632
|
+
// Move button. The first (selecting) click can't hit this because the
|
|
1633
|
+
// node isn't yet in `selectedIdsBeforeRouting`.
|
|
1634
|
+
const nativeEvent = event.nativeEvent
|
|
1635
|
+
const hasModifier = nativeEvent.shiftKey || isCommandModifier(nativeEvent)
|
|
1636
|
+
const isAlreadySole =
|
|
1637
|
+
selectedIdsBeforeRouting.length === 1 && selectedIdsBeforeRouting[0] === nodeToSelect.id
|
|
1638
|
+
if (!hasModifier && isAlreadySole && !getMovingNode() && canDirectMoveNode(nodeToSelect)) {
|
|
1639
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
1640
|
+
useEditor.getState().setMovingNode(nodeToSelect as never)
|
|
1641
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
1642
|
+
return
|
|
1331
1643
|
}
|
|
1332
1644
|
|
|
1333
|
-
activeStrategy.handleSelect(
|
|
1645
|
+
activeStrategy.handleSelect(
|
|
1646
|
+
nodeToSelect,
|
|
1647
|
+
event.nativeEvent,
|
|
1648
|
+
modifierKeysRef.current,
|
|
1649
|
+
selectedIdsBeforeRouting,
|
|
1650
|
+
)
|
|
1334
1651
|
|
|
1335
1652
|
let nextMaterialTargetHandled = false
|
|
1336
1653
|
|
|
@@ -1351,6 +1668,8 @@ export const SelectionManager = () => {
|
|
|
1351
1668
|
normal: event.normal,
|
|
1352
1669
|
localPosition: event.localPosition as readonly [number, number, number] | undefined,
|
|
1353
1670
|
hitObjectName: event.nativeEvent.object?.name,
|
|
1671
|
+
hitObject: getEventObject(event),
|
|
1672
|
+
ray: event.nativeEvent.ray,
|
|
1354
1673
|
})
|
|
1355
1674
|
if (role) {
|
|
1356
1675
|
setSelectedMaterialTargetForNode(nodeToSelect, role as MaterialTargetRole)
|
|
@@ -1398,11 +1717,6 @@ export const SelectionManager = () => {
|
|
|
1398
1717
|
if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
|
|
1399
1718
|
useEditor.getState().setSelectedMaterialTarget(null)
|
|
1400
1719
|
}
|
|
1401
|
-
|
|
1402
|
-
// Reset the handled flag after a short delay to allow grid:click to be ignored
|
|
1403
|
-
setTimeout(() => {
|
|
1404
|
-
clickHandledRef.current = false
|
|
1405
|
-
}, 50)
|
|
1406
1720
|
}
|
|
1407
1721
|
}
|
|
1408
1722
|
|
|
@@ -1435,9 +1749,11 @@ export const SelectionManager = () => {
|
|
|
1435
1749
|
emitter.on(`${type}:click` as any, onClick as any)
|
|
1436
1750
|
})
|
|
1437
1751
|
|
|
1438
|
-
const onGridClick = () => {
|
|
1752
|
+
const onGridClick = (event: GridEvent) => {
|
|
1439
1753
|
if (clickHandledRef.current) return
|
|
1440
1754
|
if (boxSelectHandled) return
|
|
1755
|
+
const nativeEvent = event.nativeEvent
|
|
1756
|
+
if (nativeEvent?.metaKey || nativeEvent?.ctrlKey || nativeEvent?.shiftKey) return
|
|
1441
1757
|
const { phase, structureLayer } = useEditor.getState()
|
|
1442
1758
|
const activeStrategy = SELECTION_STRATEGIES[phase]
|
|
1443
1759
|
if (activeStrategy) activeStrategy.handleDeselect()
|
|
@@ -1457,15 +1773,24 @@ export const SelectionManager = () => {
|
|
|
1457
1773
|
})
|
|
1458
1774
|
emitter.off('grid:click', onGridClick)
|
|
1459
1775
|
}
|
|
1460
|
-
}, [
|
|
1776
|
+
}, [isCurveReshape, mode, movingNode])
|
|
1461
1777
|
|
|
1462
1778
|
// Global double-click handler for auto-switching phases and cross-phase hover
|
|
1463
1779
|
useEffect(() => {
|
|
1464
1780
|
if (mode !== 'select') return
|
|
1465
|
-
if (movingNode ||
|
|
1781
|
+
if (movingNode || isCurveReshape) return
|
|
1466
1782
|
|
|
1467
1783
|
const onEnter = (event: NodeEvent) => {
|
|
1468
|
-
|
|
1784
|
+
// A host-driven drag (handle resize/rotate, box-select) sets
|
|
1785
|
+
// `inputDragging`. useNodeEvents still emits hover events during it so
|
|
1786
|
+
// surface move tools keep tracking — but the select-hover outline must
|
|
1787
|
+
// stay put, so don't repaint under the cursor mid-drag.
|
|
1788
|
+
if (useViewer.getState().inputDragging) return
|
|
1789
|
+
const node = resolveCanvasSelectionNode({
|
|
1790
|
+
node: resolveSelectModeNodeTarget(event),
|
|
1791
|
+
nodes: useScene.getState().nodes,
|
|
1792
|
+
selectedIds: useViewer.getState().selection.selectedIds,
|
|
1793
|
+
})
|
|
1469
1794
|
const currentPhase = useEditor.getState().phase
|
|
1470
1795
|
|
|
1471
1796
|
// Ignore site/building if we are already inside a building
|
|
@@ -1492,21 +1817,30 @@ export const SelectionManager = () => {
|
|
|
1492
1817
|
}
|
|
1493
1818
|
|
|
1494
1819
|
const onLeave = (event: NodeEvent) => {
|
|
1495
|
-
|
|
1820
|
+
if (useViewer.getState().inputDragging) return
|
|
1821
|
+
const nodeId = resolveCanvasSelectionNode({
|
|
1822
|
+
node: resolveSelectModeNodeTarget(event),
|
|
1823
|
+
nodes: useScene.getState().nodes,
|
|
1824
|
+
selectedIds: useViewer.getState().selection.selectedIds,
|
|
1825
|
+
})?.id
|
|
1496
1826
|
if (nodeId && useViewer.getState().hoveredId === nodeId) {
|
|
1497
1827
|
useViewer.setState({ hoveredId: null })
|
|
1498
1828
|
}
|
|
1499
1829
|
}
|
|
1500
1830
|
|
|
1501
1831
|
const onDoubleClick = (event: NodeEvent) => {
|
|
1502
|
-
let node =
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1832
|
+
let node = resolveCanvasSelectionNode({
|
|
1833
|
+
node: resolveSelectModeNodeTarget(event),
|
|
1834
|
+
nodes: useScene.getState().nodes,
|
|
1835
|
+
selectedIds: useViewer.getState().selection.selectedIds,
|
|
1836
|
+
})
|
|
1506
1837
|
|
|
1507
1838
|
const currentPhase = useEditor.getState().phase
|
|
1508
1839
|
|
|
1509
|
-
|
|
1840
|
+
const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
|
|
1841
|
+
const target = resolveNodeSelectionTarget(node)
|
|
1842
|
+
let targetPhase: 'site' | 'structure' | 'furnish' | null = target?.phase ?? null
|
|
1843
|
+
let targetStructureLayer = target?.structureLayer
|
|
1510
1844
|
let forceSelect = false
|
|
1511
1845
|
|
|
1512
1846
|
if (node.type === 'building' || node.type === 'site') {
|
|
@@ -1515,36 +1849,15 @@ export const SelectionManager = () => {
|
|
|
1515
1849
|
}
|
|
1516
1850
|
if (node.type === 'building') {
|
|
1517
1851
|
targetPhase = 'structure'
|
|
1852
|
+
targetStructureLayer = 'elements'
|
|
1518
1853
|
}
|
|
1519
|
-
} else
|
|
1520
|
-
node.type === 'wall' ||
|
|
1521
|
-
node.type === 'fence' ||
|
|
1522
|
-
node.type === 'column' ||
|
|
1523
|
-
node.type === 'elevator' ||
|
|
1524
|
-
node.type === 'slab' ||
|
|
1525
|
-
node.type === 'ceiling' ||
|
|
1526
|
-
node.type === 'roof' ||
|
|
1527
|
-
node.type === 'roof-segment' ||
|
|
1528
|
-
node.type === 'stair' ||
|
|
1529
|
-
node.type === 'stair-segment' ||
|
|
1530
|
-
node.type === 'spawn' ||
|
|
1531
|
-
node.type === 'window' ||
|
|
1532
|
-
node.type === 'door'
|
|
1533
|
-
) {
|
|
1534
|
-
targetPhase = 'structure'
|
|
1854
|
+
} else {
|
|
1535
1855
|
if (node.type === 'roof-segment' && currentPhase === 'structure') {
|
|
1536
1856
|
forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
|
|
1537
1857
|
}
|
|
1538
1858
|
if (node.type === 'stair-segment' && currentPhase === 'structure') {
|
|
1539
1859
|
forceSelect = true // allow double click to dive into stair-segment even if already in structure phase
|
|
1540
1860
|
}
|
|
1541
|
-
} else if (node.type === 'item') {
|
|
1542
|
-
const item = node as ItemNode
|
|
1543
|
-
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
|
1544
|
-
targetPhase = 'structure'
|
|
1545
|
-
} else {
|
|
1546
|
-
targetPhase = 'furnish'
|
|
1547
|
-
}
|
|
1548
1861
|
}
|
|
1549
1862
|
|
|
1550
1863
|
if (node.type === 'zone') {
|
|
@@ -1558,13 +1871,22 @@ export const SelectionManager = () => {
|
|
|
1558
1871
|
useEditor.getState().setPhase(targetPhase)
|
|
1559
1872
|
}
|
|
1560
1873
|
|
|
1561
|
-
if (
|
|
1562
|
-
|
|
1874
|
+
if (
|
|
1875
|
+
targetPhase === 'structure' &&
|
|
1876
|
+
targetStructureLayer &&
|
|
1877
|
+
targetStructureLayer !== useEditor.getState().structureLayer
|
|
1878
|
+
) {
|
|
1879
|
+
useEditor.getState().setStructureLayer(targetStructureLayer)
|
|
1563
1880
|
}
|
|
1564
1881
|
|
|
1565
1882
|
const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase]
|
|
1566
1883
|
if (strategy) {
|
|
1567
|
-
strategy.handleSelect(
|
|
1884
|
+
strategy.handleSelect(
|
|
1885
|
+
node,
|
|
1886
|
+
event.nativeEvent,
|
|
1887
|
+
modifierKeysRef.current,
|
|
1888
|
+
selectedIdsBeforeRouting,
|
|
1889
|
+
)
|
|
1568
1890
|
}
|
|
1569
1891
|
}
|
|
1570
1892
|
}
|
|
@@ -1606,7 +1928,7 @@ export const SelectionManager = () => {
|
|
|
1606
1928
|
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
|
|
1607
1929
|
})
|
|
1608
1930
|
}
|
|
1609
|
-
}, [
|
|
1931
|
+
}, [isCurveReshape, mode, movingNode])
|
|
1610
1932
|
|
|
1611
1933
|
// Delete mode: click-to-delete (sledgehammer tool)
|
|
1612
1934
|
useEffect(() => {
|
|
@@ -1696,6 +2018,8 @@ export const SelectionManager = () => {
|
|
|
1696
2018
|
const SelectionStateSync = () => {
|
|
1697
2019
|
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
|
1698
2020
|
const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget)
|
|
2021
|
+
const roofHostDragArmedId = useEditor((s) => s.roofHostDragArmedId)
|
|
2022
|
+
const setRoofHostDragArmedId = useEditor((s) => s.setRoofHostDragArmedId)
|
|
1699
2023
|
const singleSelectedId = useViewer((s) =>
|
|
1700
2024
|
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
|
|
1701
2025
|
)
|
|
@@ -1728,6 +2052,12 @@ const SelectionStateSync = () => {
|
|
|
1728
2052
|
})
|
|
1729
2053
|
}, [])
|
|
1730
2054
|
|
|
2055
|
+
useEffect(() => {
|
|
2056
|
+
if (!roofHostDragArmedId) return
|
|
2057
|
+
if (singleSelectedId === roofHostDragArmedId) return
|
|
2058
|
+
setRoofHostDragArmedId(null)
|
|
2059
|
+
}, [roofHostDragArmedId, setRoofHostDragArmedId, singleSelectedId])
|
|
2060
|
+
|
|
1731
2061
|
useEffect(() => {
|
|
1732
2062
|
if (!selectedMaterialTarget) return
|
|
1733
2063
|
|
|
@@ -1739,7 +2069,8 @@ const SelectionStateSync = () => {
|
|
|
1739
2069
|
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
|
|
1740
2070
|
if (
|
|
1741
2071
|
!selectedNode ||
|
|
1742
|
-
(selectedNode.type
|
|
2072
|
+
(!nodeRegistry.get(selectedNode.type)?.capabilities?.paint &&
|
|
2073
|
+
selectedNode.type !== 'wall' &&
|
|
1743
2074
|
selectedNode.type !== 'fence' &&
|
|
1744
2075
|
selectedNode.type !== 'slab' &&
|
|
1745
2076
|
selectedNode.type !== 'ceiling' &&
|
|
@@ -1763,6 +2094,7 @@ const SelectionMaterialSync = () => {
|
|
|
1763
2094
|
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
|
1764
2095
|
const hoveredId = useViewer((s) => s.hoveredId)
|
|
1765
2096
|
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
|
|
2097
|
+
const geometryRevision = useViewer((s) => s.geometryRevision)
|
|
1766
2098
|
const activeHighlightKindsRef = useRef(new Map<string, HighlightKind>())
|
|
1767
2099
|
const highlightedMaterialsRef = useRef(
|
|
1768
2100
|
new Map<
|
|
@@ -1839,6 +2171,7 @@ const SelectionMaterialSync = () => {
|
|
|
1839
2171
|
}, [])
|
|
1840
2172
|
|
|
1841
2173
|
useEffect(() => {
|
|
2174
|
+
void geometryRevision
|
|
1842
2175
|
const nextHighlightKinds = new Map<string, HighlightKind>()
|
|
1843
2176
|
|
|
1844
2177
|
for (const id of new Set([...selectedIds, ...previewSelectedIds])) {
|
|
@@ -1851,7 +2184,14 @@ const SelectionMaterialSync = () => {
|
|
|
1851
2184
|
|
|
1852
2185
|
activeHighlightKindsRef.current = nextHighlightKinds
|
|
1853
2186
|
syncSelectionMaterials()
|
|
1854
|
-
}, [
|
|
2187
|
+
}, [
|
|
2188
|
+
geometryRevision,
|
|
2189
|
+
hoverHighlightMode,
|
|
2190
|
+
hoveredId,
|
|
2191
|
+
previewSelectedIds,
|
|
2192
|
+
selectedIds,
|
|
2193
|
+
syncSelectionMaterials,
|
|
2194
|
+
])
|
|
1855
2195
|
|
|
1856
2196
|
useEffect(() => {
|
|
1857
2197
|
return useScene.subscribe((state, prevState) => {
|
|
@@ -1906,10 +2246,12 @@ const EditorOutlinerSync = () => {
|
|
|
1906
2246
|
const selection = useViewer((s) => s.selection)
|
|
1907
2247
|
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
|
1908
2248
|
const hoveredId = useViewer((s) => s.hoveredId)
|
|
2249
|
+
const geometryRevision = useViewer((s) => s.geometryRevision)
|
|
1909
2250
|
const outliner = useViewer((s) => s.outliner)
|
|
1910
2251
|
const nodes = useScene((s) => s.nodes)
|
|
1911
2252
|
|
|
1912
2253
|
useEffect(() => {
|
|
2254
|
+
void geometryRevision
|
|
1913
2255
|
let idsToHighlight: string[] = []
|
|
1914
2256
|
|
|
1915
2257
|
// 1. Determine what should be highlighted based on Phase
|
|
@@ -1954,11 +2296,15 @@ const EditorOutlinerSync = () => {
|
|
|
1954
2296
|
if (!nodes[hoveredId as AnyNodeId]) {
|
|
1955
2297
|
useViewer.setState({ hoveredId: null })
|
|
1956
2298
|
} else {
|
|
1957
|
-
const
|
|
2299
|
+
const hoveredNode = nodes[hoveredId as AnyNodeId]
|
|
2300
|
+
const obj =
|
|
2301
|
+
hoveredNode?.type === 'roof-segment'
|
|
2302
|
+
? (getHoveredRoofSegmentOutlineProxy(hoveredId) ?? sceneRegistry.nodes.get(hoveredId))
|
|
2303
|
+
: sceneRegistry.nodes.get(hoveredId)
|
|
1958
2304
|
if (obj?.parent) outliner.hoveredObjects.push(obj)
|
|
1959
2305
|
}
|
|
1960
2306
|
}
|
|
1961
|
-
}, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes])
|
|
2307
|
+
}, [geometryRevision, phase, previewSelectedIds, selection, hoveredId, outliner, nodes])
|
|
1962
2308
|
|
|
1963
2309
|
return null
|
|
1964
2310
|
}
|