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