@pascal-app/editor 0.8.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +795 -28
- package/src/components/editor/editor-layout-mobile.tsx +8 -3
- package/src/components/editor/editor-layout-v2.tsx +76 -51
- package/src/components/editor/export-manager.tsx +69 -32
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
- package/src/components/editor/first-person/build-collider-world.ts +228 -83
- package/src/components/editor/first-person-controls.tsx +943 -27
- package/src/components/editor/floating-action-menu.tsx +676 -208
- package/src/components/editor/floating-building-action-menu.tsx +2 -2
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +6916 -12137
- package/src/components/editor/grid.tsx +197 -36
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +420 -0
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +396 -0
- package/src/components/editor/group-transform-shared.ts +489 -0
- package/src/components/editor/handles/handle-arrow.tsx +581 -0
- package/src/components/editor/handles/use-handle-drag.ts +246 -0
- package/src/components/editor/index.tsx +412 -162
- package/src/components/editor/measurement-pill.tsx +115 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +1646 -0
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +1106 -483
- package/src/components/editor/site-edge-labels.tsx +45 -21
- package/src/components/editor/slab-hole-highlights.tsx +474 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +140 -41
- package/src/components/editor/use-floorplan-background-placement.ts +190 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +19 -7
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +14 -19
- package/src/components/editor/wall-move-side-handles.tsx +870 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
- package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +375 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
- package/src/components/tools/fence/fence-drafting.ts +141 -27
- package/src/components/tools/item/move-tool.tsx +45 -105
- package/src/components/tools/item/placement-math.test.ts +22 -0
- package/src/components/tools/item/placement-math.ts +59 -7
- package/src/components/tools/item/placement-strategies.ts +432 -16
- package/src/components/tools/item/placement-types.ts +22 -1
- package/src/components/tools/item/use-draft-node.ts +51 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
- package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
- package/src/components/tools/roof/roof-tool.tsx +350 -28
- package/src/components/tools/select/box-select-state.ts +88 -0
- package/src/components/tools/select/box-select-tool.tsx +442 -520
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +305 -0
- package/src/components/tools/shared/polygon-editor.tsx +866 -175
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +520 -16
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +2 -1
- package/src/components/tools/stair/stair-tool.tsx +342 -37
- package/src/components/tools/tool-manager.tsx +239 -62
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +248 -171
- package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +86 -82
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -170
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +3 -117
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +27 -93
- package/src/components/ui/action-menu/view-toggles.tsx +279 -90
- package/src/components/ui/command-palette/editor-commands.tsx +20 -15
- package/src/components/ui/command-palette/index.tsx +5 -3
- package/src/components/ui/controls/material-paint-panel.tsx +152 -0
- package/src/components/ui/controls/material-picker.tsx +87 -177
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +142 -46
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +235 -23
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +17 -16
- package/src/components/ui/panels/panel-manager.tsx +55 -58
- package/src/components/ui/panels/panel-wrapper.tsx +238 -12
- package/src/components/ui/panels/parametric-inspector.tsx +482 -0
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +75 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +253 -61
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +64 -10
- package/src/hooks/use-ceiling-events.ts +176 -0
- package/src/hooks/use-drag-action.ts +127 -0
- package/src/hooks/use-keyboard.ts +493 -95
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +451 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/constants.ts +6 -2
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +50 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +117 -0
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +10 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +339 -0
- package/src/lib/fresh-planar-placement.ts +130 -0
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +11 -1
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +227 -48
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +100 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +7 -7
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +332 -0
- package/src/lib/scene.ts +40 -8
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +69 -10
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +148 -55
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +231 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +767 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +39 -0
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +34 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
DEFAULT_ANGLE_STEP,
|
|
7
|
+
pauseSpaceDetection,
|
|
8
|
+
resumeSpaceDetection,
|
|
9
|
+
useLiveNodeOverrides,
|
|
10
|
+
useLiveTransforms,
|
|
11
|
+
useScene,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
14
|
+
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
|
15
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
16
|
+
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
|
|
17
|
+
import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
|
|
18
|
+
import { isHistoryShortcut } from '../../lib/history'
|
|
19
|
+
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
20
|
+
import useEditor from '../../store/use-editor'
|
|
21
|
+
import useInteractionScope, {
|
|
22
|
+
useActiveHandleDrag,
|
|
23
|
+
useMovingNode,
|
|
24
|
+
} from '../../store/use-interaction-scope'
|
|
25
|
+
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
|
26
|
+
import {
|
|
27
|
+
CORNER_OFFSET,
|
|
28
|
+
classifyParticipant,
|
|
29
|
+
collectParticipants,
|
|
30
|
+
computeGroupBox,
|
|
31
|
+
expandToComponent,
|
|
32
|
+
levelFrame,
|
|
33
|
+
rotateGroupPatches,
|
|
34
|
+
type Vec3,
|
|
35
|
+
} from './group-transform-shared'
|
|
36
|
+
import {
|
|
37
|
+
ARROW_COLOR,
|
|
38
|
+
ARROW_HOVER_COLOR,
|
|
39
|
+
ARROW_SCALE,
|
|
40
|
+
createRotateArrowHandleGeometry,
|
|
41
|
+
createRotateArrowHitAreaGeometry,
|
|
42
|
+
GuideRing,
|
|
43
|
+
RotationGuide,
|
|
44
|
+
type RotationGuideData,
|
|
45
|
+
swallowNextClick,
|
|
46
|
+
useArrowMaterial,
|
|
47
|
+
useInvisibleHitAreaMaterial,
|
|
48
|
+
} from './node-arrow-handles'
|
|
49
|
+
import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Group-rotate gizmo. When 2+ transformable nodes in the active level frame are
|
|
53
|
+
* selected, a single rotation handle appears at the selection's bounding-box
|
|
54
|
+
* center. Dragging it spins every selected node rigidly around that shared
|
|
55
|
+
* center — orbiting each node's position AND turning its yaw by the same delta,
|
|
56
|
+
* so the group rotates as one piece.
|
|
57
|
+
*
|
|
58
|
+
* The single-selection case is handled by `NodeArrowHandles`; a full-level
|
|
59
|
+
* box-select promotes to a building selection, so neither reaches this gizmo.
|
|
60
|
+
*/
|
|
61
|
+
export function GroupRotateHandle() {
|
|
62
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
63
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
64
|
+
const mode = useEditor((s) => s.mode)
|
|
65
|
+
const movingNode = useMovingNode()
|
|
66
|
+
const activeHandleDrag = useActiveHandleDrag()
|
|
67
|
+
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
68
|
+
// Re-derive participants whenever the scene mutates (e.g. after a commit).
|
|
69
|
+
// Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
|
|
70
|
+
const nodes = useScene((s) => s.nodes)
|
|
71
|
+
// Re-measure the pivot/corner once the meshes settle after a scene change.
|
|
72
|
+
const meshEpoch = useMeshSettleEpoch(nodes)
|
|
73
|
+
|
|
74
|
+
const participantIds = useMemo(
|
|
75
|
+
() =>
|
|
76
|
+
selectedIds.filter(
|
|
77
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
78
|
+
),
|
|
79
|
+
[selectedIds, levelId, nodes],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// Gate on the explicit selection (so a single connected wall still gets the
|
|
83
|
+
// per-node handles), but transform the full connected wall/fence component so
|
|
84
|
+
// attached structure rotates rigidly as one piece.
|
|
85
|
+
const fullIds = useMemo(
|
|
86
|
+
() => expandToComponent(participantIds, nodes, levelId),
|
|
87
|
+
[participantIds, levelId, nodes],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const shouldRender =
|
|
91
|
+
participantIds.length >= 2 &&
|
|
92
|
+
mode !== 'delete' &&
|
|
93
|
+
!movingNode &&
|
|
94
|
+
!isFloorplanHovered &&
|
|
95
|
+
// Hide while the sibling move gizmo drags the group — the frozen corner
|
|
96
|
+
// this handle would sit at goes stale as the group slides under it.
|
|
97
|
+
activeHandleDrag?.label !== GROUP_MOVE_DRAG_LABEL
|
|
98
|
+
|
|
99
|
+
if (!shouldRender) return null
|
|
100
|
+
// Remount when the moving set changes so the rest pivot re-seeds cleanly.
|
|
101
|
+
return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} meshEpoch={meshEpoch} />
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: number }) {
|
|
105
|
+
const { camera, raycaster, gl, scene } = useThree()
|
|
106
|
+
const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
|
|
107
|
+
const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), [])
|
|
108
|
+
const arrowMaterial = useArrowMaterial()
|
|
109
|
+
const hitMaterial = useInvisibleHitAreaMaterial()
|
|
110
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
111
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
112
|
+
const [guide, setGuide] = useState<RotationGuideData | null>(null)
|
|
113
|
+
const dragCleanupRef = useRef<(() => void) | null>(null)
|
|
114
|
+
const frozenRest = useRef<{ pivot: Vector3; corner: Vector3 } | null>(null)
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
|
118
|
+
}, [arrowMaterial, isHovered])
|
|
119
|
+
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
|
120
|
+
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
|
121
|
+
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
|
122
|
+
useEffect(() => () => dragCleanupRef.current?.(), [])
|
|
123
|
+
|
|
124
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
125
|
+
const baseScale = zoom * ARROW_SCALE * 1.05
|
|
126
|
+
const scale = (isHovered ? 1.12 : 1) * baseScale
|
|
127
|
+
|
|
128
|
+
// World-space bounding box of the selected meshes. Levels are axis-aligned in
|
|
129
|
+
// XZ, so world XZ coincides with each node's level-local placement — letting
|
|
130
|
+
// us rotate `position` / `start` / `end` directly against the pivot without
|
|
131
|
+
// per-node frame conversion.
|
|
132
|
+
// - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin
|
|
133
|
+
// - `corner` = front-right bbox corner at mid-height → where the gizmo sits
|
|
134
|
+
const rest = useMemo(() => {
|
|
135
|
+
const box = computeGroupBox(ids)
|
|
136
|
+
if (!box) return null
|
|
137
|
+
const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
|
|
138
|
+
const corner = new Vector3(
|
|
139
|
+
box.max.x + CORNER_OFFSET,
|
|
140
|
+
(box.min.y + box.max.y) / 2,
|
|
141
|
+
box.max.z + CORNER_OFFSET,
|
|
142
|
+
)
|
|
143
|
+
return { pivot, corner }
|
|
144
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
|
|
145
|
+
}, [ids, meshEpoch])
|
|
146
|
+
|
|
147
|
+
if (!rest) return null
|
|
148
|
+
const active = isDragging && frozenRest.current ? frozenRest.current : rest
|
|
149
|
+
const corner = active.corner
|
|
150
|
+
|
|
151
|
+
const onHoverEnter = (event: ThreeEvent<PointerEvent>) => {
|
|
152
|
+
event.stopPropagation()
|
|
153
|
+
setIsHovered(true)
|
|
154
|
+
if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'grab'
|
|
155
|
+
}
|
|
156
|
+
const onHoverLeave = (event: ThreeEvent<PointerEvent>) => {
|
|
157
|
+
event.stopPropagation()
|
|
158
|
+
setIsHovered(false)
|
|
159
|
+
if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const activate = (event: ThreeEvent<PointerEvent>) => {
|
|
163
|
+
if (event.button !== 0) return
|
|
164
|
+
event.stopPropagation()
|
|
165
|
+
suppressBoxSelectForPointer(event)
|
|
166
|
+
|
|
167
|
+
frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() }
|
|
168
|
+
const center = rest.pivot.clone()
|
|
169
|
+
|
|
170
|
+
// Snapshot the selected participants + connected wall/fence neighbours whose
|
|
171
|
+
// shared endpoints must follow the rotation (so junctions stay welded).
|
|
172
|
+
const levelId = useViewer.getState().selection.levelId
|
|
173
|
+
const { starts, links } = collectParticipants(ids, useScene.getState().nodes, levelId)
|
|
174
|
+
if (starts.length === 0) return
|
|
175
|
+
|
|
176
|
+
// Placements live in the level frame; the world pivot must be converted into
|
|
177
|
+
// it before orbiting positions, or a rotated building displaces the centre.
|
|
178
|
+
// The swept angle is frame-invariant (both frames differ by a constant yaw,
|
|
179
|
+
// which cancels in `angleOf(move) - angleOf(start)`), so it's still measured
|
|
180
|
+
// in world against `center` — keeping the world-space guide overlay correct.
|
|
181
|
+
const localCenter = center.clone().applyMatrix4(levelFrame(levelId).inverse)
|
|
182
|
+
|
|
183
|
+
// Horizontal drag plane at the pivot; bearing measured around the pivot.
|
|
184
|
+
const plane = new Plane(new Vector3(0, 1, 0), -center.y)
|
|
185
|
+
const angleOf = (p: Vector3) => Math.atan2(p.z - center.z, p.x - center.x)
|
|
186
|
+
|
|
187
|
+
// Wedge radius tracks how far the group spreads from the pivot — sample each
|
|
188
|
+
// participant's anchor point(s).
|
|
189
|
+
let spread = 0
|
|
190
|
+
const reach = (x: number, z: number) => {
|
|
191
|
+
spread = Math.max(spread, Math.hypot(x - localCenter.x, z - localCenter.z))
|
|
192
|
+
}
|
|
193
|
+
for (const s of starts) {
|
|
194
|
+
if (s.kind === 'endpoint') {
|
|
195
|
+
reach(s.start[0], s.start[1])
|
|
196
|
+
reach(s.end[0], s.end[1])
|
|
197
|
+
} else if (s.kind === 'polygon') {
|
|
198
|
+
for (const [x, z] of s.polygon) {
|
|
199
|
+
reach(x, z)
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
reach(s.position[0], s.position[2])
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const guideRadius = Math.min(Math.max(spread * 0.6, 0.3), 3)
|
|
206
|
+
|
|
207
|
+
const ndc = new Vector2()
|
|
208
|
+
const setNDC = (clientX: number, clientY: number) => {
|
|
209
|
+
const rect = gl.domElement.getBoundingClientRect()
|
|
210
|
+
ndc.set(
|
|
211
|
+
((clientX - rect.left) / rect.width) * 2 - 1,
|
|
212
|
+
-((clientY - rect.top) / rect.height) * 2 + 1,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
|
|
217
|
+
raycaster.setFromCamera(ndc, camera)
|
|
218
|
+
const hit = new Vector3()
|
|
219
|
+
if (!raycaster.ray.intersectPlane(plane, hit)) return
|
|
220
|
+
const initialAngle = angleOf(hit)
|
|
221
|
+
|
|
222
|
+
document.body.style.cursor = 'grabbing'
|
|
223
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
224
|
+
useViewer.getState().setInputDragging(true)
|
|
225
|
+
useScene.temporal.getState().pause()
|
|
226
|
+
useInteractionScope.getState().begin({
|
|
227
|
+
kind: 'handle-drag',
|
|
228
|
+
nodeId: ids[0] ?? '',
|
|
229
|
+
handle: GROUP_ROTATE_DRAG_LABEL,
|
|
230
|
+
})
|
|
231
|
+
setIsDragging(true)
|
|
232
|
+
|
|
233
|
+
const onMove = (e: PointerEvent) => {
|
|
234
|
+
setNDC(e.clientX, e.clientY)
|
|
235
|
+
raycaster.setFromCamera(ndc, camera)
|
|
236
|
+
const moveHit = new Vector3()
|
|
237
|
+
if (!raycaster.ray.intersectPlane(plane, moveHit)) return
|
|
238
|
+
let delta = angleOf(moveHit) - initialAngle
|
|
239
|
+
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
240
|
+
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
241
|
+
if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
|
242
|
+
|
|
243
|
+
// Shared rigid-rotation math (also used by the keyboard group R/T);
|
|
244
|
+
// see `rotateGroupPatches` for the orbit/yaw handedness contract.
|
|
245
|
+
const overrideEntries = rotateGroupPatches(
|
|
246
|
+
starts,
|
|
247
|
+
links,
|
|
248
|
+
{ x: localCenter.x, z: localCenter.z },
|
|
249
|
+
delta,
|
|
250
|
+
)
|
|
251
|
+
const patchById = new Map(overrideEntries)
|
|
252
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
253
|
+
for (const s of starts) {
|
|
254
|
+
if (s.kind === 'scalar') {
|
|
255
|
+
const patch = patchById.get(s.id)
|
|
256
|
+
if (patch) {
|
|
257
|
+
liveTransforms.set(s.id, {
|
|
258
|
+
position: patch.position as Vec3,
|
|
259
|
+
rotation: patch.rotation as number,
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
useScene.getState().markDirty(s.id)
|
|
264
|
+
}
|
|
265
|
+
for (const l of links) {
|
|
266
|
+
useScene.getState().markDirty(l.id)
|
|
267
|
+
}
|
|
268
|
+
useLiveNodeOverrides.getState().setMany(overrideEntries)
|
|
269
|
+
|
|
270
|
+
if (Math.abs(delta) < 0.0087) {
|
|
271
|
+
setGuide(null)
|
|
272
|
+
} else {
|
|
273
|
+
const midAngle = initialAngle + delta / 2
|
|
274
|
+
const labelRadius = guideRadius + 0.22
|
|
275
|
+
setGuide({
|
|
276
|
+
center: [center.x, center.y, center.z],
|
|
277
|
+
startAngle: initialAngle,
|
|
278
|
+
endAngle: initialAngle + delta,
|
|
279
|
+
radius: guideRadius,
|
|
280
|
+
labelPos: [
|
|
281
|
+
center.x + Math.cos(midAngle) * labelRadius,
|
|
282
|
+
center.y + 0.02,
|
|
283
|
+
center.z + Math.sin(midAngle) * labelRadius,
|
|
284
|
+
],
|
|
285
|
+
sweep: Math.abs(delta),
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
|
291
|
+
const clearLivePreviews = () => {
|
|
292
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
293
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
294
|
+
for (const id of affectedIds) {
|
|
295
|
+
overrides.clear(id)
|
|
296
|
+
liveTransforms.clear(id)
|
|
297
|
+
useScene.getState().markDirty(id)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const cleanup = () => {
|
|
302
|
+
window.removeEventListener('pointermove', onMove)
|
|
303
|
+
window.removeEventListener('pointerup', onUp)
|
|
304
|
+
window.removeEventListener('pointercancel', onCancel)
|
|
305
|
+
window.removeEventListener('keydown', onKeyDown, true)
|
|
306
|
+
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
|
307
|
+
useScene.temporal.getState().resume()
|
|
308
|
+
useViewer.getState().setInputDragging(false)
|
|
309
|
+
useInteractionScope
|
|
310
|
+
.getState()
|
|
311
|
+
.endIf((s) => s.kind === 'handle-drag' && s.handle === GROUP_ROTATE_DRAG_LABEL)
|
|
312
|
+
setIsDragging(false)
|
|
313
|
+
setGuide(null)
|
|
314
|
+
frozenRest.current = null
|
|
315
|
+
dragCleanupRef.current = null
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const commitFromOverrides = () => {
|
|
319
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
320
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
321
|
+
for (const id of affectedIds) {
|
|
322
|
+
const patch = overrides.get(id)
|
|
323
|
+
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
|
|
324
|
+
}
|
|
325
|
+
return updates
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const onUp = () => {
|
|
329
|
+
// Eat the click that follows pointer-up so the selection manager doesn't
|
|
330
|
+
// treat it as a canvas click and clear the multi-selection.
|
|
331
|
+
swallowNextClick()
|
|
332
|
+
sfxEmitter.emit('sfx:item-place')
|
|
333
|
+
const updates = commitFromOverrides()
|
|
334
|
+
// Resume before the commit so the single batched `updateNodes` is the
|
|
335
|
+
// one tracked set — collapsing the whole group rotation into one undo.
|
|
336
|
+
// Space detection stays out: a rigid rotation of existing walls must
|
|
337
|
+
// not re-create the room's auto floors/ceilings at the new bearing.
|
|
338
|
+
pauseSpaceDetection()
|
|
339
|
+
useScene.temporal.getState().resume()
|
|
340
|
+
if (updates.length > 0) useScene.getState().updateNodes(updates)
|
|
341
|
+
resumeSpaceDetection()
|
|
342
|
+
clearLivePreviews()
|
|
343
|
+
cleanup()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const onCancel = () => {
|
|
347
|
+
// Revert: drop overrides + mark dirty so renderers rebuild from the store.
|
|
348
|
+
clearLivePreviews()
|
|
349
|
+
cleanup()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Escape / ⌘Z abort the rotate — capture phase so they win over the global
|
|
353
|
+
// use-keyboard arms (⌘Z must never history-jump under a live pointer).
|
|
354
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
355
|
+
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
|
356
|
+
e.preventDefault()
|
|
357
|
+
e.stopPropagation()
|
|
358
|
+
swallowNextClick()
|
|
359
|
+
onCancel()
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
dragCleanupRef.current = () => {
|
|
363
|
+
clearLivePreviews()
|
|
364
|
+
cleanup()
|
|
365
|
+
}
|
|
366
|
+
for (const id of affectedIds) {
|
|
367
|
+
useLiveTransforms.getState().clear(id)
|
|
368
|
+
}
|
|
369
|
+
window.addEventListener('pointermove', onMove)
|
|
370
|
+
window.addEventListener('pointerup', onUp)
|
|
371
|
+
window.addEventListener('pointercancel', onCancel)
|
|
372
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return createPortal(
|
|
376
|
+
<>
|
|
377
|
+
{(isHovered || isDragging) && (
|
|
378
|
+
<group position={[corner.x, corner.y, corner.z]}>
|
|
379
|
+
<GuideRing radius={0.2 * scale} y={0} />
|
|
380
|
+
</group>
|
|
381
|
+
)}
|
|
382
|
+
<group position={[corner.x, corner.y, corner.z]}>
|
|
383
|
+
{/* Fat invisible grab target (torus wrapping the arrow). A plain
|
|
384
|
+
default-layer mesh with the standard raycast — the shared
|
|
385
|
+
`InvisibleHandleHitArea` (EDITOR_LAYER + custom raycast) never
|
|
386
|
+
received pointer events in this portalled context: R3F's
|
|
387
|
+
`createPortal` gives the portal root its own fresh `Raycaster`
|
|
388
|
+
(default mask = layer 0 only), so the EDITOR_LAYER enable applied
|
|
389
|
+
to the root raycaster in `custom-camera-controls` never reaches
|
|
390
|
+
it. Harmless render-wise — the material is colorWrite:false /
|
|
391
|
+
depthWrite:false, so nothing leaks into thumbnails or the ink
|
|
392
|
+
pass. A proper fix would enable EDITOR_LAYER on the portal
|
|
393
|
+
raycaster from inside the portal. */}
|
|
394
|
+
<mesh
|
|
395
|
+
frustumCulled={false}
|
|
396
|
+
geometry={hitGeometry}
|
|
397
|
+
material={hitMaterial}
|
|
398
|
+
onPointerDown={activate}
|
|
399
|
+
onPointerEnter={onHoverEnter}
|
|
400
|
+
onPointerLeave={onHoverLeave}
|
|
401
|
+
scale={baseScale}
|
|
402
|
+
/>
|
|
403
|
+
<mesh
|
|
404
|
+
frustumCulled={false}
|
|
405
|
+
geometry={arrowGeometry}
|
|
406
|
+
material={arrowMaterial}
|
|
407
|
+
onPointerDown={activate}
|
|
408
|
+
onPointerEnter={onHoverEnter}
|
|
409
|
+
onPointerLeave={onHoverLeave}
|
|
410
|
+
renderOrder={1010}
|
|
411
|
+
scale={scale}
|
|
412
|
+
/>
|
|
413
|
+
</group>
|
|
414
|
+
{guide ? <RotationGuide data={guide} /> : null}
|
|
415
|
+
</>,
|
|
416
|
+
scene,
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export default GroupRotateHandle
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { type ThreeEvent, useThree } from '@react-three/fiber'
|
|
6
|
+
import { useEffect, useMemo } from 'react'
|
|
7
|
+
import * as THREE from 'three'
|
|
8
|
+
import useEditor from '../../store/use-editor'
|
|
9
|
+
import { useMovingNode } from '../../store/use-interaction-scope'
|
|
10
|
+
import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
|
|
11
|
+
import { armGroupMove3d } from './group-move-3d'
|
|
12
|
+
import {
|
|
13
|
+
classifyParticipant,
|
|
14
|
+
computeGroupBox,
|
|
15
|
+
expandToComponent,
|
|
16
|
+
levelFrame,
|
|
17
|
+
} from './group-transform-shared'
|
|
18
|
+
import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
|
|
19
|
+
|
|
20
|
+
// Matches the 2D dashed selection box's stroke.
|
|
21
|
+
const BOX_COLOR = '#3b82f6'
|
|
22
|
+
// Small clearance so the dashes don't z-fight the selection's outer faces.
|
|
23
|
+
const BOX_PAD = 0.06
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 3D sibling of the 2D dashed group selection box: a dashed wireframe around
|
|
27
|
+
* the multi-selection's transformable participants (expanded to the welded
|
|
28
|
+
* wall/fence component) that doubles as the group's whole-volume drag
|
|
29
|
+
* handle — move cursor across it, press-drag anywhere on it slides the group,
|
|
30
|
+
* a plain click picks it up. Holding a selection modifier passes the press
|
|
31
|
+
* through so members inside can still be toggled. Rides the live drag delta
|
|
32
|
+
* so it tracks the group mid-gesture.
|
|
33
|
+
*/
|
|
34
|
+
export function GroupSelectionBox3D() {
|
|
35
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
36
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
37
|
+
const nodes = useScene((s) => s.nodes)
|
|
38
|
+
const mode = useEditor((s) => s.mode)
|
|
39
|
+
const movingNode = useMovingNode()
|
|
40
|
+
const delta = useFloorplanGroupDrag((s) => s.delta)
|
|
41
|
+
const { camera, raycaster, gl } = useThree()
|
|
42
|
+
|
|
43
|
+
const participantIds = useMemo(
|
|
44
|
+
() =>
|
|
45
|
+
selectedIds.length > 1
|
|
46
|
+
? selectedIds.filter(
|
|
47
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
48
|
+
)
|
|
49
|
+
: [],
|
|
50
|
+
[selectedIds, levelId, nodes],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// Re-measure once the meshes settle after a scene change (undo included).
|
|
54
|
+
const meshEpoch = useMeshSettleEpoch(nodes)
|
|
55
|
+
const box = useMemo(() => {
|
|
56
|
+
if (participantIds.length === 0) return null
|
|
57
|
+
const fullIds = expandToComponent(participantIds, nodes, levelId)
|
|
58
|
+
const world = computeGroupBox(fullIds)
|
|
59
|
+
if (!world) return null
|
|
60
|
+
const size = new THREE.Vector3()
|
|
61
|
+
world.getSize(size)
|
|
62
|
+
const center = new THREE.Vector3()
|
|
63
|
+
world.getCenter(center)
|
|
64
|
+
return {
|
|
65
|
+
size: [size.x + 2 * BOX_PAD, size.y + 2 * BOX_PAD, size.z + 2 * BOX_PAD] as [
|
|
66
|
+
number,
|
|
67
|
+
number,
|
|
68
|
+
number,
|
|
69
|
+
],
|
|
70
|
+
center,
|
|
71
|
+
}
|
|
72
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
|
|
73
|
+
}, [participantIds, nodes, levelId, meshEpoch])
|
|
74
|
+
|
|
75
|
+
// Dashed wireframe. Built per box size (rare — selection / commit changes)
|
|
76
|
+
// because LineDashedMaterial measures dashes along the line, so scaling a
|
|
77
|
+
// unit box would stretch them per axis.
|
|
78
|
+
const edges = useMemo(() => {
|
|
79
|
+
if (!box) return null
|
|
80
|
+
const geometry = new THREE.EdgesGeometry(new THREE.BoxGeometry(...box.size))
|
|
81
|
+
const line = new THREE.LineSegments(
|
|
82
|
+
geometry,
|
|
83
|
+
new THREE.LineDashedMaterial({
|
|
84
|
+
color: BOX_COLOR,
|
|
85
|
+
dashSize: 0.18,
|
|
86
|
+
gapSize: 0.12,
|
|
87
|
+
opacity: 0.9,
|
|
88
|
+
transparent: true,
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
line.computeLineDistances()
|
|
92
|
+
return line
|
|
93
|
+
}, [box])
|
|
94
|
+
useEffect(
|
|
95
|
+
() => () => {
|
|
96
|
+
if (edges) {
|
|
97
|
+
edges.geometry.dispose()
|
|
98
|
+
;(edges.material as THREE.Material).dispose()
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
[edges],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
// Mid-drag the sessions publish a level-frame delta; map it to world so the
|
|
105
|
+
// box rides the group (shared with the 2D box's store).
|
|
106
|
+
const worldOffset = useMemo(() => {
|
|
107
|
+
if (!delta) return null
|
|
108
|
+
const { matrix } = levelFrame(levelId)
|
|
109
|
+
const origin = new THREE.Vector3().applyMatrix4(matrix)
|
|
110
|
+
return new THREE.Vector3(delta[0], 0, delta[1]).applyMatrix4(matrix).sub(origin)
|
|
111
|
+
}, [delta, levelId])
|
|
112
|
+
|
|
113
|
+
if (!box || !edges || movingNode || mode !== 'select') return null
|
|
114
|
+
|
|
115
|
+
const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
|
|
116
|
+
const native = event.nativeEvent
|
|
117
|
+
if (native.button !== 0) return
|
|
118
|
+
// A selection modifier passes through so members inside the box can
|
|
119
|
+
// still be toggled in and out of the selection.
|
|
120
|
+
if (native.metaKey || native.ctrlKey || native.shiftKey || native.altKey) return
|
|
121
|
+
const { selectedIds: currentIds, levelId: currentLevelId } = useViewer.getState().selection
|
|
122
|
+
const sceneNodes = useScene.getState().nodes
|
|
123
|
+
const anchor = currentIds.find(
|
|
124
|
+
(id) => classifyParticipant(sceneNodes[id as AnyNodeId], currentLevelId, sceneNodes) !== null,
|
|
125
|
+
)
|
|
126
|
+
if (!anchor) return
|
|
127
|
+
const armed = armGroupMove3d({
|
|
128
|
+
nodeId: anchor as AnyNodeId,
|
|
129
|
+
clientX: native.clientX,
|
|
130
|
+
clientY: native.clientY,
|
|
131
|
+
pointerId: native.pointerId,
|
|
132
|
+
nativeEvent: native,
|
|
133
|
+
camera,
|
|
134
|
+
raycaster,
|
|
135
|
+
domElement: gl.domElement,
|
|
136
|
+
})
|
|
137
|
+
// Own the press: deeper hits (members, ground) must not also select.
|
|
138
|
+
if (armed) event.stopPropagation()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Set on move (not just enter): the canvas may carry an app default
|
|
142
|
+
// cursor, and member hovers inside the box write theirs — keep 'move'
|
|
143
|
+
// asserted across the whole volume, except while a drag shows 'grabbing'.
|
|
144
|
+
const applyMoveCursor = () => {
|
|
145
|
+
const cursor = gl.domElement.style.cursor
|
|
146
|
+
if (cursor !== 'move' && cursor !== 'grabbing') gl.domElement.style.cursor = 'move'
|
|
147
|
+
}
|
|
148
|
+
const handlePointerLeave = () => {
|
|
149
|
+
if (gl.domElement.style.cursor === 'move') gl.domElement.style.cursor = ''
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const position: [number, number, number] = [
|
|
153
|
+
box.center.x + (worldOffset?.x ?? 0),
|
|
154
|
+
box.center.y + (worldOffset?.y ?? 0),
|
|
155
|
+
box.center.z + (worldOffset?.z ?? 0),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<group position={position}>
|
|
160
|
+
<primitive object={edges} />
|
|
161
|
+
{/* Invisible whole-volume hit target — the box IS the drag handle. */}
|
|
162
|
+
<mesh
|
|
163
|
+
onPointerDown={handlePointerDown}
|
|
164
|
+
onPointerEnter={applyMoveCursor}
|
|
165
|
+
onPointerLeave={handlePointerLeave}
|
|
166
|
+
onPointerMove={applyMoveCursor}
|
|
167
|
+
>
|
|
168
|
+
<boxGeometry args={box.size} />
|
|
169
|
+
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
|
|
170
|
+
</mesh>
|
|
171
|
+
</group>
|
|
172
|
+
)
|
|
173
|
+
}
|