@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,212 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
6
|
+
import {
|
|
7
|
+
createQuickMeasurementPointerScheduler,
|
|
8
|
+
resolveQuickMeasurementReport,
|
|
9
|
+
} from '../../lib/quick-measurement'
|
|
10
|
+
import {
|
|
11
|
+
activateQuickMeasurementHudSource,
|
|
12
|
+
clearQuickMeasurementHudSource,
|
|
13
|
+
publishQuickMeasurementHudSource,
|
|
14
|
+
} from '../../store/use-quick-measurement-hud'
|
|
15
|
+
import { useFloorplanRender } from './floorplan-render-context'
|
|
16
|
+
|
|
17
|
+
type FloorplanQuickMeasureHit = {
|
|
18
|
+
nodeId: string
|
|
19
|
+
point: { x: number; y: number }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nodeIdFromElement(element: Element | null): string | null {
|
|
23
|
+
const entry = element?.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')
|
|
24
|
+
return entry?.dataset.nodeId ?? null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function nodeIdAtPointer(svg: SVGSVGElement, event: PointerEvent): string | null {
|
|
28
|
+
const direct = event.target instanceof Element ? nodeIdFromElement(event.target) : null
|
|
29
|
+
if (direct) return direct
|
|
30
|
+
for (const element of document.elementsFromPoint(event.clientX, event.clientY)) {
|
|
31
|
+
if (!svg.contains(element)) continue
|
|
32
|
+
const nodeId = nodeIdFromElement(element)
|
|
33
|
+
if (nodeId) return nodeId
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pointAtPointer(
|
|
39
|
+
group: SVGGElement,
|
|
40
|
+
event: PointerEvent,
|
|
41
|
+
): FloorplanQuickMeasureHit['point'] | null {
|
|
42
|
+
const matrix = group.getScreenCTM()
|
|
43
|
+
if (!matrix) return null
|
|
44
|
+
const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse())
|
|
45
|
+
return { x: point.x, y: point.y }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function FloorplanSmartMarker({
|
|
49
|
+
hit,
|
|
50
|
+
pinned,
|
|
51
|
+
unitsPerPixel,
|
|
52
|
+
markerRef,
|
|
53
|
+
}: {
|
|
54
|
+
hit?: FloorplanQuickMeasureHit
|
|
55
|
+
pinned: boolean
|
|
56
|
+
unitsPerPixel: number
|
|
57
|
+
markerRef?: RefObject<SVGGElement | null>
|
|
58
|
+
}) {
|
|
59
|
+
const localRef = useRef<SVGGElement>(null)
|
|
60
|
+
const ref = markerRef ?? localRef
|
|
61
|
+
|
|
62
|
+
useLayoutEffect(() => {
|
|
63
|
+
if (!hit) ref.current?.setAttribute('display', 'none')
|
|
64
|
+
}, [hit, ref])
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<g ref={ref} transform={hit ? `translate(${hit.point.x} ${hit.point.y})` : undefined}>
|
|
68
|
+
<circle
|
|
69
|
+
cx={0}
|
|
70
|
+
cy={0}
|
|
71
|
+
fill="none"
|
|
72
|
+
r={(pinned ? 11 : 8) * unitsPerPixel}
|
|
73
|
+
stroke={pinned ? '#0e7490' : '#0891b2'}
|
|
74
|
+
strokeWidth={pinned ? 2.5 : 2}
|
|
75
|
+
vectorEffect="non-scaling-stroke"
|
|
76
|
+
/>
|
|
77
|
+
{pinned ? <circle cx={0} cy={0} fill="#0e7490" r={2.75 * unitsPerPixel} /> : null}
|
|
78
|
+
</g>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function showFloorplanSmartMarker(
|
|
83
|
+
marker: SVGGElement | null,
|
|
84
|
+
point: FloorplanQuickMeasureHit['point'],
|
|
85
|
+
) {
|
|
86
|
+
if (!marker) return
|
|
87
|
+
marker.setAttribute('transform', `translate(${point.x} ${point.y})`)
|
|
88
|
+
marker.removeAttribute('display')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hideFloorplanSmartMarker(marker: SVGGElement | null) {
|
|
92
|
+
marker?.setAttribute('display', 'none')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function FloorplanQuickMeasureLayer() {
|
|
96
|
+
const groupRef = useRef<SVGGElement>(null)
|
|
97
|
+
const hoverRef = useRef<FloorplanQuickMeasureHit | null>(null)
|
|
98
|
+
const hoverNodeIdRef = useRef<string | null>(null)
|
|
99
|
+
const candidateNodeIdRef = useRef<string | null | undefined>(undefined)
|
|
100
|
+
const candidateHasReportRef = useRef(false)
|
|
101
|
+
const hoverMarkerRef = useRef<SVGGElement>(null)
|
|
102
|
+
const [hoverNodeId, setHoverNodeId] = useState<string | null>(null)
|
|
103
|
+
const [pinned, setPinned] = useState<FloorplanQuickMeasureHit | null>(null)
|
|
104
|
+
const nodes = useScene((state) => state.nodes)
|
|
105
|
+
const candidateNodesRef = useRef(nodes)
|
|
106
|
+
const levelId = useViewer((state) => state.selection.levelId)
|
|
107
|
+
const levelRef = useRef(levelId)
|
|
108
|
+
const renderContext = useFloorplanRender()
|
|
109
|
+
const hoverReport = useMemo(
|
|
110
|
+
() => resolveQuickMeasurementReport(hoverNodeId, nodes),
|
|
111
|
+
[hoverNodeId, nodes],
|
|
112
|
+
)
|
|
113
|
+
const pinnedReport = useMemo(
|
|
114
|
+
() => resolveQuickMeasurementReport(pinned?.nodeId ?? null, nodes),
|
|
115
|
+
[pinned?.nodeId, nodes],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (levelRef.current === levelId) return
|
|
120
|
+
levelRef.current = levelId
|
|
121
|
+
hoverRef.current = null
|
|
122
|
+
hoverNodeIdRef.current = null
|
|
123
|
+
candidateNodeIdRef.current = undefined
|
|
124
|
+
candidateHasReportRef.current = false
|
|
125
|
+
hideFloorplanSmartMarker(hoverMarkerRef.current)
|
|
126
|
+
setHoverNodeId(null)
|
|
127
|
+
setPinned(null)
|
|
128
|
+
}, [levelId])
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const group = groupRef.current
|
|
132
|
+
const svg = groupRef.current?.ownerSVGElement
|
|
133
|
+
if (!(group && svg)) return
|
|
134
|
+
const updateHover = (next: FloorplanQuickMeasureHit | null) => {
|
|
135
|
+
hoverRef.current = next
|
|
136
|
+
if (next) showFloorplanSmartMarker(hoverMarkerRef.current, next.point)
|
|
137
|
+
else hideFloorplanSmartMarker(hoverMarkerRef.current)
|
|
138
|
+
const nextNodeId = next?.nodeId ?? null
|
|
139
|
+
if (nextNodeId === hoverNodeIdRef.current) return
|
|
140
|
+
hoverNodeIdRef.current = nextNodeId
|
|
141
|
+
setHoverNodeId(nextNodeId)
|
|
142
|
+
}
|
|
143
|
+
const processPointerMove = (event: PointerEvent) => {
|
|
144
|
+
activateQuickMeasurementHudSource('2d')
|
|
145
|
+
const candidateNodeId = nodeIdAtPointer(svg, event)
|
|
146
|
+
const sceneNodes = useScene.getState().nodes
|
|
147
|
+
if (candidateNodesRef.current !== sceneNodes) {
|
|
148
|
+
candidateNodesRef.current = sceneNodes
|
|
149
|
+
candidateNodeIdRef.current = undefined
|
|
150
|
+
}
|
|
151
|
+
if (candidateNodeId !== candidateNodeIdRef.current) {
|
|
152
|
+
candidateNodeIdRef.current = candidateNodeId
|
|
153
|
+
candidateHasReportRef.current = Boolean(
|
|
154
|
+
resolveQuickMeasurementReport(candidateNodeId, sceneNodes),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
const point = candidateHasReportRef.current ? pointAtPointer(group, event) : null
|
|
158
|
+
updateHover(candidateNodeId && point ? { nodeId: candidateNodeId, point } : null)
|
|
159
|
+
}
|
|
160
|
+
const pointerScheduler = createQuickMeasurementPointerScheduler(processPointerMove)
|
|
161
|
+
const onPointerMove = (event: PointerEvent) => pointerScheduler.enqueue(event)
|
|
162
|
+
const clear = () => {
|
|
163
|
+
pointerScheduler.clear()
|
|
164
|
+
updateHover(null)
|
|
165
|
+
}
|
|
166
|
+
const onClick = (event: MouseEvent) => {
|
|
167
|
+
const next = hoverRef.current
|
|
168
|
+
if (!(next && event.button === 0)) return
|
|
169
|
+
event.preventDefault()
|
|
170
|
+
event.stopImmediatePropagation()
|
|
171
|
+
activateQuickMeasurementHudSource('2d')
|
|
172
|
+
setPinned(next)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
svg.addEventListener('pointermove', onPointerMove, true)
|
|
176
|
+
svg.addEventListener('pointerleave', clear)
|
|
177
|
+
svg.addEventListener('click', onClick, true)
|
|
178
|
+
return () => {
|
|
179
|
+
svg.removeEventListener('pointermove', onPointerMove, true)
|
|
180
|
+
svg.removeEventListener('pointerleave', clear)
|
|
181
|
+
svg.removeEventListener('click', onClick, true)
|
|
182
|
+
pointerScheduler.clear()
|
|
183
|
+
}
|
|
184
|
+
}, [])
|
|
185
|
+
|
|
186
|
+
const unitsPerPixel = Math.max(renderContext?.unitsPerPixel ?? 0.01, 1e-6)
|
|
187
|
+
const activeHit = hoverReport ? hoverRef.current : pinnedReport ? pinned : null
|
|
188
|
+
const report = hoverReport ?? pinnedReport
|
|
189
|
+
const lensState =
|
|
190
|
+
pinnedReport && activeHit?.nodeId === pinned?.nodeId ? ('pinned' as const) : ('live' as const)
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
publishQuickMeasurementHudSource('2d', report ? { lensState, report } : null)
|
|
194
|
+
}, [lensState, report])
|
|
195
|
+
|
|
196
|
+
useEffect(() => () => clearQuickMeasurementHudSource('2d'), [])
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<g pointerEvents="none" ref={groupRef}>
|
|
200
|
+
{pinned && pinnedReport ? (
|
|
201
|
+
<FloorplanSmartMarker hit={pinned} pinned unitsPerPixel={unitsPerPixel} />
|
|
202
|
+
) : null}
|
|
203
|
+
<FloorplanSmartMarker
|
|
204
|
+
markerRef={hoverMarkerRef}
|
|
205
|
+
pinned={false}
|
|
206
|
+
unitsPerPixel={unitsPerPixel}
|
|
207
|
+
/>
|
|
208
|
+
</g>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export default FloorplanQuickMeasureLayer
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
type CeilingNode,
|
|
7
|
+
createSceneApi,
|
|
8
|
+
getWallMidpointHandlePoint,
|
|
9
|
+
type NodeQuickAction,
|
|
10
|
+
nodeRegistry,
|
|
11
|
+
runAsSingleSceneHistoryStep,
|
|
12
|
+
type SlabNode,
|
|
13
|
+
useLiveNodeOverrides,
|
|
14
|
+
useScene,
|
|
15
|
+
type WallNode,
|
|
16
|
+
} from '@pascal-app/core'
|
|
17
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
18
|
+
import { useEffect, useState } from 'react'
|
|
19
|
+
import { createPortal } from 'react-dom'
|
|
20
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
21
|
+
import {
|
|
22
|
+
createFreshPlacementSubtree,
|
|
23
|
+
duplicatesAsFreshSubtree,
|
|
24
|
+
} from '../../lib/fresh-planar-placement'
|
|
25
|
+
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
26
|
+
import useEditor from '../../store/use-editor'
|
|
27
|
+
import { useMovingNode } from '../../store/use-interaction-scope'
|
|
28
|
+
import { NodeActionMenu } from '../editor/node-action-menu'
|
|
29
|
+
import { IconRefGlyph } from '../ui/icon-ref'
|
|
30
|
+
|
|
31
|
+
function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) {
|
|
32
|
+
return (
|
|
33
|
+
<svg
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
className="h-3.5 w-3.5"
|
|
36
|
+
fill="none"
|
|
37
|
+
stroke="currentColor"
|
|
38
|
+
strokeLinecap="round"
|
|
39
|
+
strokeLinejoin="round"
|
|
40
|
+
strokeWidth="1.7"
|
|
41
|
+
viewBox="0 0 24 24"
|
|
42
|
+
>
|
|
43
|
+
<rect x="8" y="7" width="9" height="10" rx="1.75" />
|
|
44
|
+
<path d="M12.5 7v10" />
|
|
45
|
+
{direction === 'left' ? (
|
|
46
|
+
<>
|
|
47
|
+
<path d="M6.5 12H2.75" />
|
|
48
|
+
<path d="m5.5 9.75-2.75 2.25 2.75 2.25" />
|
|
49
|
+
</>
|
|
50
|
+
) : (
|
|
51
|
+
<>
|
|
52
|
+
<path d="M17.5 12h3.75" />
|
|
53
|
+
<path d="m18.5 9.75 2.75 2.25-2.75 2.25" />
|
|
54
|
+
</>
|
|
55
|
+
)}
|
|
56
|
+
</svg>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Builtin string tokens map to the generic glyphs above; kind-owned marks
|
|
61
|
+
// arrive as IconRef objects and render through the shared IconRefGlyph.
|
|
62
|
+
// Mirrors the 3D floating-action-menu (2D ↔ 3D parity).
|
|
63
|
+
function QuickActionIcon({ action }: { action: NodeQuickAction }) {
|
|
64
|
+
const icon = action.icon
|
|
65
|
+
if (!icon) return null
|
|
66
|
+
if (typeof icon === 'object') return <IconRefGlyph icon={icon} size={14} />
|
|
67
|
+
switch (icon) {
|
|
68
|
+
case 'add-left':
|
|
69
|
+
return <SideAddGlyph direction="left" />
|
|
70
|
+
case 'add-right':
|
|
71
|
+
return <SideAddGlyph direction="right" />
|
|
72
|
+
default:
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function collectQuickActionNodes(
|
|
78
|
+
nodes: Record<AnyNodeId, AnyNode>,
|
|
79
|
+
selectedId: string | null,
|
|
80
|
+
): Record<AnyNodeId, AnyNode> | null {
|
|
81
|
+
if (!selectedId) return null
|
|
82
|
+
const selected = nodes[selectedId as AnyNodeId]
|
|
83
|
+
if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null
|
|
84
|
+
|
|
85
|
+
const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
|
|
86
|
+
const add = (id: string | null | undefined) => {
|
|
87
|
+
if (!id) return
|
|
88
|
+
const node = nodes[id as AnyNodeId]
|
|
89
|
+
if (node) collected[node.id as AnyNodeId] = node
|
|
90
|
+
}
|
|
91
|
+
const addChildren = (node: AnyNode | undefined) => {
|
|
92
|
+
for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
|
|
93
|
+
add(childId)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
add(selected.parentId ?? null)
|
|
98
|
+
addChildren(selected)
|
|
99
|
+
const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
|
|
100
|
+
addChildren(parent)
|
|
101
|
+
|
|
102
|
+
return collected
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Floating Move / Duplicate / Delete buttons that appear above the
|
|
107
|
+
* selected registered kind in the floor plan view.
|
|
108
|
+
*
|
|
109
|
+
* Lives outside the floorplan-panel.tsx monolith. Reads selection from
|
|
110
|
+
* `useViewer`, finds the rendered `[data-node-id]` <g> inside the floor
|
|
111
|
+
* plan scene, polls its bounding rect via rAF while open, and portals
|
|
112
|
+
* an HTML overlay positioned at the top of the bounding box.
|
|
113
|
+
*
|
|
114
|
+
* Buttons:
|
|
115
|
+
* - Move: sets `movingNode` in useEditor. Enabled when the kind has
|
|
116
|
+
* `capabilities.movable`, `def.floorplanMoveTarget`, OR
|
|
117
|
+
* `def.affordanceTools.move` (slab / ceiling). The
|
|
118
|
+
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
|
|
119
|
+
* Walls are excluded — their move is reached via the side-arrow
|
|
120
|
+
* handles emitted from `def.floorplan`, not via a menu button.
|
|
121
|
+
* - Add hole (slab + ceiling only): inserts a small default-square
|
|
122
|
+
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
|
|
123
|
+
* `handleAddHole` in `floating-action-menu.tsx`.
|
|
124
|
+
* - Duplicate: deep-clones the node, marks it new, sets it as the
|
|
125
|
+
* movingNode (placement cursor) — same UX pattern as 3D duplicate.
|
|
126
|
+
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
|
|
127
|
+
* `relations.cascadeDelete` if declared on the def.
|
|
128
|
+
*
|
|
129
|
+
* Hidden while in a move state (so we don't show buttons over a ghost).
|
|
130
|
+
*/
|
|
131
|
+
export function FloorplanRegistryActionMenu() {
|
|
132
|
+
// Sole selection only — a multi-selection gets the group menu
|
|
133
|
+
// (`FloorplanGroupActionMenu`), whose actions target the whole selection.
|
|
134
|
+
const selectedId = useViewer((s) =>
|
|
135
|
+
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
|
|
136
|
+
) as AnyNodeId | undefined
|
|
137
|
+
const movingNode = useMovingNode()
|
|
138
|
+
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
139
|
+
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
|
140
|
+
// Gate on floorplan hover so this 2D menu never coexists with the 3D
|
|
141
|
+
// FloatingActionMenu in split view — that menu hides while the floorplan
|
|
142
|
+
// is hovered, so this one must only show then. Mirrors the legacy
|
|
143
|
+
// FloorplanActionMenuLayer guard. Without it a registry kind (e.g. a
|
|
144
|
+
// duct) shows two Duplicate buttons whenever the pointer is outside the
|
|
145
|
+
// 2D panel.
|
|
146
|
+
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
147
|
+
|
|
148
|
+
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
|
149
|
+
|
|
150
|
+
// Only show for registered kinds (skip legacy kinds — they have their
|
|
151
|
+
// own FloorplanActionMenuLayer entries).
|
|
152
|
+
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
|
|
153
|
+
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
|
|
154
|
+
const isRegistryKind = !!def
|
|
155
|
+
const isVisible =
|
|
156
|
+
isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered
|
|
157
|
+
const isWall = selectedKind === 'wall'
|
|
158
|
+
const quickActionNodes = useScene(
|
|
159
|
+
useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!(isVisible && selectedId)) {
|
|
164
|
+
setPosition(null)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
let raf = 0
|
|
168
|
+
const tick = () => {
|
|
169
|
+
raf = requestAnimationFrame(tick)
|
|
170
|
+
const sceneEl = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
|
|
171
|
+
const svgEl = sceneEl?.ownerSVGElement ?? null
|
|
172
|
+
const ctm = sceneEl?.getScreenCTM() ?? null
|
|
173
|
+
if (!(sceneEl && svgEl && ctm)) {
|
|
174
|
+
setPosition(null)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Walls: anchor at the wall midpoint in screen space so the menu
|
|
179
|
+
// sits over the centre of the wall (not the top of its screen-axis
|
|
180
|
+
// bounding box). Menu itself stays horizontal. Read live overrides
|
|
181
|
+
// too so the anchor tracks the wall during side-arrow / endpoint
|
|
182
|
+
// drags. For curved walls `getWallMidpointHandlePoint` returns the
|
|
183
|
+
// apex point on the arc at t=0.5, matching what the renderer draws.
|
|
184
|
+
if (isWall) {
|
|
185
|
+
const sceneNode = useScene.getState().nodes[selectedId] as WallNode | undefined
|
|
186
|
+
if (!sceneNode) {
|
|
187
|
+
setPosition(null)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
const overrides = useLiveNodeOverrides.getState().get(selectedId) as
|
|
191
|
+
| Partial<WallNode>
|
|
192
|
+
| undefined
|
|
193
|
+
const wall = (overrides ? { ...sceneNode, ...overrides } : sceneNode) as WallNode
|
|
194
|
+
const planMid = getWallMidpointHandlePoint(wall)
|
|
195
|
+
const midPt = svgEl.createSVGPoint()
|
|
196
|
+
midPt.x = planMid.x
|
|
197
|
+
midPt.y = planMid.y
|
|
198
|
+
const midScreen = midPt.matrixTransform(ctm)
|
|
199
|
+
setPosition({ left: midScreen.x, top: midScreen.y })
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const el = sceneEl.querySelector(`[data-node-id="${selectedId}"]`) as SVGGElement | null
|
|
204
|
+
if (el) {
|
|
205
|
+
const rect = el.getBoundingClientRect()
|
|
206
|
+
setPosition({ left: rect.left + rect.width / 2, top: rect.top })
|
|
207
|
+
} else {
|
|
208
|
+
setPosition(null)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
raf = requestAnimationFrame(tick)
|
|
212
|
+
return () => cancelAnimationFrame(raf)
|
|
213
|
+
}, [isVisible, selectedId, isWall])
|
|
214
|
+
|
|
215
|
+
if (!(isVisible && selectedId && position && def)) return null
|
|
216
|
+
|
|
217
|
+
const node = useScene.getState().nodes[selectedId]
|
|
218
|
+
if (!node) return null
|
|
219
|
+
|
|
220
|
+
const quickActions =
|
|
221
|
+
quickActionNodes && nodeRegistry.get(node.type)?.quickActions
|
|
222
|
+
? (nodeRegistry.get(node.type)?.quickActions?.({
|
|
223
|
+
node: node as never,
|
|
224
|
+
nodes: quickActionNodes,
|
|
225
|
+
}) ?? [])
|
|
226
|
+
: []
|
|
227
|
+
|
|
228
|
+
// Move button is enabled when any of:
|
|
229
|
+
// - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
|
|
230
|
+
// - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item)
|
|
231
|
+
// - `def.affordanceTools.move` (kind-owned 3D mover — slab / ceiling / wall)
|
|
232
|
+
// From the menu's perspective all three are "this kind can move from
|
|
233
|
+
// the floor plan." The `MoveTool` dispatcher resolves the right path —
|
|
234
|
+
// walls land on their bespoke `MoveWallTool` (perpendicular slide
|
|
235
|
+
// with linked-wall cascade) via `affordanceTools.move`.
|
|
236
|
+
const canMove =
|
|
237
|
+
!!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move
|
|
238
|
+
const canDuplicate = def.capabilities.duplicable !== false
|
|
239
|
+
const canDelete = def.capabilities.deletable !== false
|
|
240
|
+
const canAddHole = node.type === 'slab' || node.type === 'ceiling'
|
|
241
|
+
|
|
242
|
+
const handleMove = () => {
|
|
243
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
244
|
+
setMovingNode(node as never)
|
|
245
|
+
// 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
|
|
246
|
+
// Mark the origin (after `setMovingNode`, which resets it to null) so
|
|
247
|
+
// `ToolManager` keeps the 3D affordance mover from also adopting the node
|
|
248
|
+
// and reverting it on unmount. Mirrors the orange move-dot path.
|
|
249
|
+
setMovingNodeOrigin('2d')
|
|
250
|
+
// Match the legacy 3D `floating-action-menu`: clear selection so
|
|
251
|
+
// selection-gated affordances unmount during the drag. Specifically
|
|
252
|
+
// the slab / ceiling boundary editor (`ToolManager` shows it when
|
|
253
|
+
// `selectedSlabId !== undefined`) would otherwise stay visible
|
|
254
|
+
// and render its vertex / edge handles on top of the moving mesh
|
|
255
|
+
// in split-view 3D. The move overlay reads `movingNode`, not the
|
|
256
|
+
// selection, so clearing it doesn't disturb the move itself; the
|
|
257
|
+
// commit path re-selects the node when it ends.
|
|
258
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const handleAddHole = () => {
|
|
262
|
+
if (!canAddHole) return
|
|
263
|
+
const surfaceNode = node as SlabNode | CeilingNode
|
|
264
|
+
const polygon = surfaceNode.polygon
|
|
265
|
+
if (!polygon || polygon.length < 3) return
|
|
266
|
+
|
|
267
|
+
let cx = 0
|
|
268
|
+
let cz = 0
|
|
269
|
+
for (const [x, z] of polygon) {
|
|
270
|
+
cx += x
|
|
271
|
+
cz += z
|
|
272
|
+
}
|
|
273
|
+
cx /= polygon.length
|
|
274
|
+
cz /= polygon.length
|
|
275
|
+
|
|
276
|
+
const holeSize = 0.5
|
|
277
|
+
const newHole: Array<[number, number]> = [
|
|
278
|
+
[cx - holeSize, cz - holeSize],
|
|
279
|
+
[cx + holeSize, cz - holeSize],
|
|
280
|
+
[cx + holeSize, cz + holeSize],
|
|
281
|
+
[cx - holeSize, cz + holeSize],
|
|
282
|
+
]
|
|
283
|
+
const currentHoles = surfaceNode.holes ?? []
|
|
284
|
+
const currentMetadata = currentHoles.map(
|
|
285
|
+
(_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
|
|
286
|
+
)
|
|
287
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
288
|
+
useScene.getState().updateNode(
|
|
289
|
+
selectedId as AnyNodeId,
|
|
290
|
+
{
|
|
291
|
+
holes: [...currentHoles, newHole],
|
|
292
|
+
holeMetadata: [...currentMetadata, { source: 'manual' as const }],
|
|
293
|
+
} as Partial<AnyNode>,
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const handleDuplicate = () => {
|
|
298
|
+
if (!node.parentId) return
|
|
299
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
300
|
+
useScene.temporal.getState().pause()
|
|
301
|
+
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
|
302
|
+
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
|
303
|
+
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
|
304
|
+
if (draft) {
|
|
305
|
+
setMovingNode(draft as never)
|
|
306
|
+
setMovingNodeOrigin('2d')
|
|
307
|
+
useScene.temporal.getState().resume()
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
useScene.temporal.getState().resume()
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
|
|
314
|
+
delete (cloned as { id?: AnyNodeId }).id
|
|
315
|
+
const prevMeta =
|
|
316
|
+
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
|
|
317
|
+
? (cloned.metadata as Record<string, unknown>)
|
|
318
|
+
: {}
|
|
319
|
+
// Mark fresh + hand to the placement cursor so the copy follows the
|
|
320
|
+
// pointer and only lands on the next click — same gesture for every
|
|
321
|
+
// kind. Polyline runs (duct / pipe / lineset) ride the same path:
|
|
322
|
+
// `FloorplanRegistryMoveOverlay` translates their whole `path`, so they
|
|
323
|
+
// no longer need the old "offset + drop already-placed" special case.
|
|
324
|
+
cloned.metadata = { ...prevMeta, isNew: true }
|
|
325
|
+
const parsed = def.schema.parse(cloned) as AnyNode
|
|
326
|
+
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
|
|
327
|
+
setMovingNode(parsed as never)
|
|
328
|
+
useScene.temporal.getState().resume()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const handleDelete = () => {
|
|
332
|
+
sfxEmitter.emit('sfx:item-delete')
|
|
333
|
+
useScene.getState().deleteNode(selectedId)
|
|
334
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const handleQuickAction = (action: NodeQuickAction) => {
|
|
338
|
+
if (action.disabled) return
|
|
339
|
+
const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
|
|
340
|
+
const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
|
|
341
|
+
if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds })
|
|
342
|
+
if (result?.selectedIds) {
|
|
343
|
+
const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id)
|
|
344
|
+
sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return createPortal(
|
|
349
|
+
<div
|
|
350
|
+
className="pointer-events-none fixed z-30 flex w-max flex-col items-center"
|
|
351
|
+
style={{
|
|
352
|
+
left: position.left,
|
|
353
|
+
top: position.top,
|
|
354
|
+
transform: 'translate(-50%, calc(-100% - 32px))',
|
|
355
|
+
}}
|
|
356
|
+
>
|
|
357
|
+
<NodeActionMenu
|
|
358
|
+
onAddHole={canAddHole ? handleAddHole : undefined}
|
|
359
|
+
onDelete={canDelete ? handleDelete : undefined}
|
|
360
|
+
onDuplicate={canDuplicate ? handleDuplicate : undefined}
|
|
361
|
+
onMove={canMove ? handleMove : undefined}
|
|
362
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
363
|
+
onPointerUp={(event) => event.stopPropagation()}
|
|
364
|
+
/>
|
|
365
|
+
{quickActions.length > 0 ? (
|
|
366
|
+
<div
|
|
367
|
+
className="pointer-events-auto mt-1 inline-flex w-max items-center justify-center gap-0.5 rounded-lg border border-border/50 bg-background/90 px-1.5 py-1 shadow-md backdrop-blur-md"
|
|
368
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
369
|
+
onPointerUp={(event) => event.stopPropagation()}
|
|
370
|
+
>
|
|
371
|
+
{quickActions.map((action) => (
|
|
372
|
+
<button
|
|
373
|
+
aria-label={action.title ?? action.label}
|
|
374
|
+
className="tooltip-trigger flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
|
375
|
+
disabled={action.disabled}
|
|
376
|
+
key={action.id}
|
|
377
|
+
onClick={() => handleQuickAction(action)}
|
|
378
|
+
title={action.title ?? action.label}
|
|
379
|
+
type="button"
|
|
380
|
+
>
|
|
381
|
+
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
|
|
382
|
+
<QuickActionIcon action={action} />
|
|
383
|
+
</span>
|
|
384
|
+
<span className="whitespace-nowrap leading-none">{action.label}</span>
|
|
385
|
+
</button>
|
|
386
|
+
))}
|
|
387
|
+
</div>
|
|
388
|
+
) : null}
|
|
389
|
+
</div>,
|
|
390
|
+
document.body,
|
|
391
|
+
)
|
|
392
|
+
}
|