@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
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
bboxCornerAnchors,
|
|
5
|
+
collectAlignmentAnchors,
|
|
6
|
+
emitter,
|
|
7
|
+
pauseSceneHistory,
|
|
8
|
+
pauseSpaceDetection,
|
|
9
|
+
resolveAlignment,
|
|
10
|
+
resumeSceneHistory,
|
|
11
|
+
resumeSpaceDetection,
|
|
12
|
+
type SceneMaterialId,
|
|
13
|
+
useLiveNodeOverrides,
|
|
14
|
+
useLiveTransforms,
|
|
15
|
+
useScene,
|
|
16
|
+
} from '@pascal-app/core'
|
|
17
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
18
|
+
import { Plane, Vector2, Vector3 } from 'three'
|
|
19
|
+
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
|
|
20
|
+
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
|
21
|
+
import {
|
|
22
|
+
copySelectedNodesToEditorClipboard,
|
|
23
|
+
duplicateNodesToLevel,
|
|
24
|
+
getEditorClipboardSnapshot,
|
|
25
|
+
pasteSystemEditorClipboardToLevel,
|
|
26
|
+
} from '../../lib/scene-clipboard'
|
|
27
|
+
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
|
28
|
+
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
29
|
+
import useDeleteConfirmation from '../../store/use-delete-confirmation'
|
|
30
|
+
import useEditor, {
|
|
31
|
+
isAlignmentGuideActive,
|
|
32
|
+
isBrushMode,
|
|
33
|
+
isGridSnapActive,
|
|
34
|
+
isMagneticSnapActive,
|
|
35
|
+
} from '../../store/use-editor'
|
|
36
|
+
import useInteractionScope from '../../store/use-interaction-scope'
|
|
37
|
+
import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
|
|
38
|
+
import {
|
|
39
|
+
classifyParticipant,
|
|
40
|
+
collectParticipants,
|
|
41
|
+
computeGroupBox,
|
|
42
|
+
expandToComponent,
|
|
43
|
+
levelFrame,
|
|
44
|
+
participantExtents,
|
|
45
|
+
rotateGroupSnapshots,
|
|
46
|
+
translateGroupPatches,
|
|
47
|
+
type Vec2,
|
|
48
|
+
} from './group-transform-shared'
|
|
49
|
+
import { swallowNextClick } from './handles/use-handle-drag'
|
|
50
|
+
import { getEditorThreeContext } from './three-context-bridge'
|
|
51
|
+
|
|
52
|
+
// Whole-selection actions behind the group action menu (Move / Duplicate /
|
|
53
|
+
// Delete), shared by the 2D and 3D menus. The Move flow is a "pick-up": the
|
|
54
|
+
// selection rides the cursor (relative to where tracking starts, so nothing
|
|
55
|
+
// teleports) across BOTH surfaces — floor plan via the scene CTM, 3D via a
|
|
56
|
+
// ground-plane raycast through the bridged camera — until a click commits it
|
|
57
|
+
// as one undo step. Escape / right-click cancels.
|
|
58
|
+
|
|
59
|
+
const ALIGNMENT_THRESHOLD_M = 0.08
|
|
60
|
+
// Matches the keyboard Delete arm's accidental-bulk-delete guard.
|
|
61
|
+
const BULK_DELETE_THRESHOLD = 10
|
|
62
|
+
|
|
63
|
+
// Any non-empty selection can be picked up (the menus themselves gate on a
|
|
64
|
+
// multi-selection; Duplicate can legitimately land on a single cloned root).
|
|
65
|
+
function groupParticipantIds(): string[] {
|
|
66
|
+
const { selectedIds, levelId } = useViewer.getState().selection
|
|
67
|
+
if (selectedIds.length === 0) return []
|
|
68
|
+
const nodes = useScene.getState().nodes
|
|
69
|
+
return selectedIds.filter(
|
|
70
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True when the current selection is a group the pick-up move can carry. */
|
|
75
|
+
export function canGroupPickUp(): boolean {
|
|
76
|
+
return groupParticipantIds().length > 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Pick up the current multi-selection: it follows the cursor (delta-relative)
|
|
81
|
+
* until a click commits, mirroring the single-node `movingNode` flow. Returns
|
|
82
|
+
* false when the selection holds no transformable participants.
|
|
83
|
+
*
|
|
84
|
+
* `scopeToSelection` limits the moving set to the selected participants —
|
|
85
|
+
* no connected-component expansion and no welded-neighbor endpoints. The
|
|
86
|
+
* Duplicate flow needs this: its clones sit EXACTLY on the originals, so
|
|
87
|
+
* junction coincidence would otherwise weld the originals into the pick-up
|
|
88
|
+
* and drag them along with the copies.
|
|
89
|
+
*/
|
|
90
|
+
export function startGroupPickUp(
|
|
91
|
+
opts: { onCancel?: () => void; positionAtCursor?: boolean; scopeToSelection?: boolean } = {},
|
|
92
|
+
): boolean {
|
|
93
|
+
const { selectedIds, levelId } = useViewer.getState().selection
|
|
94
|
+
const participantIds = groupParticipantIds()
|
|
95
|
+
if (participantIds.length === 0) return false
|
|
96
|
+
const nodes = useScene.getState().nodes
|
|
97
|
+
const fullIds = opts.scopeToSelection
|
|
98
|
+
? participantIds
|
|
99
|
+
: expandToComponent(participantIds, nodes, levelId)
|
|
100
|
+
const collected = collectParticipants(fullIds, nodes, levelId)
|
|
101
|
+
// Mutable: mid-carry R/T rotates these snapshots in place.
|
|
102
|
+
let starts = collected.starts
|
|
103
|
+
let links = opts.scopeToSelection ? [] : collected.links
|
|
104
|
+
if (starts.length === 0) return false
|
|
105
|
+
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
|
106
|
+
|
|
107
|
+
// Rest bounds in the level frame. Prefer the mounted meshes' world box
|
|
108
|
+
// (footprint-accurate), but fall back to the participant DATA when the
|
|
109
|
+
// meshes aren't up yet — Duplicate starts the pick-up synchronously after
|
|
110
|
+
// `createNodes`, one frame before the clones' renderers mount.
|
|
111
|
+
const { inverse: frameInv } = levelFrame(levelId)
|
|
112
|
+
const restBox = computeGroupBox(fullIds)
|
|
113
|
+
let minX = Number.POSITIVE_INFINITY
|
|
114
|
+
let minZ = Number.POSITIVE_INFINITY
|
|
115
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
116
|
+
let maxZ = Number.NEGATIVE_INFINITY
|
|
117
|
+
if (restBox) {
|
|
118
|
+
const boxMin = restBox.min.clone().applyMatrix4(frameInv)
|
|
119
|
+
const boxMax = restBox.max.clone().applyMatrix4(frameInv)
|
|
120
|
+
minX = Math.min(boxMin.x, boxMax.x)
|
|
121
|
+
minZ = Math.min(boxMin.z, boxMax.z)
|
|
122
|
+
maxX = Math.max(boxMin.x, boxMax.x)
|
|
123
|
+
maxZ = Math.max(boxMin.z, boxMax.z)
|
|
124
|
+
} else {
|
|
125
|
+
const reach = (x: number, z: number) => {
|
|
126
|
+
minX = Math.min(minX, x)
|
|
127
|
+
minZ = Math.min(minZ, z)
|
|
128
|
+
maxX = Math.max(maxX, x)
|
|
129
|
+
maxZ = Math.max(maxZ, z)
|
|
130
|
+
}
|
|
131
|
+
for (const s of starts) {
|
|
132
|
+
if (s.kind === 'endpoint') {
|
|
133
|
+
reach(s.start[0], s.start[1])
|
|
134
|
+
reach(s.end[0], s.end[1])
|
|
135
|
+
} else if (s.kind === 'polygon') {
|
|
136
|
+
for (const [x, z] of s.polygon) {
|
|
137
|
+
reach(x, z)
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
reach(s.position[0], s.position[2])
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!Number.isFinite(minX)) return false
|
|
145
|
+
// Rotation pivot for mid-carry R/T; stable across the whole pick-up.
|
|
146
|
+
const restCenter: [number, number] = [(minX + maxX) / 2, (minZ + maxZ) / 2]
|
|
147
|
+
// Ground plane for the 3D surface: the meshes' base when available, floor
|
|
148
|
+
// level otherwise. Placements live in the level frame, so both surfaces
|
|
149
|
+
// resolve into it before measuring.
|
|
150
|
+
const plane = new Plane(new Vector3(0, 1, 0), -(restBox?.min.y ?? 0))
|
|
151
|
+
|
|
152
|
+
// Alignment candidates — everything outside the moving set; the group
|
|
153
|
+
// aligns as one rigid footprint via its bbox corners + center.
|
|
154
|
+
const movingIdSet = new Set<string>(affectedIds)
|
|
155
|
+
const staticNodes: Record<string, AnyNode> = {}
|
|
156
|
+
for (const [nid, n] of Object.entries(nodes)) {
|
|
157
|
+
if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
|
|
158
|
+
}
|
|
159
|
+
const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
|
|
160
|
+
let restAnchors = bboxCornerAnchors('group-move', minX, minZ, maxX, maxZ)
|
|
161
|
+
|
|
162
|
+
// Cursor → level-frame plan point, whichever surface the pointer is over.
|
|
163
|
+
const ndc = new Vector2()
|
|
164
|
+
const resolvePlanPoint = (e: PointerEvent): Vec2 | null => {
|
|
165
|
+
const three = getEditorThreeContext()
|
|
166
|
+
if (three && e.target === three.domElement) {
|
|
167
|
+
const rect = three.domElement.getBoundingClientRect()
|
|
168
|
+
ndc.set(
|
|
169
|
+
((e.clientX - rect.left) / rect.width) * 2 - 1,
|
|
170
|
+
-((e.clientY - rect.top) / rect.height) * 2 + 1,
|
|
171
|
+
)
|
|
172
|
+
three.raycaster.setFromCamera(ndc, three.camera)
|
|
173
|
+
const hit = new Vector3()
|
|
174
|
+
if (!three.raycaster.ray.intersectPlane(plane, hit)) return null
|
|
175
|
+
const local = hit.applyMatrix4(frameInv)
|
|
176
|
+
return [local.x, local.z]
|
|
177
|
+
}
|
|
178
|
+
// Anywhere inside the floor-plan viewport counts (the scene `<g>` only
|
|
179
|
+
// covers painted elements, so bounds-test the owning SVG).
|
|
180
|
+
const sceneEl = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
|
|
181
|
+
const svg = sceneEl?.ownerSVGElement
|
|
182
|
+
if (!svg) return null
|
|
183
|
+
const rect = svg.getBoundingClientRect()
|
|
184
|
+
if (
|
|
185
|
+
e.clientX < rect.left ||
|
|
186
|
+
e.clientX > rect.right ||
|
|
187
|
+
e.clientY < rect.top ||
|
|
188
|
+
e.clientY > rect.bottom
|
|
189
|
+
) {
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
const plan = clientToPlan(e.clientX, e.clientY)
|
|
193
|
+
return plan ? [plan[0], plan[1]] : null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let startPlan: Vec2 | null = null
|
|
197
|
+
let lastDelta: Vec2 | null = null
|
|
198
|
+
|
|
199
|
+
for (const id of affectedIds) {
|
|
200
|
+
useLiveTransforms.getState().clear(id)
|
|
201
|
+
}
|
|
202
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
203
|
+
pauseSceneHistory(useScene)
|
|
204
|
+
useInteractionScope.getState().begin({
|
|
205
|
+
kind: 'handle-drag',
|
|
206
|
+
nodeId: (participantIds[0] ?? '') as AnyNodeId,
|
|
207
|
+
handle: GROUP_MOVE_DRAG_LABEL,
|
|
208
|
+
})
|
|
209
|
+
document.body.style.cursor = 'grabbing'
|
|
210
|
+
|
|
211
|
+
const applyMove = (e: PointerEvent) => {
|
|
212
|
+
const plan = resolvePlanPoint(e)
|
|
213
|
+
if (!plan) return
|
|
214
|
+
// Ordinary moves are delta-relative so the group never teleports. A
|
|
215
|
+
// pasted selection instead arrives centered under the cursor.
|
|
216
|
+
if (!opts.positionAtCursor && !startPlan) {
|
|
217
|
+
startPlan = plan
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
const step = useEditor.getState().gridSnapStep
|
|
221
|
+
const snap = isGridSnapActive() && step > 0
|
|
222
|
+
const rawDx = opts.positionAtCursor ? plan[0] - restCenter[0] : plan[0] - startPlan![0]
|
|
223
|
+
const rawDz = opts.positionAtCursor ? plan[1] - restCenter[1] : plan[1] - startPlan![1]
|
|
224
|
+
let dx = snap ? Math.round(rawDx / step) * step : rawDx
|
|
225
|
+
let dz = snap ? Math.round(rawDz / step) * step : rawDz
|
|
226
|
+
|
|
227
|
+
if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) {
|
|
228
|
+
const result = resolveAlignment({
|
|
229
|
+
moving: restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
|
|
230
|
+
candidates,
|
|
231
|
+
threshold: ALIGNMENT_THRESHOLD_M,
|
|
232
|
+
})
|
|
233
|
+
if (result.snap && isMagneticSnapActive()) {
|
|
234
|
+
dx += result.snap.dx
|
|
235
|
+
dz += result.snap.dz
|
|
236
|
+
}
|
|
237
|
+
useAlignmentGuides.getState().set(result.guides)
|
|
238
|
+
} else {
|
|
239
|
+
useAlignmentGuides.getState().clear()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
applyDelta(dx, dz)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const applyDelta = (dx: number, dz: number) => {
|
|
246
|
+
if (!lastDelta || lastDelta[0] !== dx || lastDelta[1] !== dz) {
|
|
247
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
248
|
+
lastDelta = [dx, dz]
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const entries = translateGroupPatches(starts, links, dx, dz)
|
|
252
|
+
const patchById = new Map(entries)
|
|
253
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
254
|
+
for (const start of starts) {
|
|
255
|
+
if (start.kind === 'scalar') {
|
|
256
|
+
const patch = patchById.get(start.id)
|
|
257
|
+
if (patch) {
|
|
258
|
+
liveTransforms.set(start.id, {
|
|
259
|
+
position: patch.position as [number, number, number],
|
|
260
|
+
rotation: start.rotation,
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
useScene.getState().markDirty(start.id)
|
|
265
|
+
}
|
|
266
|
+
for (const l of links) {
|
|
267
|
+
useScene.getState().markDirty(l.id)
|
|
268
|
+
}
|
|
269
|
+
useLiveNodeOverrides.getState().setMany(entries)
|
|
270
|
+
useFloorplanGroupDrag.getState().set([dx, dz])
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Mid-carry R/T: rotate the SNAPSHOTS around the rest pivot and re-apply
|
|
274
|
+
// the current delta — the carried group turns exactly like the idle
|
|
275
|
+
// keyboard rotate, and the placement stays a single updateNodes.
|
|
276
|
+
const rotateCarried = (direction: 1 | -1) => {
|
|
277
|
+
const rotated = rotateGroupSnapshots(
|
|
278
|
+
starts,
|
|
279
|
+
links,
|
|
280
|
+
{ x: restCenter[0], z: restCenter[1] },
|
|
281
|
+
-direction * (Math.PI / 4),
|
|
282
|
+
)
|
|
283
|
+
starts = rotated.starts
|
|
284
|
+
links = rotated.links
|
|
285
|
+
const ext = participantExtents(rotated.starts)
|
|
286
|
+
if (ext) {
|
|
287
|
+
restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
|
|
288
|
+
}
|
|
289
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
290
|
+
applyDelta(lastDelta?.[0] ?? 0, lastDelta?.[1] ?? 0)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const clearLivePreviews = () => {
|
|
294
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
295
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
296
|
+
for (const id of affectedIds) {
|
|
297
|
+
overrides.clear(id)
|
|
298
|
+
liveTransforms.clear(id)
|
|
299
|
+
useScene.getState().markDirty(id)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const removeListeners = () => {
|
|
304
|
+
window.removeEventListener('pointermove', onMove)
|
|
305
|
+
window.removeEventListener('pointerdown', onPointerDown, true)
|
|
306
|
+
window.removeEventListener('pointerup', onPointerUp, true)
|
|
307
|
+
window.removeEventListener('keydown', onKeyDown, true)
|
|
308
|
+
window.removeEventListener('contextmenu', onContextMenu, true)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// History resume pairs one-to-one with the pause above, on the commit and
|
|
312
|
+
// cancel paths only.
|
|
313
|
+
const teardown = () => {
|
|
314
|
+
removeListeners()
|
|
315
|
+
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
|
316
|
+
useAlignmentGuides.getState().clear()
|
|
317
|
+
useInteractionScope
|
|
318
|
+
.getState()
|
|
319
|
+
.endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_MOVE_DRAG_LABEL)
|
|
320
|
+
useFloorplanGroupDrag.getState().set(null)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const commit = () => {
|
|
324
|
+
swallowNextClick()
|
|
325
|
+
sfxEmitter.emit('sfx:item-place')
|
|
326
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
327
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
328
|
+
for (const id of affectedIds) {
|
|
329
|
+
const patch = overrides.get(id)
|
|
330
|
+
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
|
|
331
|
+
}
|
|
332
|
+
// Group transforms move existing structure rigidly — the wall-driven room
|
|
333
|
+
// auto-detection must not re-create floors/ceilings for the walls' new
|
|
334
|
+
// positions (that belongs to wall building/editing). Paused around the
|
|
335
|
+
// commit; the sync rolls its baseline forward for paused changes.
|
|
336
|
+
pauseSpaceDetection()
|
|
337
|
+
resumeSceneHistory(useScene)
|
|
338
|
+
if (updates.length > 0) useScene.getState().updateNodes(updates)
|
|
339
|
+
resumeSpaceDetection()
|
|
340
|
+
clearLivePreviews()
|
|
341
|
+
teardown()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const cancel = () => {
|
|
345
|
+
clearLivePreviews()
|
|
346
|
+
resumeSceneHistory(useScene)
|
|
347
|
+
teardown()
|
|
348
|
+
opts.onCancel?.()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const onMove = (e: PointerEvent) => {
|
|
352
|
+
applyMove(e)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Capture phase: commit before the press reaches selection / tools, so the
|
|
356
|
+
// drop click can't also select whatever lands under the cursor.
|
|
357
|
+
// The placement gesture: the press over a tracked surface is claimed in
|
|
358
|
+
// capture phase and the commit runs on ITS pointerup, also claimed — the
|
|
359
|
+
// canvas never sees either, so `use-node-events` cannot synthesize a
|
|
360
|
+
// selection click from the release (which would re-select whatever sits
|
|
361
|
+
// under the cursor and break the multi-selection).
|
|
362
|
+
let commitPointerId: number | null = null
|
|
363
|
+
|
|
364
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
365
|
+
if (e.button === 2) {
|
|
366
|
+
e.preventDefault()
|
|
367
|
+
e.stopPropagation()
|
|
368
|
+
cancel()
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
if (e.button !== 0) return
|
|
372
|
+
// Only a press over a tracked surface commits; a click on side panels or
|
|
373
|
+
// the toolbar keeps the pick-up alive.
|
|
374
|
+
if (!resolvePlanPoint(e)) return
|
|
375
|
+
if (opts.positionAtCursor && !lastDelta) applyMove(e)
|
|
376
|
+
e.preventDefault()
|
|
377
|
+
e.stopPropagation()
|
|
378
|
+
commitPointerId = e.pointerId
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const onPointerUp = (e: PointerEvent) => {
|
|
382
|
+
if (commitPointerId === null || e.pointerId !== commitPointerId) return
|
|
383
|
+
commitPointerId = null
|
|
384
|
+
// Raise `inputDragging` through this event's dispatch so the canvas's
|
|
385
|
+
// use-node-events suppresses the selection click it synthesizes on every
|
|
386
|
+
// pointerup (stopPropagation would also break the window bubble
|
|
387
|
+
// listeners that clear the box-select pointer suppression).
|
|
388
|
+
useViewer.getState().setInputDragging(true)
|
|
389
|
+
setTimeout(() => useViewer.getState().setInputDragging(false), 0)
|
|
390
|
+
commit()
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
394
|
+
const key = e.key.toLowerCase()
|
|
395
|
+
if ((e.metaKey || e.ctrlKey) && (key === 'c' || key === 'v' || key === 'x')) {
|
|
396
|
+
// A clipboard chord replaces the current carry. Let the global keyboard
|
|
397
|
+
// arm receive the same event after this cancellation. Capture C/X first:
|
|
398
|
+
// pasted or duplicated carries delete their transient selection while
|
|
399
|
+
// cancelling, so the global arm would otherwise see nothing.
|
|
400
|
+
if (key === 'c' || key === 'x') {
|
|
401
|
+
copySelectedNodesToEditorClipboard()
|
|
402
|
+
}
|
|
403
|
+
cancel()
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
|
407
|
+
e.preventDefault()
|
|
408
|
+
e.stopPropagation()
|
|
409
|
+
rotateCarried(key === 'r' ? 1 : -1)
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
413
|
+
// Deleting mid-carry: put the group down first (revert), then let the
|
|
414
|
+
// global Delete arm remove the selection — no dangling carry. The
|
|
415
|
+
// duplicate flow's onCancel already discards the clones instead.
|
|
416
|
+
cancel()
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
if (e.key !== 'Escape') return
|
|
420
|
+
e.preventDefault()
|
|
421
|
+
e.stopPropagation()
|
|
422
|
+
cancel()
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const onContextMenu = (e: Event) => {
|
|
426
|
+
e.preventDefault()
|
|
427
|
+
e.stopPropagation()
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
window.addEventListener('pointermove', onMove)
|
|
431
|
+
window.addEventListener('pointerdown', onPointerDown, true)
|
|
432
|
+
window.addEventListener('pointerup', onPointerUp, true)
|
|
433
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
434
|
+
window.addEventListener('contextmenu', onContextMenu, true)
|
|
435
|
+
return true
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Duplicate the whole selection (subtrees + id remap via the clipboard
|
|
440
|
+
* pipeline, without touching the clipboard), select the clones, and pick
|
|
441
|
+
* them up so the next click places them. Cancelling the pick-up removes the
|
|
442
|
+
* clones again.
|
|
443
|
+
*/
|
|
444
|
+
export function duplicateSelectionAndPickUp(): boolean {
|
|
445
|
+
const { selectedIds, levelId } = useViewer.getState().selection
|
|
446
|
+
if (selectedIds.length < 2) return false
|
|
447
|
+
const result = duplicateNodesToLevel(
|
|
448
|
+
selectedIds as AnyNodeId[],
|
|
449
|
+
(levelId ?? undefined) as AnyNodeId | undefined,
|
|
450
|
+
)
|
|
451
|
+
if (!result || result.pastedIds.length === 0) return false
|
|
452
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
453
|
+
startGroupPickUp({
|
|
454
|
+
scopeToSelection: true,
|
|
455
|
+
onCancel: () => {
|
|
456
|
+
useScene.getState().deleteNodes(result.pastedIds)
|
|
457
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
458
|
+
},
|
|
459
|
+
})
|
|
460
|
+
return true
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function sceneReferencesMaterial(materialId: string) {
|
|
464
|
+
const reference = `scene:${materialId}`
|
|
465
|
+
const containsReference = (value: unknown): boolean => {
|
|
466
|
+
if (value === reference) return true
|
|
467
|
+
if (Array.isArray(value)) return value.some(containsReference)
|
|
468
|
+
if (value && typeof value === 'object') {
|
|
469
|
+
return Object.values(value).some(containsReference)
|
|
470
|
+
}
|
|
471
|
+
return false
|
|
472
|
+
}
|
|
473
|
+
return Object.values(useScene.getState().nodes).some(containsReference)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function removeUnusedPasteMaterials(materialIds: SceneMaterialId[]) {
|
|
477
|
+
for (const materialId of materialIds) {
|
|
478
|
+
if (!sceneReferencesMaterial(materialId)) {
|
|
479
|
+
useScene.getState().removeSceneMaterial(materialId)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Paste the Pascal scene payload from the browser clipboard onto the active
|
|
486
|
+
* level, then carry the clones under the cursor until click-to-place. Escape
|
|
487
|
+
* removes the uncommitted clones and any scene materials imported with them.
|
|
488
|
+
*/
|
|
489
|
+
export async function pasteSelectionAndPickUp(targetLevelId?: AnyNodeId): Promise<boolean> {
|
|
490
|
+
const activeScope = useInteractionScope.getState().scope
|
|
491
|
+
if (activeScope.kind === 'placing' || activeScope.kind === 'moving') {
|
|
492
|
+
emitter.emit('tool:cancel')
|
|
493
|
+
}
|
|
494
|
+
// Paint and sculpt hold their scope for the whole *mode*, so a pick-up would
|
|
495
|
+
// `begin({kind: 'handle-drag'})` over it — scopes are single-owner, so the brush
|
|
496
|
+
// would stay armed and painting while the editor believed a group move was
|
|
497
|
+
// running, and the mode's own release would then end someone else's scope.
|
|
498
|
+
// ⌘V is reachable from either mode (it reads the clipboard, not the selection,
|
|
499
|
+
// which is why clearing the selection on mode entry does not cover it), so drop
|
|
500
|
+
// the brush first: carrying clones is the newer intent, and unlike the cancels
|
|
501
|
+
// above there is nothing to abandon.
|
|
502
|
+
if (isBrushMode(useEditor.getState().mode)) useEditor.getState().setMode('select')
|
|
503
|
+
|
|
504
|
+
const result = await pasteSystemEditorClipboardToLevel(targetLevelId)
|
|
505
|
+
if (!result || result.pastedIds.length === 0) return false
|
|
506
|
+
|
|
507
|
+
const discardPaste = () => {
|
|
508
|
+
useScene.getState().deleteNodes(result.pastedIds)
|
|
509
|
+
removeUnusedPasteMaterials(result.createdMaterialIds)
|
|
510
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
511
|
+
}
|
|
512
|
+
if (result.pastedIds.length === 1) {
|
|
513
|
+
const rootId = result.pastedIds[0]!
|
|
514
|
+
const root = useScene.getState().nodes[rootId]
|
|
515
|
+
if (root?.type === 'door' || root?.type === 'window') {
|
|
516
|
+
const metadata =
|
|
517
|
+
root.metadata && typeof root.metadata === 'object' && !Array.isArray(root.metadata)
|
|
518
|
+
? (root.metadata as Record<string, unknown>)
|
|
519
|
+
: {}
|
|
520
|
+
const draft = { ...root, metadata: { ...metadata, isNew: true } }
|
|
521
|
+
useScene.getState().updateNode(rootId, { metadata: draft.metadata })
|
|
522
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
523
|
+
const unsubscribe = useInteractionScope.subscribe((state, previous) => {
|
|
524
|
+
const previousOwnsDraft =
|
|
525
|
+
(previous.scope.kind === 'placing' || previous.scope.kind === 'moving') &&
|
|
526
|
+
previous.scope.nodeId === rootId
|
|
527
|
+
const currentOwnsDraft =
|
|
528
|
+
(state.scope.kind === 'placing' || state.scope.kind === 'moving') &&
|
|
529
|
+
state.scope.nodeId === rootId
|
|
530
|
+
if (!previousOwnsDraft || currentOwnsDraft) return
|
|
531
|
+
unsubscribe()
|
|
532
|
+
removeUnusedPasteMaterials(result.createdMaterialIds)
|
|
533
|
+
})
|
|
534
|
+
useEditor.getState().setMovingNode(draft)
|
|
535
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
536
|
+
return true
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const started = startGroupPickUp({
|
|
541
|
+
positionAtCursor: true,
|
|
542
|
+
scopeToSelection: true,
|
|
543
|
+
onCancel: discardPaste,
|
|
544
|
+
})
|
|
545
|
+
if (!started) sfxEmitter.emit('sfx:item-place')
|
|
546
|
+
return true
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Cut uses the same cross-tab clipboard payload as Copy, then removes exactly
|
|
551
|
+
* the copied roots. Promoted subtree selections (such as all modules in one
|
|
552
|
+
* cabinet run) therefore remove the same root that Paste will recreate.
|
|
553
|
+
*/
|
|
554
|
+
export function cutSelectionToEditorClipboard(): boolean {
|
|
555
|
+
if (!copySelectedNodesToEditorClipboard()) return false
|
|
556
|
+
const payload = getEditorClipboardSnapshot()
|
|
557
|
+
if (!payload || payload.rootIds.length === 0) return false
|
|
558
|
+
|
|
559
|
+
if (payload.rootIds.length === 1) {
|
|
560
|
+
emitDeleteSFX(useScene.getState().nodes[payload.rootIds[0]!]?.type)
|
|
561
|
+
} else {
|
|
562
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
563
|
+
}
|
|
564
|
+
useScene.getState().deleteNodes(payload.rootIds)
|
|
565
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
566
|
+
return true
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Delete every selected node — same semantics as the keyboard Delete arm,
|
|
571
|
+
* including the accidental-bulk-delete confirm.
|
|
572
|
+
*/
|
|
573
|
+
export function deleteSelection(): boolean {
|
|
574
|
+
const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
575
|
+
if (selectedIds.length === 0) return false
|
|
576
|
+
|
|
577
|
+
const commitDelete = () => {
|
|
578
|
+
if (selectedIds.length === 1) {
|
|
579
|
+
emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type)
|
|
580
|
+
} else {
|
|
581
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
582
|
+
}
|
|
583
|
+
useScene.getState().deleteNodes(selectedIds)
|
|
584
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
|
|
588
|
+
useDeleteConfirmation.getState().requestConfirmation({
|
|
589
|
+
count: selectedIds.length,
|
|
590
|
+
onConfirm: commitDelete,
|
|
591
|
+
})
|
|
592
|
+
return true
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
commitDelete()
|
|
596
|
+
return true
|
|
597
|
+
}
|