@pascal-app/editor 0.8.0 → 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 +15 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +795 -28
- package/src/components/editor/editor-layout-mobile.tsx +8 -3
- package/src/components/editor/editor-layout-v2.tsx +76 -51
- package/src/components/editor/export-manager.tsx +69 -32
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
- package/src/components/editor/first-person/build-collider-world.ts +228 -83
- package/src/components/editor/first-person-controls.tsx +943 -27
- package/src/components/editor/floating-action-menu.tsx +676 -208
- package/src/components/editor/floating-building-action-menu.tsx +2 -2
- 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 +6916 -12137
- package/src/components/editor/grid.tsx +197 -36
- 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 +420 -0
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +396 -0
- package/src/components/editor/group-transform-shared.ts +489 -0
- package/src/components/editor/handles/handle-arrow.tsx +581 -0
- package/src/components/editor/handles/use-handle-drag.ts +246 -0
- package/src/components/editor/index.tsx +412 -162
- package/src/components/editor/measurement-pill.tsx +115 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +1646 -0
- 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 +1106 -483
- package/src/components/editor/site-edge-labels.tsx +45 -21
- package/src/components/editor/slab-hole-highlights.tsx +474 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +140 -41
- package/src/components/editor/use-floorplan-background-placement.ts +190 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +19 -7
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +14 -19
- package/src/components/editor/wall-move-side-handles.tsx +870 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-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 +392 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-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-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
- package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- 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-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +375 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
- package/src/components/tools/fence/fence-drafting.ts +141 -27
- package/src/components/tools/item/move-tool.tsx +45 -105
- package/src/components/tools/item/placement-math.test.ts +22 -0
- package/src/components/tools/item/placement-math.ts +59 -7
- package/src/components/tools/item/placement-strategies.ts +432 -16
- package/src/components/tools/item/placement-types.ts +22 -1
- package/src/components/tools/item/use-draft-node.ts +51 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
- package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
- package/src/components/tools/roof/roof-tool.tsx +350 -28
- package/src/components/tools/select/box-select-state.ts +88 -0
- package/src/components/tools/select/box-select-tool.tsx +442 -520
- 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 +570 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
- 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/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +305 -0
- package/src/components/tools/shared/polygon-editor.tsx +866 -175
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +520 -16
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +2 -1
- package/src/components/tools/stair/stair-tool.tsx +342 -37
- package/src/components/tools/tool-manager.tsx +239 -62
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +248 -171
- package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +86 -82
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -170
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +3 -117
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +27 -93
- package/src/components/ui/action-menu/view-toggles.tsx +279 -90
- package/src/components/ui/command-palette/editor-commands.tsx +20 -15
- package/src/components/ui/command-palette/index.tsx +5 -3
- package/src/components/ui/controls/material-paint-panel.tsx +152 -0
- package/src/components/ui/controls/material-picker.tsx +87 -177
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +142 -46
- 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 +47 -10
- 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 +235 -23
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
- 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 +40 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +17 -16
- package/src/components/ui/panels/panel-manager.tsx +55 -58
- package/src/components/ui/panels/panel-wrapper.tsx +238 -12
- package/src/components/ui/panels/parametric-inspector.tsx +482 -0
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- 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/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
- 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 +92 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
- 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 +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -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 +112 -39
- 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/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +75 -1
- 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 +253 -61
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +64 -10
- package/src/hooks/use-ceiling-events.ts +176 -0
- package/src/hooks/use-drag-action.ts +127 -0
- package/src/hooks/use-keyboard.ts +493 -95
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +451 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/constants.ts +6 -2
- 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 +50 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +117 -0
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +10 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +339 -0
- package/src/lib/fresh-planar-placement.ts +130 -0
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +11 -1
- 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-duplication.ts +54 -42
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +227 -48
- 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/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +100 -0
- package/src/lib/planar-cursor-placement.ts +42 -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 +7 -7
- 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 +332 -0
- package/src/lib/scene.ts +40 -8
- 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 +69 -10
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +148 -55
- package/src/lib/site-boundary.ts +1 -0
- 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/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- 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 +231 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +767 -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 +39 -0
- 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-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +34 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -2,60 +2,89 @@ import {
|
|
|
2
2
|
type AnyNode,
|
|
3
3
|
type AnyNodeId,
|
|
4
4
|
type BuildingNode,
|
|
5
|
-
|
|
6
|
-
type ColumnNode,
|
|
5
|
+
createSceneApi,
|
|
7
6
|
emitter,
|
|
8
|
-
type
|
|
9
|
-
|
|
7
|
+
type GridEvent,
|
|
8
|
+
getEffectiveRoofSurfaceMaterial,
|
|
9
|
+
getEffectiveSegmentSurfaceMaterial,
|
|
10
|
+
getRoofSegmentSurfaceY,
|
|
11
|
+
getSelectableKinds,
|
|
10
12
|
type ItemNode,
|
|
13
|
+
isRegistrySelectable,
|
|
11
14
|
type NodeEvent,
|
|
15
|
+
nodeRegistry,
|
|
12
16
|
type RoofEvent,
|
|
13
17
|
type RoofNode,
|
|
14
18
|
type RoofSegmentEvent,
|
|
19
|
+
type RoofSegmentNode,
|
|
15
20
|
resolveLevelId,
|
|
16
|
-
resolveMaterial,
|
|
17
|
-
type SlabNode,
|
|
18
21
|
type StairEvent,
|
|
19
|
-
type StairNode,
|
|
20
22
|
type StairSegmentEvent,
|
|
21
23
|
type StairSurfaceMaterialRole,
|
|
22
24
|
sceneRegistry,
|
|
25
|
+
useLiveNodeOverrides,
|
|
23
26
|
useScene,
|
|
24
|
-
type WallEvent,
|
|
25
|
-
type WallNode,
|
|
26
|
-
type WallSurfaceSide,
|
|
27
27
|
} from '@pascal-app/core'
|
|
28
28
|
|
|
29
29
|
import {
|
|
30
|
-
applyMaterialPresetToMaterials,
|
|
31
30
|
createMaterial,
|
|
32
31
|
createMaterialFromPresetRef,
|
|
33
32
|
getRoofMaterialArray,
|
|
34
|
-
getStairBodyMaterials,
|
|
35
|
-
getStairRailingMaterial,
|
|
36
|
-
getVisibleWallMaterials,
|
|
37
33
|
useViewer,
|
|
38
34
|
} from '@pascal-app/viewer'
|
|
35
|
+
import { useThree } from '@react-three/fiber'
|
|
39
36
|
import { useCallback, useEffect, useRef } from 'react'
|
|
40
|
-
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
|
|
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'
|
|
41
45
|
import {
|
|
42
46
|
type ActivePaintMaterial,
|
|
47
|
+
buildRoofSegmentSurfaceMaterialPatch,
|
|
43
48
|
buildRoofSurfaceMaterialPatch,
|
|
44
|
-
buildSingleSurfaceMaterialPatch,
|
|
45
|
-
buildStairSurfaceMaterialPatch,
|
|
46
|
-
buildWallSurfaceMaterialPatch,
|
|
47
49
|
hasActivePaintMaterial,
|
|
48
50
|
resolveActivePaintMaterialFromSelection,
|
|
49
51
|
} from '../../lib/material-paint'
|
|
50
|
-
import {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
type
|
|
55
|
-
|
|
56
|
-
|
|
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'
|
|
57
84
|
|
|
58
85
|
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
|
86
|
+
// Elevators are building-scoped, so they stay selectable across level filters.
|
|
87
|
+
if (node.type === 'elevator') return true
|
|
59
88
|
const currentLevelId = useViewer.getState().selection.levelId
|
|
60
89
|
if (!currentLevelId) return true // No level selected, allow all
|
|
61
90
|
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes)
|
|
@@ -68,6 +97,7 @@ type SelectableNodeType =
|
|
|
68
97
|
| 'item'
|
|
69
98
|
| 'column'
|
|
70
99
|
| 'building'
|
|
100
|
+
| 'elevator'
|
|
71
101
|
| 'zone'
|
|
72
102
|
| 'slab'
|
|
73
103
|
| 'ceiling'
|
|
@@ -79,11 +109,6 @@ type SelectableNodeType =
|
|
|
79
109
|
| 'window'
|
|
80
110
|
| 'door'
|
|
81
111
|
|
|
82
|
-
type ModifierKeys = {
|
|
83
|
-
meta: boolean
|
|
84
|
-
ctrl: boolean
|
|
85
|
-
}
|
|
86
|
-
|
|
87
112
|
type PaintPreviewCleanup = () => void
|
|
88
113
|
|
|
89
114
|
type PaintInteraction = {
|
|
@@ -92,18 +117,40 @@ type PaintInteraction = {
|
|
|
92
117
|
hoverMode: HoverHighlightMode
|
|
93
118
|
hoveredId: AnyNodeId
|
|
94
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
|
|
95
123
|
}
|
|
96
124
|
|
|
97
125
|
interface SelectionStrategy {
|
|
98
126
|
types: SelectableNodeType[]
|
|
99
|
-
handleSelect: (
|
|
127
|
+
handleSelect: (
|
|
128
|
+
node: AnyNode,
|
|
129
|
+
nativeEvent?: any,
|
|
130
|
+
modifierKeys?: SelectionModifierKeys,
|
|
131
|
+
baseSelectedIds?: readonly string[],
|
|
132
|
+
) => void
|
|
100
133
|
handleDeselect: () => void
|
|
101
134
|
isValid: (node: AnyNode) => boolean
|
|
102
135
|
}
|
|
103
136
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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)
|
|
107
154
|
}
|
|
108
155
|
|
|
109
156
|
export const resolveBuildingId = (
|
|
@@ -118,33 +165,6 @@ export const resolveBuildingId = (
|
|
|
118
165
|
return null
|
|
119
166
|
}
|
|
120
167
|
|
|
121
|
-
function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
|
|
122
|
-
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
|
|
123
|
-
if (materialIndex === 1) return 'interior'
|
|
124
|
-
if (materialIndex === 2) return 'exterior'
|
|
125
|
-
|
|
126
|
-
const normalZ = event.normal?.[2]
|
|
127
|
-
const localZ = event.localPosition[2]
|
|
128
|
-
const thickness = event.node.thickness ?? 0.1
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
normalZ === undefined ||
|
|
132
|
-
Math.abs(normalZ) < 0.65 ||
|
|
133
|
-
Math.abs(localZ) < Math.max(thickness * 0.2, 0.01)
|
|
134
|
-
) {
|
|
135
|
-
return null
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const hitFace = localZ >= 0 ? 'front' : 'back'
|
|
139
|
-
const semantic = hitFace === 'front' ? event.node.frontSide : event.node.backSide
|
|
140
|
-
|
|
141
|
-
if (semantic === 'interior' || semantic === 'exterior') {
|
|
142
|
-
return semantic
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return hitFace === 'front' ? 'interior' : 'exterior'
|
|
146
|
-
}
|
|
147
|
-
|
|
148
168
|
function resolveStairMaterialTarget(
|
|
149
169
|
event: StairEvent | StairSegmentEvent,
|
|
150
170
|
): StairSurfaceMaterialRole | null {
|
|
@@ -200,6 +220,26 @@ function getEventObject(event: NodeEvent): Object3D {
|
|
|
200
220
|
return eventWithObject.object ?? event.nativeEvent.object
|
|
201
221
|
}
|
|
202
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
|
+
|
|
203
243
|
function getIntersectionMaterialIndex(
|
|
204
244
|
object: Object3D,
|
|
205
245
|
faceIndex: number | undefined,
|
|
@@ -226,6 +266,107 @@ function getRegisteredMesh(nodeId: string): Mesh | null {
|
|
|
226
266
|
return object && (object as Mesh).isMesh ? (object as Mesh) : null
|
|
227
267
|
}
|
|
228
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
|
+
|
|
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
|
+
}
|
|
315
|
+
|
|
316
|
+
function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
|
|
317
|
+
const roof = event.node
|
|
318
|
+
if (roof.type !== 'roof') return null
|
|
319
|
+
|
|
320
|
+
roofSelectionWorldPoint.set(...event.position)
|
|
321
|
+
const nodes = useScene.getState().nodes
|
|
322
|
+
let firstSegment: RoofSegmentNode | null = null
|
|
323
|
+
let bestSegment: { node: RoofSegmentNode; score: number } | null = null
|
|
324
|
+
|
|
325
|
+
for (const childId of roof.children ?? []) {
|
|
326
|
+
const segment = nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
|
|
327
|
+
if (segment?.type !== 'roof-segment') continue
|
|
328
|
+
|
|
329
|
+
const object = getRegisteredNodeObject(segment.id)
|
|
330
|
+
if (!object) continue
|
|
331
|
+
|
|
332
|
+
if (!firstSegment) firstSegment = segment
|
|
333
|
+
|
|
334
|
+
object.updateWorldMatrix(true, false)
|
|
335
|
+
const local = object.worldToLocal(roofSelectionWorldPoint.clone())
|
|
336
|
+
const overhang = segment.overhang ?? 0
|
|
337
|
+
const halfWidth = segment.width / 2 + overhang
|
|
338
|
+
const halfDepth = segment.depth / 2 + overhang
|
|
339
|
+
|
|
340
|
+
if (Math.abs(local.x) > halfWidth || Math.abs(local.z) > halfDepth) {
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const score = Math.abs(local.y - getRoofSegmentSurfaceY(segment, local.x, local.z))
|
|
345
|
+
if (!bestSegment || score < bestSegment.score) {
|
|
346
|
+
bestSegment = { node: segment, score }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return bestSegment?.node ?? firstSegment
|
|
351
|
+
}
|
|
352
|
+
|
|
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
|
+
}
|
|
366
|
+
|
|
367
|
+
return event.node
|
|
368
|
+
}
|
|
369
|
+
|
|
229
370
|
function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
|
|
230
371
|
const previousMaterial = mesh.material
|
|
231
372
|
mesh.material = material
|
|
@@ -242,34 +383,6 @@ function previewCursor(cursor: string): PaintPreviewCleanup {
|
|
|
242
383
|
}
|
|
243
384
|
}
|
|
244
385
|
|
|
245
|
-
function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
|
|
246
|
-
if (material.materialPreset) {
|
|
247
|
-
return createMaterialFromPresetRef(material.materialPreset)
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (material.material) {
|
|
251
|
-
return createMaterial(material.material)
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
return null
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function applyWallPaintPreview(
|
|
258
|
-
node: WallNode,
|
|
259
|
-
role: WallSurfaceSide,
|
|
260
|
-
material: ActivePaintMaterial,
|
|
261
|
-
): PaintPreviewCleanup | null {
|
|
262
|
-
const mesh = getRegisteredMesh(node.id)
|
|
263
|
-
if (!mesh) return null
|
|
264
|
-
|
|
265
|
-
const previewNode = {
|
|
266
|
-
...node,
|
|
267
|
-
...buildWallSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return previewMeshMaterial(mesh, getVisibleWallMaterials(previewNode))
|
|
271
|
-
}
|
|
272
|
-
|
|
273
386
|
function applyRoofPaintPreview(
|
|
274
387
|
node: RoofNode,
|
|
275
388
|
role: 'top' | 'edge' | 'wall',
|
|
@@ -283,149 +396,62 @@ function applyRoofPaintPreview(
|
|
|
283
396
|
...node,
|
|
284
397
|
...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
|
285
398
|
}
|
|
286
|
-
const previewMaterial = getRoofMaterialArray(
|
|
399
|
+
const previewMaterial = getRoofMaterialArray(
|
|
400
|
+
previewNode,
|
|
401
|
+
useViewer.getState().shading,
|
|
402
|
+
useViewer.getState().textures,
|
|
403
|
+
useViewer.getState().colorPreset,
|
|
404
|
+
useViewer.getState().sceneTheme,
|
|
405
|
+
)
|
|
287
406
|
if (!previewMaterial) return null
|
|
288
407
|
|
|
289
408
|
return previewMeshMaterial(mesh, previewMaterial)
|
|
290
409
|
}
|
|
291
410
|
|
|
292
|
-
function
|
|
293
|
-
node:
|
|
294
|
-
|
|
411
|
+
function applyRoofSegmentPaintPreview(
|
|
412
|
+
node: RoofSegmentNode,
|
|
413
|
+
parent: RoofNode | null,
|
|
414
|
+
role: 'top' | 'edge' | 'wall',
|
|
295
415
|
material: ActivePaintMaterial,
|
|
296
416
|
): PaintPreviewCleanup | null {
|
|
297
|
-
const
|
|
298
|
-
if (!
|
|
417
|
+
const mesh = getRegisteredMesh(node.id)
|
|
418
|
+
if (!mesh) return null
|
|
299
419
|
|
|
300
|
-
|
|
420
|
+
// Synthesise the segment node as if the paint had committed, then build
|
|
421
|
+
// the same 4-slot array the renderer would. Mirrors getRoofMaterialArray
|
|
422
|
+
// layout (slot 0 ← edge, 1 ← wall, 2 ← wall, 3 ← top) so the preview
|
|
423
|
+
// material lands on the matching CSG groups.
|
|
424
|
+
const previewNode: RoofSegmentNode = {
|
|
301
425
|
...node,
|
|
302
|
-
...
|
|
426
|
+
...buildRoofSegmentSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
|
303
427
|
}
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const mesh = object as Mesh
|
|
311
|
-
if (mesh.name.startsWith('stair-railing')) {
|
|
312
|
-
restores.push(previewMeshMaterial(mesh, railingMaterial))
|
|
313
|
-
return
|
|
314
|
-
}
|
|
315
|
-
if (Array.isArray(mesh.material) && mesh.material.length === 2) {
|
|
316
|
-
restores.push(previewMeshMaterial(mesh, bodyMaterials))
|
|
317
|
-
return
|
|
318
|
-
}
|
|
319
|
-
if (mesh.name === 'merged-stair') {
|
|
320
|
-
restores.push(previewMeshMaterial(mesh, bodyMaterials))
|
|
321
|
-
return
|
|
322
|
-
}
|
|
323
|
-
if (mesh.name.startsWith('stair-side')) {
|
|
324
|
-
restores.push(previewMeshMaterial(mesh, bodyMaterials[1]))
|
|
325
|
-
}
|
|
326
|
-
})
|
|
327
|
-
|
|
328
|
-
if (restores.length === 0) return null
|
|
329
|
-
|
|
330
|
-
return () => {
|
|
331
|
-
for (let index = restores.length - 1; index >= 0; index -= 1) {
|
|
332
|
-
restores[index]?.()
|
|
428
|
+
const resolveSlot = (r: 'top' | 'edge' | 'wall'): Material | null => {
|
|
429
|
+
const parentSpec = parent ? getEffectiveRoofSurfaceMaterial(parent, r) : undefined
|
|
430
|
+
const spec = getEffectiveSegmentSurfaceMaterial(previewNode, r, parentSpec)
|
|
431
|
+
if (typeof spec.materialPreset === 'string') {
|
|
432
|
+
const resolved = createMaterialFromPresetRef(spec.materialPreset)
|
|
433
|
+
if (resolved) return resolved
|
|
333
434
|
}
|
|
435
|
+
if (spec.material !== undefined) return createMaterial(spec.material)
|
|
436
|
+
return null
|
|
334
437
|
}
|
|
438
|
+
const edge = resolveSlot('edge')
|
|
439
|
+
const wall = resolveSlot('wall')
|
|
440
|
+
const top = resolveSlot('top')
|
|
441
|
+
if (!(edge || wall || top)) return null
|
|
442
|
+
const fallback = parent ? getRoofMaterialArray(parent) : null
|
|
443
|
+
const fb = (n: number) => fallback?.[n] ?? null
|
|
444
|
+
// Per-role only, then the parent's themed slot — matches the renderer so the
|
|
445
|
+
// preview never bleeds a painted surface onto the segment's other surfaces.
|
|
446
|
+
const arr: Material[] = [edge ?? fb(0)!, wall ?? fb(1)!, wall ?? fb(2)!, top ?? fb(3)!]
|
|
447
|
+
if (arr.some((m) => !m)) return null
|
|
448
|
+
return previewMeshMaterial(mesh, arr)
|
|
335
449
|
}
|
|
336
450
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (node.type === 'ceiling') {
|
|
342
|
-
const root = getRegisteredMesh(node.id)
|
|
343
|
-
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
|
|
344
|
-
if (!(root && overlay)) return null
|
|
345
|
-
|
|
346
|
-
const previewColor =
|
|
347
|
-
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
|
|
348
|
-
resolveMaterial(material.material).color ??
|
|
349
|
-
'#999999'
|
|
350
|
-
|
|
351
|
-
const previousRootMaterial = root.material
|
|
352
|
-
const previousOverlayMaterial = overlay.material
|
|
353
|
-
const rootPreviewMaterial = Array.isArray(previousRootMaterial)
|
|
354
|
-
? previousRootMaterial.map((entry) => entry.clone())
|
|
355
|
-
: previousRootMaterial.clone()
|
|
356
|
-
const overlayPreviewMaterial = Array.isArray(previousOverlayMaterial)
|
|
357
|
-
? previousOverlayMaterial.map((entry) => entry.clone())
|
|
358
|
-
: previousOverlayMaterial.clone()
|
|
359
|
-
|
|
360
|
-
const applyColor = (input: Material | Material[]) => {
|
|
361
|
-
const materials = Array.isArray(input) ? input : [input]
|
|
362
|
-
for (const entry of materials) {
|
|
363
|
-
const materialWithColor = entry as Material & { color?: Color; needsUpdate?: boolean }
|
|
364
|
-
if (materialWithColor.color instanceof Color) {
|
|
365
|
-
materialWithColor.color = new Color(previewColor)
|
|
366
|
-
}
|
|
367
|
-
materialWithColor.needsUpdate = true
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
applyColor(rootPreviewMaterial)
|
|
372
|
-
applyColor(overlayPreviewMaterial)
|
|
373
|
-
root.material = rootPreviewMaterial
|
|
374
|
-
overlay.material = overlayPreviewMaterial
|
|
375
|
-
|
|
376
|
-
return () => {
|
|
377
|
-
root.material = previousRootMaterial
|
|
378
|
-
overlay.material = previousOverlayMaterial
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const registeredObject = getRegisteredNodeObject(node.id)
|
|
383
|
-
const mesh =
|
|
384
|
-
registeredObject && (registeredObject as Mesh).isMesh ? (registeredObject as Mesh) : null
|
|
385
|
-
|
|
386
|
-
const previewMaterial = getSingleSurfacePreviewMaterial(material)
|
|
387
|
-
if (!previewMaterial) return null
|
|
388
|
-
|
|
389
|
-
if (node.type === 'column') {
|
|
390
|
-
if (!registeredObject) return null
|
|
391
|
-
const restores: PaintPreviewCleanup[] = []
|
|
392
|
-
|
|
393
|
-
registeredObject.traverse((object) => {
|
|
394
|
-
if (!(object as Mesh).isMesh) return
|
|
395
|
-
restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
|
|
396
|
-
})
|
|
397
|
-
|
|
398
|
-
if (restores.length === 0) return null
|
|
399
|
-
return () => {
|
|
400
|
-
for (let index = restores.length - 1; index >= 0; index -= 1) {
|
|
401
|
-
restores[index]?.()
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
if (!mesh) return null
|
|
407
|
-
|
|
408
|
-
if (node.type === 'slab') {
|
|
409
|
-
const slabMaterial = previewMaterial.clone()
|
|
410
|
-
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
|
|
411
|
-
const previewMeshMaterialInput = slabMaterial as Material & {
|
|
412
|
-
alphaMap?: unknown
|
|
413
|
-
depthWrite?: boolean
|
|
414
|
-
needsUpdate?: boolean
|
|
415
|
-
opacity?: number
|
|
416
|
-
side?: number
|
|
417
|
-
transparent?: boolean
|
|
418
|
-
}
|
|
419
|
-
previewMeshMaterialInput.transparent = false
|
|
420
|
-
previewMeshMaterialInput.opacity = 1
|
|
421
|
-
previewMeshMaterialInput.alphaMap = null
|
|
422
|
-
previewMeshMaterialInput.depthWrite = true
|
|
423
|
-
previewMeshMaterialInput.needsUpdate = true
|
|
424
|
-
return previewMeshMaterial(mesh, slabMaterial)
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
return previewMeshMaterial(mesh, previewMaterial)
|
|
428
|
-
}
|
|
451
|
+
// Chimney + dormer paint dispatch lives on their NodeDefinition's
|
|
452
|
+
// `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/
|
|
453
|
+
// paint.ts). The generic registry-driven arm in this file consults
|
|
454
|
+
// those entries — no per-kind helpers needed here.
|
|
429
455
|
|
|
430
456
|
function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
|
|
431
457
|
if (!role) {
|
|
@@ -450,10 +476,12 @@ const HIGHLIGHT_PROFILES = {
|
|
|
450
476
|
emissiveIntensity: 0.46,
|
|
451
477
|
},
|
|
452
478
|
selection: {
|
|
479
|
+
// Keep the real material/texture readable: no albedo tint, just a gentle
|
|
480
|
+
// indigo emissive glow so it reads as selected.
|
|
453
481
|
color: new Color('#818cf8'),
|
|
454
|
-
blend: 0
|
|
455
|
-
emissiveBlend: 0.
|
|
456
|
-
emissiveIntensity: 0.
|
|
482
|
+
blend: 0,
|
|
483
|
+
emissiveBlend: 0.4,
|
|
484
|
+
emissiveIntensity: 0.12,
|
|
457
485
|
},
|
|
458
486
|
} as const
|
|
459
487
|
|
|
@@ -478,8 +506,30 @@ function isHighlightableMesh(object: Object3D): object is Mesh {
|
|
|
478
506
|
)
|
|
479
507
|
}
|
|
480
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
|
+
|
|
481
522
|
function createHighlightedMaterial(material: Material, kind: HighlightKind): Material {
|
|
482
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
|
+
}
|
|
483
533
|
const profile = HIGHLIGHT_PROFILES[kind]
|
|
484
534
|
|
|
485
535
|
if (highlightedMaterial.color instanceof Color) {
|
|
@@ -529,22 +579,17 @@ function disposeHighlightedMaterials(material: Material | Material[]) {
|
|
|
529
579
|
|
|
530
580
|
const computeNextIds = (
|
|
531
581
|
node: AnyNode,
|
|
532
|
-
selectedIds: string[],
|
|
582
|
+
selectedIds: readonly string[],
|
|
533
583
|
event?: any,
|
|
534
|
-
modifierKeys?:
|
|
584
|
+
modifierKeys?: SelectionModifierKeys,
|
|
585
|
+
baseSelectedIds?: readonly string[],
|
|
535
586
|
): string[] => {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}
|
|
543
|
-
return [...selectedIds, node.id]
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Not holding modifiers: select only this node
|
|
547
|
-
return [node.id]
|
|
587
|
+
return resolveSelectedIdsForNodeClick({
|
|
588
|
+
baseSelectedIds,
|
|
589
|
+
currentSelectedIds: selectedIds,
|
|
590
|
+
modifierKeys: selectionModifiersFromEvent(event, modifierKeys),
|
|
591
|
+
nodeId: node.id,
|
|
592
|
+
})
|
|
548
593
|
}
|
|
549
594
|
|
|
550
595
|
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
@@ -565,6 +610,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
565
610
|
'fence',
|
|
566
611
|
'item',
|
|
567
612
|
'column',
|
|
613
|
+
'elevator',
|
|
568
614
|
'zone',
|
|
569
615
|
'slab',
|
|
570
616
|
'ceiling',
|
|
@@ -576,14 +622,21 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
576
622
|
'window',
|
|
577
623
|
'door',
|
|
578
624
|
],
|
|
579
|
-
handleSelect: (node, nativeEvent, modifierKeys) => {
|
|
625
|
+
handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
|
|
580
626
|
const { selection, setSelection } = useViewer.getState()
|
|
581
627
|
const nodes = useScene.getState().nodes
|
|
582
|
-
const nodeLevelId = resolveLevelId(node, nodes)
|
|
583
|
-
const buildingId =
|
|
628
|
+
const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes)
|
|
629
|
+
const buildingId =
|
|
630
|
+
node.type === 'elevator' &&
|
|
631
|
+
node.parentId &&
|
|
632
|
+
nodes[node.parentId as AnyNodeId]?.type === 'building'
|
|
633
|
+
? node.parentId
|
|
634
|
+
: nodeLevelId
|
|
635
|
+
? resolveBuildingId(nodeLevelId, nodes)
|
|
636
|
+
: null
|
|
584
637
|
|
|
585
638
|
const updates: any = {}
|
|
586
|
-
if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
|
|
639
|
+
if (nodeLevelId && nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
|
|
587
640
|
updates.levelId = nodeLevelId
|
|
588
641
|
}
|
|
589
642
|
if (buildingId && buildingId !== selection.buildingId) {
|
|
@@ -596,7 +649,13 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
596
649
|
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
|
|
597
650
|
setSelection(updates)
|
|
598
651
|
} else {
|
|
599
|
-
updates.selectedIds = computeNextIds(
|
|
652
|
+
updates.selectedIds = computeNextIds(
|
|
653
|
+
node,
|
|
654
|
+
selection.selectedIds,
|
|
655
|
+
nativeEvent,
|
|
656
|
+
modifierKeys,
|
|
657
|
+
baseSelectedIds,
|
|
658
|
+
)
|
|
600
659
|
setSelection(updates)
|
|
601
660
|
}
|
|
602
661
|
},
|
|
@@ -619,6 +678,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
619
678
|
node.type === 'wall' ||
|
|
620
679
|
node.type === 'fence' ||
|
|
621
680
|
node.type === 'column' ||
|
|
681
|
+
node.type === 'elevator' ||
|
|
622
682
|
node.type === 'slab' ||
|
|
623
683
|
node.type === 'ceiling' ||
|
|
624
684
|
node.type === 'roof' ||
|
|
@@ -636,13 +696,18 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
636
696
|
}
|
|
637
697
|
if (node.type === 'window' || node.type === 'door') return true
|
|
638
698
|
|
|
699
|
+
// Registry-driven: any kind whose NodeDefinition declares the
|
|
700
|
+
// `selectable` capability is also selectable in structure phase. Phase 4
|
|
701
|
+
// makes this the only path and deletes the hardcoded chain above.
|
|
702
|
+
if (isRegistrySelectable(node.type)) return true
|
|
703
|
+
|
|
639
704
|
return false
|
|
640
705
|
},
|
|
641
706
|
},
|
|
642
707
|
|
|
643
708
|
furnish: {
|
|
644
709
|
types: ['item'],
|
|
645
|
-
handleSelect: (node, nativeEvent, modifierKeys) => {
|
|
710
|
+
handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
|
|
646
711
|
const { selection, setSelection } = useViewer.getState()
|
|
647
712
|
const nodes = useScene.getState().nodes
|
|
648
713
|
const nodeLevelId = resolveLevelId(node, nodes)
|
|
@@ -656,7 +721,13 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
656
721
|
updates.buildingId = buildingId
|
|
657
722
|
}
|
|
658
723
|
|
|
659
|
-
updates.selectedIds = computeNextIds(
|
|
724
|
+
updates.selectedIds = computeNextIds(
|
|
725
|
+
node,
|
|
726
|
+
selection.selectedIds,
|
|
727
|
+
nativeEvent,
|
|
728
|
+
modifierKeys,
|
|
729
|
+
baseSelectedIds,
|
|
730
|
+
)
|
|
660
731
|
setSelection(updates)
|
|
661
732
|
},
|
|
662
733
|
handleDeselect: () => {
|
|
@@ -664,71 +735,49 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|
|
664
735
|
},
|
|
665
736
|
isValid: (node) => {
|
|
666
737
|
if (!isNodeInCurrentLevel(node)) return false
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
738
|
+
// Item: door/window-category items belong to structure phase, not furnish.
|
|
739
|
+
if (node.type === 'item') {
|
|
740
|
+
const item = node as ItemNode
|
|
741
|
+
return item.asset.category !== 'door' && item.asset.category !== 'window'
|
|
742
|
+
}
|
|
743
|
+
// Registry-driven kinds with `category: 'furnish'` (shelf today,
|
|
744
|
+
// future furniture kinds): selectable in furnish phase if their
|
|
745
|
+
// definition declares the `selectable` capability. Without this
|
|
746
|
+
// branch, shelf clicks routed to furnish phase via resolveNodeSelectionTarget
|
|
747
|
+
// would be rejected here — single-click selection broken.
|
|
748
|
+
const def = nodeRegistry.get(node.type)
|
|
749
|
+
if (def && def.category === 'furnish' && def.capabilities.selectable) return true
|
|
750
|
+
return false
|
|
670
751
|
},
|
|
671
752
|
},
|
|
672
753
|
}
|
|
673
754
|
|
|
674
|
-
const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
|
675
|
-
if (node.type === 'zone') {
|
|
676
|
-
return {
|
|
677
|
-
phase: 'structure',
|
|
678
|
-
structureLayer: 'zones',
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
if (
|
|
683
|
-
node.type === 'wall' ||
|
|
684
|
-
node.type === 'fence' ||
|
|
685
|
-
node.type === 'column' ||
|
|
686
|
-
node.type === 'slab' ||
|
|
687
|
-
node.type === 'ceiling' ||
|
|
688
|
-
node.type === 'roof' ||
|
|
689
|
-
node.type === 'roof-segment' ||
|
|
690
|
-
node.type === 'stair' ||
|
|
691
|
-
node.type === 'stair-segment' ||
|
|
692
|
-
node.type === 'spawn' ||
|
|
693
|
-
node.type === 'window' ||
|
|
694
|
-
node.type === 'door'
|
|
695
|
-
) {
|
|
696
|
-
return {
|
|
697
|
-
phase: 'structure',
|
|
698
|
-
structureLayer: 'elements',
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
if (node.type === 'item') {
|
|
703
|
-
const item = node as ItemNode
|
|
704
|
-
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
|
705
|
-
return {
|
|
706
|
-
phase: 'structure',
|
|
707
|
-
structureLayer: 'elements',
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
return {
|
|
712
|
-
phase: 'furnish',
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
return null
|
|
717
|
-
}
|
|
718
|
-
|
|
719
755
|
export const SelectionManager = () => {
|
|
720
756
|
const phase = useEditor((s) => s.phase)
|
|
721
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)
|
|
722
764
|
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
|
|
723
|
-
|
|
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>({
|
|
724
773
|
meta: false,
|
|
725
774
|
ctrl: false,
|
|
775
|
+
shift: false,
|
|
726
776
|
})
|
|
727
777
|
const clickHandledRef = useRef(false)
|
|
728
778
|
|
|
729
|
-
const movingNode =
|
|
730
|
-
const
|
|
731
|
-
const curvingFence = useEditor((s) => s.curvingFence)
|
|
779
|
+
const movingNode = useMovingNode()
|
|
780
|
+
const isCurveReshape = useIsCurveReshape()
|
|
732
781
|
|
|
733
782
|
useEffect(() => {
|
|
734
783
|
const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
|
|
@@ -741,9 +790,12 @@ export const SelectionManager = () => {
|
|
|
741
790
|
|
|
742
791
|
useEffect(() => {
|
|
743
792
|
if (mode !== 'material-paint') return
|
|
744
|
-
if (movingNode ||
|
|
793
|
+
if (movingNode || isCurveReshape) return
|
|
745
794
|
|
|
746
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
|
|
747
799
|
|
|
748
800
|
const clearActivePreview = () => {
|
|
749
801
|
activePreview?.restore()
|
|
@@ -762,166 +814,240 @@ export const SelectionManager = () => {
|
|
|
762
814
|
})
|
|
763
815
|
|
|
764
816
|
const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
|
|
817
|
+
const eraser = useEditor.getState().paintEraser
|
|
765
818
|
const activePaintMaterial = resolveActivePaintMaterial()
|
|
766
819
|
const node = event.node
|
|
767
820
|
|
|
768
821
|
if (!isNodeInCurrentLevel(node)) return null
|
|
769
822
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
823
|
+
// The eraser clears a surface back to its default by painting with an
|
|
824
|
+
// empty material — every `build*SurfaceMaterialPatch` interprets
|
|
825
|
+
// `undefined` material/preset as "reset this role". So a single spec
|
|
826
|
+
// with both fields undefined drives the same apply/preview paths as a
|
|
827
|
+
// real material; only the enabled-gate differs (no material required).
|
|
828
|
+
const paintEnabled = eraser || hasActivePaintMaterial(activePaintMaterial)
|
|
829
|
+
const paintSpec: ActivePaintMaterial = eraser
|
|
830
|
+
? {
|
|
831
|
+
material: undefined,
|
|
832
|
+
materialPreset: undefined,
|
|
833
|
+
sourceTarget:
|
|
834
|
+
activePaintMaterial?.sourceTarget ?? useEditor.getState().activePaintTarget,
|
|
835
|
+
}
|
|
836
|
+
: (activePaintMaterial ?? {
|
|
837
|
+
material: undefined,
|
|
838
|
+
materialPreset: undefined,
|
|
839
|
+
sourceTarget: useEditor.getState().activePaintTarget,
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
// Registry-driven paint dispatch — kinds that declare
|
|
843
|
+
// `capabilities.paint` route hover / click / preview through
|
|
844
|
+
// their definition. Wall, chimney, and dormer use this; legacy
|
|
845
|
+
// roof / stair / single-surface arms below stay until they
|
|
846
|
+
// migrate too.
|
|
847
|
+
const paintCap = nodeRegistry.get(node.type)?.capabilities?.paint
|
|
848
|
+
if (paintCap) {
|
|
849
|
+
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
|
|
850
|
+
const role = paintCap.resolveRole({
|
|
851
|
+
node,
|
|
852
|
+
materialIndex: materialIndex ?? null,
|
|
853
|
+
normal: event.normal,
|
|
854
|
+
localPosition: event.localPosition as readonly [number, number, number] | undefined,
|
|
855
|
+
hitObjectName: event.nativeEvent.object?.name,
|
|
856
|
+
hitObject: getEventObject(event),
|
|
857
|
+
ray: event.nativeEvent.ray,
|
|
858
|
+
})
|
|
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(',')
|
|
773
885
|
return {
|
|
774
|
-
key:
|
|
886
|
+
key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}:${scope}:${scopeTargetKey}`,
|
|
775
887
|
hoveredId: node.id as AnyNodeId,
|
|
776
|
-
hoverMode:
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
888
|
+
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
|
|
889
|
+
paintHover:
|
|
890
|
+
compatible && role
|
|
891
|
+
? {
|
|
892
|
+
scopes: availablePaintScopes({ node, slotRoles }),
|
|
893
|
+
slotLabel: slotDisplayLabel(node, role),
|
|
894
|
+
nodeNoun: node.type,
|
|
895
|
+
}
|
|
896
|
+
: null,
|
|
780
897
|
apply:
|
|
781
|
-
compatible &&
|
|
898
|
+
compatible && role
|
|
782
899
|
? () => {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
),
|
|
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,
|
|
793
909
|
)
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
const args = {
|
|
913
|
+
node,
|
|
914
|
+
role,
|
|
915
|
+
material: paintSpec.material,
|
|
916
|
+
materialPreset: paintSpec.materialPreset,
|
|
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
|
+
}
|
|
794
928
|
}
|
|
795
929
|
: null,
|
|
796
930
|
preview:
|
|
797
|
-
compatible &&
|
|
798
|
-
? () =>
|
|
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
|
+
}
|
|
960
|
+
}
|
|
799
961
|
: () => previewCursor('not-allowed'),
|
|
800
962
|
}
|
|
801
963
|
}
|
|
802
964
|
|
|
803
965
|
if (node.type === 'roof' || node.type === 'roof-segment') {
|
|
966
|
+
const isSegmentHit = node.type === 'roof-segment'
|
|
804
967
|
const roofNode =
|
|
805
968
|
node.type === 'roof'
|
|
806
969
|
? node
|
|
807
970
|
: node.parentId
|
|
808
971
|
? useScene.getState().nodes[node.parentId as AnyNodeId]
|
|
809
972
|
: null
|
|
810
|
-
if (
|
|
973
|
+
if (roofNode?.type !== 'roof') return null
|
|
811
974
|
|
|
812
975
|
const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
|
|
813
|
-
const compatible = role !== null &&
|
|
976
|
+
const compatible = role !== null && paintEnabled
|
|
977
|
+
// Painting directly on a segment (only possible in segment edit
|
|
978
|
+
// mode, where the per-segment mesh is visible) writes to the
|
|
979
|
+
// segment's own role-specific fields. Painting the merged shell
|
|
980
|
+
// — or a roof node directly — keeps fanning to the parent roof.
|
|
981
|
+
const segmentTarget = isSegmentHit ? (node as RoofSegmentNode) : null
|
|
814
982
|
return {
|
|
815
|
-
key:
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
983
|
+
key: `${segmentTarget ? 'roof-segment' : 'roof'}:${
|
|
984
|
+
segmentTarget ? segmentTarget.id : roofNode.id
|
|
985
|
+
}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
|
|
986
|
+
hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId,
|
|
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,
|
|
821
998
|
apply:
|
|
822
|
-
compatible &&
|
|
999
|
+
compatible && role
|
|
823
1000
|
? () => {
|
|
824
|
-
useScene
|
|
825
|
-
|
|
826
|
-
.updateNode(
|
|
1001
|
+
const sceneState = useScene.getState()
|
|
1002
|
+
if (segmentTarget) {
|
|
1003
|
+
sceneState.updateNode(
|
|
1004
|
+
segmentTarget.id as AnyNodeId,
|
|
1005
|
+
buildRoofSegmentSurfaceMaterialPatch(
|
|
1006
|
+
segmentTarget,
|
|
1007
|
+
role,
|
|
1008
|
+
paintSpec.material,
|
|
1009
|
+
paintSpec.materialPreset,
|
|
1010
|
+
),
|
|
1011
|
+
)
|
|
1012
|
+
} else {
|
|
1013
|
+
sceneState.updateNode(
|
|
827
1014
|
roofNode.id as AnyNodeId,
|
|
828
1015
|
buildRoofSurfaceMaterialPatch(
|
|
829
1016
|
roofNode as RoofNode,
|
|
830
|
-
role
|
|
831
|
-
|
|
832
|
-
|
|
1017
|
+
role,
|
|
1018
|
+
paintSpec.material,
|
|
1019
|
+
paintSpec.materialPreset,
|
|
833
1020
|
),
|
|
834
1021
|
)
|
|
1022
|
+
}
|
|
835
1023
|
}
|
|
836
1024
|
: null,
|
|
837
1025
|
preview:
|
|
838
|
-
compatible &&
|
|
839
|
-
? () =>
|
|
1026
|
+
compatible && role
|
|
1027
|
+
? () =>
|
|
1028
|
+
segmentTarget
|
|
1029
|
+
? applyRoofSegmentPaintPreview(
|
|
1030
|
+
segmentTarget,
|
|
1031
|
+
roofNode as RoofNode,
|
|
1032
|
+
role,
|
|
1033
|
+
paintSpec,
|
|
1034
|
+
)
|
|
1035
|
+
: applyRoofPaintPreview(roofNode as RoofNode, role, paintSpec)
|
|
840
1036
|
: () => previewCursor('not-allowed'),
|
|
841
1037
|
}
|
|
842
1038
|
}
|
|
843
1039
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
? node
|
|
848
|
-
: node.parentId
|
|
849
|
-
? useScene.getState().nodes[node.parentId as AnyNodeId]
|
|
850
|
-
: null
|
|
851
|
-
if (!stairNode || stairNode.type !== 'stair') return null
|
|
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.
|
|
852
1043
|
|
|
853
|
-
|
|
854
|
-
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
|
|
855
|
-
return {
|
|
856
|
-
key: `stair:${stairNode.id}:${role ?? 'unsupported'}`,
|
|
857
|
-
hoveredId: stairNode.id as AnyNodeId,
|
|
858
|
-
hoverMode:
|
|
859
|
-
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
|
860
|
-
? 'paint-ready'
|
|
861
|
-
: 'paint-disabled',
|
|
862
|
-
apply:
|
|
863
|
-
compatible && hasActivePaintMaterial(activePaintMaterial)
|
|
864
|
-
? () => {
|
|
865
|
-
useScene
|
|
866
|
-
.getState()
|
|
867
|
-
.updateNode(
|
|
868
|
-
stairNode.id as AnyNodeId,
|
|
869
|
-
buildStairSurfaceMaterialPatch(
|
|
870
|
-
stairNode as StairNode,
|
|
871
|
-
role!,
|
|
872
|
-
activePaintMaterial.material,
|
|
873
|
-
activePaintMaterial.materialPreset,
|
|
874
|
-
),
|
|
875
|
-
)
|
|
876
|
-
}
|
|
877
|
-
: null,
|
|
878
|
-
preview:
|
|
879
|
-
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
|
880
|
-
? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial)
|
|
881
|
-
: () => previewCursor('not-allowed'),
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
if (
|
|
886
|
-
node.type === 'fence' ||
|
|
887
|
-
node.type === 'column' ||
|
|
888
|
-
node.type === 'slab' ||
|
|
889
|
-
node.type === 'ceiling'
|
|
890
|
-
) {
|
|
891
|
-
const compatible = hasActivePaintMaterial(activePaintMaterial)
|
|
892
|
-
|
|
893
|
-
return {
|
|
894
|
-
key: `${node.type}:${node.id}:surface`,
|
|
895
|
-
hoveredId: node.id as AnyNodeId,
|
|
896
|
-
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
|
|
897
|
-
apply: compatible
|
|
898
|
-
? () => {
|
|
899
|
-
useScene
|
|
900
|
-
.getState()
|
|
901
|
-
.updateNode(
|
|
902
|
-
node.id as AnyNodeId,
|
|
903
|
-
buildSingleSurfaceMaterialPatch<
|
|
904
|
-
FenceNode | ColumnNode | SlabNode | CeilingNode
|
|
905
|
-
>(activePaintMaterial.material, activePaintMaterial.materialPreset),
|
|
906
|
-
)
|
|
907
|
-
}
|
|
908
|
-
: null,
|
|
909
|
-
preview: compatible
|
|
910
|
-
? () =>
|
|
911
|
-
applySingleSurfacePaintPreview(
|
|
912
|
-
node as FenceNode | ColumnNode | SlabNode | CeilingNode,
|
|
913
|
-
activePaintMaterial,
|
|
914
|
-
)
|
|
915
|
-
: () => previewCursor('not-allowed'),
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
const disabledNodeTypes = ['item', 'window', 'door', 'zone']
|
|
1044
|
+
const disabledNodeTypes = ['zone']
|
|
920
1045
|
if (disabledNodeTypes.includes(node.type)) {
|
|
921
1046
|
return {
|
|
922
1047
|
key: `${node.type}:${node.id}:unsupported`,
|
|
923
1048
|
hoveredId: node.id as AnyNodeId,
|
|
924
1049
|
hoverMode: 'paint-disabled',
|
|
1050
|
+
paintHover: null,
|
|
925
1051
|
apply: null,
|
|
926
1052
|
preview: () => previewCursor('not-allowed'),
|
|
927
1053
|
}
|
|
@@ -931,12 +1057,22 @@ export const SelectionManager = () => {
|
|
|
931
1057
|
}
|
|
932
1058
|
|
|
933
1059
|
const onEnter = (event: NodeEvent) => {
|
|
934
|
-
|
|
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
|
|
935
1065
|
|
|
936
1066
|
const interaction = getPaintInteraction(event)
|
|
937
1067
|
if (!interaction) return
|
|
938
1068
|
|
|
939
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)
|
|
940
1076
|
|
|
941
1077
|
if (activePreview?.key === interaction.key) {
|
|
942
1078
|
return
|
|
@@ -956,6 +1092,10 @@ export const SelectionManager = () => {
|
|
|
956
1092
|
const interaction = getPaintInteraction(event)
|
|
957
1093
|
if (!interaction) return
|
|
958
1094
|
|
|
1095
|
+
// Leaving any surface → the HUD shows the "hover a surface" hint again.
|
|
1096
|
+
lastEnterEvent = null
|
|
1097
|
+
useEditor.getState().setPaintHover(null)
|
|
1098
|
+
|
|
959
1099
|
if (activePreview?.key !== interaction.key) {
|
|
960
1100
|
return
|
|
961
1101
|
}
|
|
@@ -980,6 +1120,7 @@ export const SelectionManager = () => {
|
|
|
980
1120
|
}
|
|
981
1121
|
|
|
982
1122
|
interaction.apply()
|
|
1123
|
+
sfxEmitter.emit('sfx:paint-apply')
|
|
983
1124
|
if (activePreview?.key === interaction.key) {
|
|
984
1125
|
activePreview = null
|
|
985
1126
|
} else {
|
|
@@ -1004,38 +1145,64 @@ export const SelectionManager = () => {
|
|
|
1004
1145
|
'zone',
|
|
1005
1146
|
] as const
|
|
1006
1147
|
|
|
1007
|
-
|
|
1148
|
+
// Registry-driven kinds get the same subscriptions as the hardcoded list,
|
|
1149
|
+
// so future built-in nodes don't need to edit allTypes per migration.
|
|
1150
|
+
const registryKinds = getSelectableKinds().filter(
|
|
1151
|
+
(k) => !(allTypes as readonly string[]).includes(k),
|
|
1152
|
+
)
|
|
1153
|
+
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
|
|
1154
|
+
|
|
1155
|
+
for (const type of subscribedKinds) {
|
|
1008
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)
|
|
1009
1162
|
emitter.on(`${type}:leave` as any, onLeave as any)
|
|
1010
1163
|
emitter.on(`${type}:click` as any, onClick as any)
|
|
1011
1164
|
}
|
|
1012
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
|
+
|
|
1013
1174
|
return () => {
|
|
1014
|
-
|
|
1175
|
+
unsubscribePaintScope()
|
|
1176
|
+
for (const type of subscribedKinds) {
|
|
1015
1177
|
emitter.off(`${type}:enter` as any, onEnter as any)
|
|
1178
|
+
emitter.off(`${type}:move` as any, onEnter as any)
|
|
1016
1179
|
emitter.off(`${type}:leave` as any, onLeave as any)
|
|
1017
1180
|
emitter.off(`${type}:click` as any, onClick as any)
|
|
1018
1181
|
}
|
|
1019
1182
|
clearActivePreview()
|
|
1020
1183
|
useViewer.setState({ hoveredId: null })
|
|
1021
1184
|
setHoverHighlightMode('default')
|
|
1185
|
+
useEditor.getState().setPaintHover(null)
|
|
1022
1186
|
}
|
|
1023
|
-
}, [
|
|
1187
|
+
}, [isCurveReshape, mode, movingNode, setHoverHighlightMode])
|
|
1024
1188
|
|
|
1025
1189
|
useEffect(() => {
|
|
1026
1190
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
1027
1191
|
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
|
1028
1192
|
if (event.key === 'Control') modifierKeysRef.current.ctrl = true
|
|
1193
|
+
if (event.key === 'Shift') modifierKeysRef.current.shift = true
|
|
1029
1194
|
}
|
|
1030
1195
|
|
|
1031
1196
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
1032
1197
|
if (event.key === 'Meta') modifierKeysRef.current.meta = false
|
|
1033
1198
|
if (event.key === 'Control') modifierKeysRef.current.ctrl = false
|
|
1199
|
+
if (event.key === 'Shift') modifierKeysRef.current.shift = false
|
|
1034
1200
|
}
|
|
1035
1201
|
|
|
1036
1202
|
const clearModifiers = () => {
|
|
1037
1203
|
modifierKeysRef.current.meta = false
|
|
1038
1204
|
modifierKeysRef.current.ctrl = false
|
|
1205
|
+
modifierKeysRef.current.shift = false
|
|
1039
1206
|
}
|
|
1040
1207
|
|
|
1041
1208
|
window.addEventListener('keydown', onKeyDown)
|
|
@@ -1051,21 +1218,360 @@ export const SelectionManager = () => {
|
|
|
1051
1218
|
|
|
1052
1219
|
useEffect(() => {
|
|
1053
1220
|
if (mode !== 'select') return
|
|
1054
|
-
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
|
|
1055
1527
|
|
|
1056
1528
|
const onClick = (event: NodeEvent) => {
|
|
1057
1529
|
// Skip if box-select just completed (drag ended over a node)
|
|
1058
1530
|
if (boxSelectHandled) return
|
|
1059
1531
|
|
|
1060
|
-
|
|
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
|
+
})
|
|
1556
|
+
|
|
1557
|
+
// A ceiling is selectable only through its corner handles, never via
|
|
1558
|
+
// the `ceiling-grid` body mesh. When the grid is revealed (ceiling
|
|
1559
|
+
// selected, or an item placed beneath it) a top-down click hits the
|
|
1560
|
+
// grid first; selecting the ceiling there both re-selects it as a
|
|
1561
|
+
// no-op and stops propagation, blocking the hosted item below. By
|
|
1562
|
+
// ignoring non-handle ceiling clicks (without stopping propagation)
|
|
1563
|
+
// the click falls through to the item underneath.
|
|
1564
|
+
if (node.type === 'ceiling' && !event.viaHandle) return
|
|
1565
|
+
|
|
1061
1566
|
let currentPhase = useEditor.getState().phase
|
|
1062
1567
|
let currentStructureLayer = useEditor.getState().structureLayer
|
|
1568
|
+
const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
|
|
1063
1569
|
|
|
1064
1570
|
// Auto-switch between zones, structure, and furnish when clicking elements on the same level.
|
|
1065
1571
|
// Also auto-switch from site phase when clicking structural/furnish elements (e.g. 2D floorplan).
|
|
1066
1572
|
if (currentPhase === 'structure' || currentPhase === 'furnish' || currentPhase === 'site') {
|
|
1067
1573
|
if (isNodeInCurrentLevel(node)) {
|
|
1068
|
-
const target =
|
|
1574
|
+
const target = resolveNodeSelectionTarget(node)
|
|
1069
1575
|
if (target) {
|
|
1070
1576
|
if (target.phase !== currentPhase) {
|
|
1071
1577
|
useEditor.getState().setPhase(target.phase)
|
|
@@ -1088,34 +1594,92 @@ export const SelectionManager = () => {
|
|
|
1088
1594
|
if (activeStrategy?.isValid(node)) {
|
|
1089
1595
|
event.stopPropagation()
|
|
1090
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)
|
|
1091
1606
|
|
|
1092
1607
|
let nodeToSelect = node
|
|
1093
|
-
if (node.type === 'roof-segment' && node.parentId) {
|
|
1094
|
-
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
|
|
1095
|
-
if (parentNode && parentNode.type === 'roof') {
|
|
1096
|
-
nodeToSelect = parentNode
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
1608
|
if (node.type === 'stair-segment' && node.parentId) {
|
|
1100
1609
|
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
|
|
1101
1610
|
if (parentNode && parentNode.type === 'stair') {
|
|
1102
1611
|
nodeToSelect = parentNode
|
|
1103
1612
|
}
|
|
1104
1613
|
}
|
|
1614
|
+
nodeToSelect = resolveCanvasSelectionNode({
|
|
1615
|
+
node: nodeToSelect,
|
|
1616
|
+
nodes: useScene.getState().nodes,
|
|
1617
|
+
selectedIds: selectedIdsBeforeRouting,
|
|
1618
|
+
})
|
|
1619
|
+
// Clicking any node (e.g. the slab surface outside a hole) exits slab
|
|
1620
|
+
// hole-edit mode. The hole handles + hit mesh stopPropagation, so a
|
|
1621
|
+
// click reaching here means the user clicked outside the hole.
|
|
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
|
|
1643
|
+
}
|
|
1105
1644
|
|
|
1106
|
-
activeStrategy.handleSelect(
|
|
1645
|
+
activeStrategy.handleSelect(
|
|
1646
|
+
nodeToSelect,
|
|
1647
|
+
event.nativeEvent,
|
|
1648
|
+
modifierKeysRef.current,
|
|
1649
|
+
selectedIdsBeforeRouting,
|
|
1650
|
+
)
|
|
1107
1651
|
|
|
1108
1652
|
let nextMaterialTargetHandled = false
|
|
1109
1653
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1654
|
+
// Registry-driven paint-target resolve on click. Kinds with
|
|
1655
|
+
// `capabilities.paint` route through this entry — wall,
|
|
1656
|
+
// chimney, dormer use it today. The legacy stair / roof /
|
|
1657
|
+
// single-surface arms below stay until they migrate too.
|
|
1658
|
+
if (nodeToSelect.type === node.type) {
|
|
1659
|
+
const paintCap = nodeRegistry.get(node.type)?.capabilities?.paint
|
|
1660
|
+
if (paintCap) {
|
|
1661
|
+
const materialIndex = getIntersectionMaterialIndex(
|
|
1662
|
+
getEventObject(event),
|
|
1663
|
+
event.faceIndex,
|
|
1664
|
+
)
|
|
1665
|
+
const role = paintCap.resolveRole({
|
|
1666
|
+
node,
|
|
1667
|
+
materialIndex: materialIndex ?? null,
|
|
1668
|
+
normal: event.normal,
|
|
1669
|
+
localPosition: event.localPosition as readonly [number, number, number] | undefined,
|
|
1670
|
+
hitObjectName: event.nativeEvent.object?.name,
|
|
1671
|
+
hitObject: getEventObject(event),
|
|
1672
|
+
ray: event.nativeEvent.ray,
|
|
1673
|
+
})
|
|
1674
|
+
if (role) {
|
|
1675
|
+
setSelectedMaterialTargetForNode(nodeToSelect, role as MaterialTargetRole)
|
|
1676
|
+
nextMaterialTargetHandled = true
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1116
1679
|
}
|
|
1117
1680
|
|
|
1118
1681
|
if (
|
|
1682
|
+
!nextMaterialTargetHandled &&
|
|
1119
1683
|
(node.type === 'stair' || node.type === 'stair-segment') &&
|
|
1120
1684
|
nodeToSelect.type === 'stair'
|
|
1121
1685
|
) {
|
|
@@ -1127,6 +1691,7 @@ export const SelectionManager = () => {
|
|
|
1127
1691
|
}
|
|
1128
1692
|
|
|
1129
1693
|
if (
|
|
1694
|
+
!nextMaterialTargetHandled &&
|
|
1130
1695
|
(node.type === 'roof' || node.type === 'roof-segment') &&
|
|
1131
1696
|
nodeToSelect.type === 'roof'
|
|
1132
1697
|
) {
|
|
@@ -1138,7 +1703,11 @@ export const SelectionManager = () => {
|
|
|
1138
1703
|
}
|
|
1139
1704
|
|
|
1140
1705
|
if (
|
|
1141
|
-
|
|
1706
|
+
!nextMaterialTargetHandled &&
|
|
1707
|
+
(node.type === 'fence' ||
|
|
1708
|
+
node.type === 'slab' ||
|
|
1709
|
+
node.type === 'ceiling' ||
|
|
1710
|
+
node.type === 'shelf') &&
|
|
1142
1711
|
nodeToSelect.type === node.type
|
|
1143
1712
|
) {
|
|
1144
1713
|
setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
|
|
@@ -1148,11 +1717,6 @@ export const SelectionManager = () => {
|
|
|
1148
1717
|
if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
|
|
1149
1718
|
useEditor.getState().setSelectedMaterialTarget(null)
|
|
1150
1719
|
}
|
|
1151
|
-
|
|
1152
|
-
// Reset the handled flag after a short delay to allow grid:click to be ignored
|
|
1153
|
-
setTimeout(() => {
|
|
1154
|
-
clickHandledRef.current = false
|
|
1155
|
-
}, 50)
|
|
1156
1720
|
}
|
|
1157
1721
|
}
|
|
1158
1722
|
|
|
@@ -1162,6 +1726,7 @@ export const SelectionManager = () => {
|
|
|
1162
1726
|
'item',
|
|
1163
1727
|
'column',
|
|
1164
1728
|
'building',
|
|
1729
|
+
'elevator',
|
|
1165
1730
|
'zone',
|
|
1166
1731
|
'slab',
|
|
1167
1732
|
'ceiling',
|
|
@@ -1173,13 +1738,22 @@ export const SelectionManager = () => {
|
|
|
1173
1738
|
'window',
|
|
1174
1739
|
'door',
|
|
1175
1740
|
]
|
|
1176
|
-
|
|
1741
|
+
// Registry-driven kinds get the same subscriptions as the hardcoded list,
|
|
1742
|
+
// so future built-in nodes don't need to edit allTypes per migration.
|
|
1743
|
+
const registryKinds = getSelectableKinds().filter(
|
|
1744
|
+
(k) => !(allTypes as readonly string[]).includes(k),
|
|
1745
|
+
)
|
|
1746
|
+
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
|
|
1747
|
+
|
|
1748
|
+
subscribedKinds.forEach((type) => {
|
|
1177
1749
|
emitter.on(`${type}:click` as any, onClick as any)
|
|
1178
1750
|
})
|
|
1179
1751
|
|
|
1180
|
-
const onGridClick = () => {
|
|
1752
|
+
const onGridClick = (event: GridEvent) => {
|
|
1181
1753
|
if (clickHandledRef.current) return
|
|
1182
1754
|
if (boxSelectHandled) return
|
|
1755
|
+
const nativeEvent = event.nativeEvent
|
|
1756
|
+
if (nativeEvent?.metaKey || nativeEvent?.ctrlKey || nativeEvent?.shiftKey) return
|
|
1183
1757
|
const { phase, structureLayer } = useEditor.getState()
|
|
1184
1758
|
const activeStrategy = SELECTION_STRATEGIES[phase]
|
|
1185
1759
|
if (activeStrategy) activeStrategy.handleDeselect()
|
|
@@ -1194,20 +1768,29 @@ export const SelectionManager = () => {
|
|
|
1194
1768
|
emitter.on('grid:click', onGridClick)
|
|
1195
1769
|
|
|
1196
1770
|
return () => {
|
|
1197
|
-
|
|
1771
|
+
subscribedKinds.forEach((type) => {
|
|
1198
1772
|
emitter.off(`${type}:click` as any, onClick as any)
|
|
1199
1773
|
})
|
|
1200
1774
|
emitter.off('grid:click', onGridClick)
|
|
1201
1775
|
}
|
|
1202
|
-
}, [
|
|
1776
|
+
}, [isCurveReshape, mode, movingNode])
|
|
1203
1777
|
|
|
1204
1778
|
// Global double-click handler for auto-switching phases and cross-phase hover
|
|
1205
1779
|
useEffect(() => {
|
|
1206
1780
|
if (mode !== 'select') return
|
|
1207
|
-
if (movingNode ||
|
|
1781
|
+
if (movingNode || isCurveReshape) return
|
|
1208
1782
|
|
|
1209
1783
|
const onEnter = (event: NodeEvent) => {
|
|
1210
|
-
|
|
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
|
+
})
|
|
1211
1794
|
const currentPhase = useEditor.getState().phase
|
|
1212
1795
|
|
|
1213
1796
|
// Ignore site/building if we are already inside a building
|
|
@@ -1234,17 +1817,30 @@ export const SelectionManager = () => {
|
|
|
1234
1817
|
}
|
|
1235
1818
|
|
|
1236
1819
|
const onLeave = (event: NodeEvent) => {
|
|
1237
|
-
|
|
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
|
|
1238
1826
|
if (nodeId && useViewer.getState().hoveredId === nodeId) {
|
|
1239
1827
|
useViewer.setState({ hoveredId: null })
|
|
1240
1828
|
}
|
|
1241
1829
|
}
|
|
1242
1830
|
|
|
1243
1831
|
const onDoubleClick = (event: NodeEvent) => {
|
|
1244
|
-
|
|
1832
|
+
let node = resolveCanvasSelectionNode({
|
|
1833
|
+
node: resolveSelectModeNodeTarget(event),
|
|
1834
|
+
nodes: useScene.getState().nodes,
|
|
1835
|
+
selectedIds: useViewer.getState().selection.selectedIds,
|
|
1836
|
+
})
|
|
1837
|
+
|
|
1245
1838
|
const currentPhase = useEditor.getState().phase
|
|
1246
1839
|
|
|
1247
|
-
|
|
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
|
|
1248
1844
|
let forceSelect = false
|
|
1249
1845
|
|
|
1250
1846
|
if (node.type === 'building' || node.type === 'site') {
|
|
@@ -1253,35 +1849,15 @@ export const SelectionManager = () => {
|
|
|
1253
1849
|
}
|
|
1254
1850
|
if (node.type === 'building') {
|
|
1255
1851
|
targetPhase = 'structure'
|
|
1852
|
+
targetStructureLayer = 'elements'
|
|
1256
1853
|
}
|
|
1257
|
-
} else
|
|
1258
|
-
node.type === 'wall' ||
|
|
1259
|
-
node.type === 'fence' ||
|
|
1260
|
-
node.type === 'column' ||
|
|
1261
|
-
node.type === 'slab' ||
|
|
1262
|
-
node.type === 'ceiling' ||
|
|
1263
|
-
node.type === 'roof' ||
|
|
1264
|
-
node.type === 'roof-segment' ||
|
|
1265
|
-
node.type === 'stair' ||
|
|
1266
|
-
node.type === 'stair-segment' ||
|
|
1267
|
-
node.type === 'spawn' ||
|
|
1268
|
-
node.type === 'window' ||
|
|
1269
|
-
node.type === 'door'
|
|
1270
|
-
) {
|
|
1271
|
-
targetPhase = 'structure'
|
|
1854
|
+
} else {
|
|
1272
1855
|
if (node.type === 'roof-segment' && currentPhase === 'structure') {
|
|
1273
1856
|
forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
|
|
1274
1857
|
}
|
|
1275
1858
|
if (node.type === 'stair-segment' && currentPhase === 'structure') {
|
|
1276
1859
|
forceSelect = true // allow double click to dive into stair-segment even if already in structure phase
|
|
1277
1860
|
}
|
|
1278
|
-
} else if (node.type === 'item') {
|
|
1279
|
-
const item = node as ItemNode
|
|
1280
|
-
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
|
1281
|
-
targetPhase = 'structure'
|
|
1282
|
-
} else {
|
|
1283
|
-
targetPhase = 'furnish'
|
|
1284
|
-
}
|
|
1285
1861
|
}
|
|
1286
1862
|
|
|
1287
1863
|
if (node.type === 'zone') {
|
|
@@ -1295,13 +1871,22 @@ export const SelectionManager = () => {
|
|
|
1295
1871
|
useEditor.getState().setPhase(targetPhase)
|
|
1296
1872
|
}
|
|
1297
1873
|
|
|
1298
|
-
if (
|
|
1299
|
-
|
|
1874
|
+
if (
|
|
1875
|
+
targetPhase === 'structure' &&
|
|
1876
|
+
targetStructureLayer &&
|
|
1877
|
+
targetStructureLayer !== useEditor.getState().structureLayer
|
|
1878
|
+
) {
|
|
1879
|
+
useEditor.getState().setStructureLayer(targetStructureLayer)
|
|
1300
1880
|
}
|
|
1301
1881
|
|
|
1302
1882
|
const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase]
|
|
1303
1883
|
if (strategy) {
|
|
1304
|
-
strategy.handleSelect(
|
|
1884
|
+
strategy.handleSelect(
|
|
1885
|
+
node,
|
|
1886
|
+
event.nativeEvent,
|
|
1887
|
+
modifierKeysRef.current,
|
|
1888
|
+
selectedIdsBeforeRouting,
|
|
1889
|
+
)
|
|
1305
1890
|
}
|
|
1306
1891
|
}
|
|
1307
1892
|
}
|
|
@@ -1312,6 +1897,7 @@ export const SelectionManager = () => {
|
|
|
1312
1897
|
'item',
|
|
1313
1898
|
'column',
|
|
1314
1899
|
'building',
|
|
1900
|
+
'elevator',
|
|
1315
1901
|
'slab',
|
|
1316
1902
|
'ceiling',
|
|
1317
1903
|
'roof',
|
|
@@ -1324,20 +1910,25 @@ export const SelectionManager = () => {
|
|
|
1324
1910
|
'zone',
|
|
1325
1911
|
'site',
|
|
1326
1912
|
]
|
|
1327
|
-
|
|
1913
|
+
const registryKinds = getSelectableKinds().filter(
|
|
1914
|
+
(k) => !(allTypes as readonly string[]).includes(k),
|
|
1915
|
+
)
|
|
1916
|
+
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
|
|
1917
|
+
|
|
1918
|
+
subscribedKinds.forEach((type) => {
|
|
1328
1919
|
emitter.on(`${type}:enter` as any, onEnter as any)
|
|
1329
1920
|
emitter.on(`${type}:leave` as any, onLeave as any)
|
|
1330
1921
|
emitter.on(`${type}:double-click` as any, onDoubleClick as any)
|
|
1331
1922
|
})
|
|
1332
1923
|
|
|
1333
1924
|
return () => {
|
|
1334
|
-
|
|
1925
|
+
subscribedKinds.forEach((type) => {
|
|
1335
1926
|
emitter.off(`${type}:enter` as any, onEnter as any)
|
|
1336
1927
|
emitter.off(`${type}:leave` as any, onLeave as any)
|
|
1337
1928
|
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
|
|
1338
1929
|
})
|
|
1339
1930
|
}
|
|
1340
|
-
}, [
|
|
1931
|
+
}, [isCurveReshape, mode, movingNode])
|
|
1341
1932
|
|
|
1342
1933
|
// Delete mode: click-to-delete (sledgehammer tool)
|
|
1343
1934
|
useEffect(() => {
|
|
@@ -1350,11 +1941,7 @@ export const SelectionManager = () => {
|
|
|
1350
1941
|
event.stopPropagation()
|
|
1351
1942
|
|
|
1352
1943
|
// Play appropriate SFX
|
|
1353
|
-
|
|
1354
|
-
sfxEmitter.emit('sfx:item-delete')
|
|
1355
|
-
} else {
|
|
1356
|
-
sfxEmitter.emit('sfx:structure-delete')
|
|
1357
|
-
}
|
|
1944
|
+
emitDeleteSFX(node.type)
|
|
1358
1945
|
|
|
1359
1946
|
useScene.getState().deleteNode(node.id as AnyNodeId)
|
|
1360
1947
|
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
|
@@ -1385,6 +1972,7 @@ export const SelectionManager = () => {
|
|
|
1385
1972
|
'fence',
|
|
1386
1973
|
'item',
|
|
1387
1974
|
'column',
|
|
1975
|
+
'elevator',
|
|
1388
1976
|
'slab',
|
|
1389
1977
|
'ceiling',
|
|
1390
1978
|
'roof',
|
|
@@ -1397,14 +1985,19 @@ export const SelectionManager = () => {
|
|
|
1397
1985
|
'zone',
|
|
1398
1986
|
] as const
|
|
1399
1987
|
|
|
1400
|
-
|
|
1988
|
+
const registryKinds = getSelectableKinds().filter(
|
|
1989
|
+
(k) => !(allTypes as readonly string[]).includes(k),
|
|
1990
|
+
)
|
|
1991
|
+
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
|
|
1992
|
+
|
|
1993
|
+
for (const type of subscribedKinds) {
|
|
1401
1994
|
emitter.on(`${type}:click` as any, onClick as any)
|
|
1402
1995
|
emitter.on(`${type}:enter` as any, onEnter as any)
|
|
1403
1996
|
emitter.on(`${type}:leave` as any, onLeave as any)
|
|
1404
1997
|
}
|
|
1405
1998
|
|
|
1406
1999
|
return () => {
|
|
1407
|
-
for (const type of
|
|
2000
|
+
for (const type of subscribedKinds) {
|
|
1408
2001
|
emitter.off(`${type}:click` as any, onClick as any)
|
|
1409
2002
|
emitter.off(`${type}:enter` as any, onEnter as any)
|
|
1410
2003
|
emitter.off(`${type}:leave` as any, onLeave as any)
|
|
@@ -1425,6 +2018,8 @@ export const SelectionManager = () => {
|
|
|
1425
2018
|
const SelectionStateSync = () => {
|
|
1426
2019
|
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
|
1427
2020
|
const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget)
|
|
2021
|
+
const roofHostDragArmedId = useEditor((s) => s.roofHostDragArmedId)
|
|
2022
|
+
const setRoofHostDragArmedId = useEditor((s) => s.setRoofHostDragArmedId)
|
|
1428
2023
|
const singleSelectedId = useViewer((s) =>
|
|
1429
2024
|
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
|
|
1430
2025
|
)
|
|
@@ -1457,6 +2052,12 @@ const SelectionStateSync = () => {
|
|
|
1457
2052
|
})
|
|
1458
2053
|
}, [])
|
|
1459
2054
|
|
|
2055
|
+
useEffect(() => {
|
|
2056
|
+
if (!roofHostDragArmedId) return
|
|
2057
|
+
if (singleSelectedId === roofHostDragArmedId) return
|
|
2058
|
+
setRoofHostDragArmedId(null)
|
|
2059
|
+
}, [roofHostDragArmedId, setRoofHostDragArmedId, singleSelectedId])
|
|
2060
|
+
|
|
1460
2061
|
useEffect(() => {
|
|
1461
2062
|
if (!selectedMaterialTarget) return
|
|
1462
2063
|
|
|
@@ -1468,7 +2069,8 @@ const SelectionStateSync = () => {
|
|
|
1468
2069
|
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
|
|
1469
2070
|
if (
|
|
1470
2071
|
!selectedNode ||
|
|
1471
|
-
(selectedNode.type
|
|
2072
|
+
(!nodeRegistry.get(selectedNode.type)?.capabilities?.paint &&
|
|
2073
|
+
selectedNode.type !== 'wall' &&
|
|
1472
2074
|
selectedNode.type !== 'fence' &&
|
|
1473
2075
|
selectedNode.type !== 'slab' &&
|
|
1474
2076
|
selectedNode.type !== 'ceiling' &&
|
|
@@ -1492,6 +2094,7 @@ const SelectionMaterialSync = () => {
|
|
|
1492
2094
|
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
|
1493
2095
|
const hoveredId = useViewer((s) => s.hoveredId)
|
|
1494
2096
|
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
|
|
2097
|
+
const geometryRevision = useViewer((s) => s.geometryRevision)
|
|
1495
2098
|
const activeHighlightKindsRef = useRef(new Map<string, HighlightKind>())
|
|
1496
2099
|
const highlightedMaterialsRef = useRef(
|
|
1497
2100
|
new Map<
|
|
@@ -1568,6 +2171,7 @@ const SelectionMaterialSync = () => {
|
|
|
1568
2171
|
}, [])
|
|
1569
2172
|
|
|
1570
2173
|
useEffect(() => {
|
|
2174
|
+
void geometryRevision
|
|
1571
2175
|
const nextHighlightKinds = new Map<string, HighlightKind>()
|
|
1572
2176
|
|
|
1573
2177
|
for (const id of new Set([...selectedIds, ...previewSelectedIds])) {
|
|
@@ -1580,7 +2184,14 @@ const SelectionMaterialSync = () => {
|
|
|
1580
2184
|
|
|
1581
2185
|
activeHighlightKindsRef.current = nextHighlightKinds
|
|
1582
2186
|
syncSelectionMaterials()
|
|
1583
|
-
}, [
|
|
2187
|
+
}, [
|
|
2188
|
+
geometryRevision,
|
|
2189
|
+
hoverHighlightMode,
|
|
2190
|
+
hoveredId,
|
|
2191
|
+
previewSelectedIds,
|
|
2192
|
+
selectedIds,
|
|
2193
|
+
syncSelectionMaterials,
|
|
2194
|
+
])
|
|
1584
2195
|
|
|
1585
2196
|
useEffect(() => {
|
|
1586
2197
|
return useScene.subscribe((state, prevState) => {
|
|
@@ -1635,9 +2246,12 @@ const EditorOutlinerSync = () => {
|
|
|
1635
2246
|
const selection = useViewer((s) => s.selection)
|
|
1636
2247
|
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
|
1637
2248
|
const hoveredId = useViewer((s) => s.hoveredId)
|
|
2249
|
+
const geometryRevision = useViewer((s) => s.geometryRevision)
|
|
1638
2250
|
const outliner = useViewer((s) => s.outliner)
|
|
2251
|
+
const nodes = useScene((s) => s.nodes)
|
|
1639
2252
|
|
|
1640
2253
|
useEffect(() => {
|
|
2254
|
+
void geometryRevision
|
|
1641
2255
|
let idsToHighlight: string[] = []
|
|
1642
2256
|
|
|
1643
2257
|
// 1. Determine what should be highlighted based on Phase
|
|
@@ -1672,16 +2286,25 @@ const EditorOutlinerSync = () => {
|
|
|
1672
2286
|
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
|
1673
2287
|
outliner.selectedObjects.length = 0
|
|
1674
2288
|
for (const id of idsToHighlight) {
|
|
2289
|
+
if (!nodes[id as AnyNodeId]) continue
|
|
1675
2290
|
const obj = sceneRegistry.nodes.get(id)
|
|
1676
2291
|
if (obj?.parent) outliner.selectedObjects.push(obj)
|
|
1677
2292
|
}
|
|
1678
2293
|
|
|
1679
2294
|
outliner.hoveredObjects.length = 0
|
|
1680
2295
|
if (hoveredId) {
|
|
1681
|
-
|
|
1682
|
-
|
|
2296
|
+
if (!nodes[hoveredId as AnyNodeId]) {
|
|
2297
|
+
useViewer.setState({ hoveredId: null })
|
|
2298
|
+
} else {
|
|
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)
|
|
2304
|
+
if (obj?.parent) outliner.hoveredObjects.push(obj)
|
|
2305
|
+
}
|
|
1683
2306
|
}
|
|
1684
|
-
}, [phase, previewSelectedIds, selection, hoveredId, outliner])
|
|
2307
|
+
}, [geometryRevision, phase, previewSelectedIds, selection, hoveredId, outliner, nodes])
|
|
1685
2308
|
|
|
1686
2309
|
return null
|
|
1687
2310
|
}
|