@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
|
@@ -5,16 +5,33 @@ import {
|
|
|
5
5
|
type AnyNodeId,
|
|
6
6
|
type CeilingNode,
|
|
7
7
|
ColumnNode,
|
|
8
|
+
createSceneApi,
|
|
9
|
+
DEFAULT_WALL_HEIGHT,
|
|
8
10
|
DoorNode,
|
|
11
|
+
ElevatorNode,
|
|
12
|
+
emitter,
|
|
9
13
|
FenceNode,
|
|
10
14
|
generateId,
|
|
15
|
+
getActiveRoofHeight,
|
|
16
|
+
getEffectiveNode,
|
|
17
|
+
getWallCurveLength,
|
|
18
|
+
getWallThickness,
|
|
11
19
|
ItemNode,
|
|
20
|
+
isCurvedWall,
|
|
21
|
+
isRegistryMovable,
|
|
22
|
+
isRegistrySelectable,
|
|
23
|
+
isSplineFence,
|
|
24
|
+
type NodeQuickAction,
|
|
25
|
+
nodeRegistry,
|
|
12
26
|
RoofSegmentNode,
|
|
27
|
+
runAsSingleSceneHistoryStep,
|
|
13
28
|
type SlabNode,
|
|
14
29
|
SpawnNode,
|
|
15
30
|
StairNode,
|
|
16
31
|
StairSegmentNode,
|
|
17
32
|
sceneRegistry,
|
|
33
|
+
summarizeSystemFor,
|
|
34
|
+
useLiveNodeOverrides,
|
|
18
35
|
useScene,
|
|
19
36
|
WallNode,
|
|
20
37
|
WindowNode,
|
|
@@ -22,19 +39,48 @@ import {
|
|
|
22
39
|
import { useViewer } from '@pascal-app/viewer'
|
|
23
40
|
import { Html } from '@react-three/drei'
|
|
24
41
|
import { useFrame } from '@react-three/fiber'
|
|
25
|
-
import {
|
|
26
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
42
|
+
import { useCallback, useMemo, useRef } from 'react'
|
|
27
43
|
import * as THREE from 'three'
|
|
44
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
45
|
+
import {
|
|
46
|
+
createFreshPlacementSubtree,
|
|
47
|
+
duplicatesAsFreshSubtree,
|
|
48
|
+
} from '../../lib/fresh-planar-placement'
|
|
49
|
+
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
|
|
50
|
+
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
|
|
28
51
|
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
|
29
|
-
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
52
|
+
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
|
30
53
|
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
|
31
54
|
import useEditor from '../../store/use-editor'
|
|
55
|
+
import useInteractionScope, {
|
|
56
|
+
useActiveHandleDrag,
|
|
57
|
+
useEndpointReshape,
|
|
58
|
+
useIsCurveReshape,
|
|
59
|
+
} from '../../store/use-interaction-scope'
|
|
60
|
+
import { IconRefGlyph } from '../ui/icon-ref'
|
|
61
|
+
import { formatMeasurement, MeasurementPill } from './measurement-pill'
|
|
32
62
|
import { NodeActionMenu } from './node-action-menu'
|
|
33
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A kind shows the system pill when it exposes typed ports — `def.ports`
|
|
66
|
+
* is exactly what makes a node participate in the supply/return graph the
|
|
67
|
+
* pill summarizes. Keeps the menu off a hand-maintained kind list.
|
|
68
|
+
*/
|
|
69
|
+
const hasPorts = (type: string) => nodeRegistry.get(type)?.ports != null
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A kind shows the rotation-axis pill when its R/T keyboard rotation
|
|
73
|
+
* turns around a user-cyclable axis (`keyboardActions.axisCycling`) —
|
|
74
|
+
* duct / pipe fittings with full 3D orientation.
|
|
75
|
+
*/
|
|
76
|
+
const hasAxisCycling = (type: string) =>
|
|
77
|
+
nodeRegistry.get(type)?.keyboardActions?.axisCycling === true
|
|
78
|
+
|
|
34
79
|
const ALLOWED_TYPES = [
|
|
35
80
|
'item',
|
|
36
81
|
'door',
|
|
37
82
|
'window',
|
|
83
|
+
'elevator',
|
|
38
84
|
'roof',
|
|
39
85
|
'roof-segment',
|
|
40
86
|
'stair',
|
|
@@ -49,34 +95,288 @@ const ALLOWED_TYPES = [
|
|
|
49
95
|
const DELETE_ONLY_TYPES: string[] = []
|
|
50
96
|
const HOLE_TYPES = ['slab', 'ceiling']
|
|
51
97
|
|
|
98
|
+
// Menu scales with camera zoom so it feels anchored to the object, but is
|
|
99
|
+
// clamped on both ends so it stays readable when zoomed way out and doesn't
|
|
100
|
+
// dominate the screen when zoomed in close. Reference values are picked so
|
|
101
|
+
// scale = 1 lands near the editor's default framing.
|
|
102
|
+
const MIN_MENU_SCALE = 0.5
|
|
103
|
+
// Cap at 1 so zooming in doesn't grow the menu past its default pixel size —
|
|
104
|
+
// only zoom-out shrinks it (down to MIN_MENU_SCALE).
|
|
105
|
+
const MAX_MENU_SCALE = 1
|
|
106
|
+
const REF_ORTHO_ZOOM = 20
|
|
107
|
+
const REF_CAMERA_DISTANCE = 12
|
|
108
|
+
|
|
109
|
+
// World-space Y distance from a node's bbox top to the floating menu anchor.
|
|
110
|
+
// Per-type because in-world chrome above the node (height-resize arrows,
|
|
111
|
+
// measurement labels) varies in vertical reach.
|
|
112
|
+
// `EXTRA_MENU_LIFT` is a uniform global nudge — easier to tune one
|
|
113
|
+
// constant than to bump every per-type entry below.
|
|
114
|
+
const EXTRA_MENU_LIFT = 0.35
|
|
115
|
+
const MENU_Y_OFFSET_DEFAULT = 0.3
|
|
116
|
+
const MENU_Y_OFFSETS: Record<string, number> = {
|
|
117
|
+
wall: 0.5,
|
|
118
|
+
door: 0.6,
|
|
119
|
+
window: 0.6,
|
|
120
|
+
column: 0.6,
|
|
121
|
+
// Fence: still clears the height-resize arrow (sits at fence.height +
|
|
122
|
+
// 0.45) plus the chevron's visual size, but kept low so the menu sits
|
|
123
|
+
// close to the fence rather than floating well above it.
|
|
124
|
+
fence: 0.7,
|
|
125
|
+
// Elevator: clears the cab-height arrow which sits above the SHAFT
|
|
126
|
+
// top (resolved through level entries), so the menu floats above it.
|
|
127
|
+
elevator: 0.9,
|
|
128
|
+
stair: 0.2,
|
|
129
|
+
'stair-stair': 1.1,
|
|
130
|
+
'stair-landing': 0.9,
|
|
131
|
+
// Slab: clears the height arrow that sits at elevation + 0.22 plus the
|
|
132
|
+
// chevron's own visual reach, so the menu floats just above it.
|
|
133
|
+
slab: 0.7,
|
|
134
|
+
// Ceiling: clears the upward height arrow that sits ~0.22 above the
|
|
135
|
+
// ceiling plane, plus extra headroom so the menu doesn't crowd the
|
|
136
|
+
// chevron at any zoom level.
|
|
137
|
+
ceiling: 1.0,
|
|
138
|
+
// Shelf: clears the height arrow that sits at shelf.height + 0.22
|
|
139
|
+
// plus the chevron's visual reach.
|
|
140
|
+
shelf: 0.6,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getMenuYOffset(node: AnyNode | null): number {
|
|
144
|
+
if (!node) return MENU_Y_OFFSET_DEFAULT + EXTRA_MENU_LIFT
|
|
145
|
+
if (node.type === 'stair-segment') {
|
|
146
|
+
return (MENU_Y_OFFSETS[`stair-${node.segmentType}`] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
|
|
147
|
+
}
|
|
148
|
+
return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getAttributeVersion(
|
|
152
|
+
attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute | null | undefined,
|
|
153
|
+
): number {
|
|
154
|
+
return attribute && 'version' in attribute && typeof attribute.version === 'number'
|
|
155
|
+
? attribute.version
|
|
156
|
+
: 0
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) {
|
|
160
|
+
return (
|
|
161
|
+
<svg
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
className="h-3.5 w-3.5"
|
|
164
|
+
fill="none"
|
|
165
|
+
stroke="currentColor"
|
|
166
|
+
strokeLinecap="round"
|
|
167
|
+
strokeLinejoin="round"
|
|
168
|
+
strokeWidth="1.7"
|
|
169
|
+
viewBox="0 0 24 24"
|
|
170
|
+
>
|
|
171
|
+
<rect x="8" y="7" width="9" height="10" rx="1.75" />
|
|
172
|
+
<path d="M12.5 7v10" />
|
|
173
|
+
{direction === 'left' ? (
|
|
174
|
+
<>
|
|
175
|
+
<path d="M6.5 12H2.75" />
|
|
176
|
+
<path d="m5.5 9.75-2.75 2.25 2.75 2.25" />
|
|
177
|
+
</>
|
|
178
|
+
) : (
|
|
179
|
+
<>
|
|
180
|
+
<path d="M17.5 12h3.75" />
|
|
181
|
+
<path d="m18.5 9.75 2.75 2.25-2.75 2.25" />
|
|
182
|
+
</>
|
|
183
|
+
)}
|
|
184
|
+
</svg>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Builtin string tokens map to the generic glyphs above; kind-owned marks
|
|
189
|
+
// arrive as IconRef objects and render through the shared IconRefGlyph.
|
|
190
|
+
function QuickActionIcon({ action }: { action: NodeQuickAction }) {
|
|
191
|
+
const icon = action.icon
|
|
192
|
+
if (!icon) return null
|
|
193
|
+
if (typeof icon === 'object') return <IconRefGlyph icon={icon} size={14} />
|
|
194
|
+
switch (icon) {
|
|
195
|
+
case 'add-left':
|
|
196
|
+
return <SideAddGlyph direction="left" />
|
|
197
|
+
case 'add-right':
|
|
198
|
+
return <SideAddGlyph direction="right" />
|
|
199
|
+
default:
|
|
200
|
+
return null
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function collectQuickActionNodes(
|
|
205
|
+
nodes: Record<AnyNodeId, AnyNode>,
|
|
206
|
+
selectedId: string | null,
|
|
207
|
+
): Record<AnyNodeId, AnyNode> | null {
|
|
208
|
+
if (!selectedId) return null
|
|
209
|
+
const selected = nodes[selectedId as AnyNodeId]
|
|
210
|
+
if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null
|
|
211
|
+
|
|
212
|
+
const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
|
|
213
|
+
const add = (id: string | null | undefined) => {
|
|
214
|
+
if (!id) return
|
|
215
|
+
const node = nodes[id as AnyNodeId]
|
|
216
|
+
if (node) collected[node.id as AnyNodeId] = node
|
|
217
|
+
}
|
|
218
|
+
const addChildren = (node: AnyNode | undefined) => {
|
|
219
|
+
for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
|
|
220
|
+
add(childId)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
add(selected.parentId ?? null)
|
|
225
|
+
addChildren(selected)
|
|
226
|
+
const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
|
|
227
|
+
addChildren(parent)
|
|
228
|
+
|
|
229
|
+
return collected
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
|
|
233
|
+
// dragged node doesn't allocate a fresh Box3 + Vector3 every frame.
|
|
234
|
+
const _anchorBox = new THREE.Box3()
|
|
235
|
+
const _anchorCenter = new THREE.Vector3()
|
|
236
|
+
|
|
237
|
+
function getObjectGeometryKey(object: THREE.Object3D): string {
|
|
238
|
+
const parts: string[] = []
|
|
239
|
+
object.traverse((child) => {
|
|
240
|
+
const geometry = (child as Partial<THREE.Mesh>).geometry
|
|
241
|
+
if (!geometry) return
|
|
242
|
+
|
|
243
|
+
parts.push(
|
|
244
|
+
[
|
|
245
|
+
geometry.id,
|
|
246
|
+
getAttributeVersion(geometry.getAttribute('position')),
|
|
247
|
+
getAttributeVersion(geometry.getIndex()),
|
|
248
|
+
].join(':'),
|
|
249
|
+
)
|
|
250
|
+
})
|
|
251
|
+
return parts.join('|')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function setNodeDerivedMenuAnchor(
|
|
255
|
+
node: AnyNode,
|
|
256
|
+
object: THREE.Object3D,
|
|
257
|
+
target: THREE.Vector3,
|
|
258
|
+
): boolean {
|
|
259
|
+
if (node.type !== 'roof-segment') return false
|
|
260
|
+
|
|
261
|
+
const visualTop =
|
|
262
|
+
node.wallHeight +
|
|
263
|
+
getActiveRoofHeight(node) +
|
|
264
|
+
Math.max(0, node.deckThickness ?? 0) +
|
|
265
|
+
Math.max(0, node.shingleThickness ?? 0)
|
|
266
|
+
|
|
267
|
+
target.set(0, visualTop, 0).applyMatrix4(object.matrixWorld)
|
|
268
|
+
target.y += getMenuYOffset(node)
|
|
269
|
+
return true
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Fence schema defaults — mirror packages/nodes/src/fence/definition.ts so the
|
|
273
|
+
// pill reads sensibly before an explicit height / thickness is set.
|
|
274
|
+
const FENCE_DEFAULT_HEIGHT = 1.8
|
|
275
|
+
const FENCE_DEFAULT_THICKNESS = 0.08
|
|
276
|
+
|
|
277
|
+
// Dimensions for the height-drag pill. Walls and fences both carry
|
|
278
|
+
// start/end/curveOffset, so getWallCurveLength covers length for either.
|
|
279
|
+
function getHeightPillDimensions(node: WallNode | FenceNode): {
|
|
280
|
+
height: number
|
|
281
|
+
length: number
|
|
282
|
+
thickness: number
|
|
283
|
+
} {
|
|
284
|
+
if (node.type === 'wall') {
|
|
285
|
+
return {
|
|
286
|
+
height: node.height ?? DEFAULT_WALL_HEIGHT,
|
|
287
|
+
length: getWallCurveLength(node),
|
|
288
|
+
thickness: getWallThickness(node),
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
height: node.height ?? FENCE_DEFAULT_HEIGHT,
|
|
293
|
+
length: getWallCurveLength(node),
|
|
294
|
+
thickness: node.thickness ?? FENCE_DEFAULT_THICKNESS,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
52
298
|
export function FloatingActionMenu() {
|
|
53
299
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
54
300
|
const updateNode = useScene((s) => s.updateNode)
|
|
55
301
|
const mode = useEditor((s) => s.mode)
|
|
56
302
|
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const
|
|
303
|
+
const canFindNode = useEditor((s) => s.canFindNode)
|
|
304
|
+
const endpointReshape = useEndpointReshape()
|
|
305
|
+
const isCurveReshape = useIsCurveReshape()
|
|
60
306
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
61
|
-
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
|
|
62
|
-
const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
|
|
63
|
-
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
|
64
|
-
const setCurvingFence = useEditor((s) => s.setCurvingFence)
|
|
65
307
|
const setSelection = useViewer((s) => s.setSelection)
|
|
66
|
-
const
|
|
308
|
+
const unit = useViewer((s) => s.unit)
|
|
309
|
+
// Drives the height-drag dimension pill below the menu. `activeHandleDrag`
|
|
310
|
+
// flips only at drag start / end, so subscribing here is cheap — the live
|
|
311
|
+
// height value is written imperatively in the useFrame below.
|
|
312
|
+
const activeHandleDrag = useActiveHandleDrag()
|
|
313
|
+
// R/T rotation axis for kinds with full 3D orientation (duct fittings).
|
|
314
|
+
const rotationAxis = useEditor((s) => s.rotationAxis)
|
|
315
|
+
// The floating action menu is an action-conflicting control: hard-hidden
|
|
316
|
+
// during any active interaction so it never competes with the live action.
|
|
317
|
+
const scope = useInteractionScope((s) => s.scope)
|
|
318
|
+
const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
|
|
67
319
|
|
|
68
320
|
const groupRef = useRef<THREE.Group>(null)
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
321
|
+
const menuScaleRef = useRef<HTMLDivElement>(null)
|
|
322
|
+
const pillHeightRef = useRef<HTMLSpanElement>(null)
|
|
323
|
+
|
|
324
|
+
// Cached world anchor. The anchor is derived from `Box3.setFromObject`,
|
|
325
|
+
// which traverses the selected object's children — so a node with a
|
|
326
|
+
// continuously-animating child (the spinning turbine-vent head) makes the
|
|
327
|
+
// AABB wobble a few millimetres every frame, and the menu drifts even with
|
|
328
|
+
// a still camera. We instead recompute the box only when the selection,
|
|
329
|
+
// the object's world transform, or its geometry actually changes, and
|
|
330
|
+
// reuse the cached anchor otherwise. (Also removes a per-frame
|
|
331
|
+
// `setFromObject` for every selection.)
|
|
332
|
+
const anchorRef = useRef(new THREE.Vector3())
|
|
333
|
+
const hasAnchorRef = useRef(false)
|
|
334
|
+
const lastMatrixRef = useRef(new THREE.Matrix4())
|
|
335
|
+
const lastAnchorKeyRef = useRef<{
|
|
336
|
+
id: string | null
|
|
337
|
+
node: AnyNode | null
|
|
338
|
+
geometryKey: string | null
|
|
339
|
+
}>({
|
|
340
|
+
id: null,
|
|
341
|
+
node: null,
|
|
342
|
+
geometryKey: null,
|
|
343
|
+
})
|
|
72
344
|
|
|
73
345
|
// Only show for single selection of specific types
|
|
74
|
-
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
|
346
|
+
const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
|
|
75
347
|
|
|
76
348
|
// Subscribe just to the selected node so unrelated scene updates do not
|
|
77
349
|
// re-render this menu.
|
|
78
350
|
const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
|
|
79
|
-
const
|
|
351
|
+
const quickActionNodes = useScene(useShallow((s) => collectQuickActionNodes(s.nodes, selectedId)))
|
|
352
|
+
const quickActions = useMemo<NodeQuickAction[]>(
|
|
353
|
+
() =>
|
|
354
|
+
node && quickActionNodes
|
|
355
|
+
? (nodeRegistry.get(node.type)?.quickActions?.({
|
|
356
|
+
node: node as never,
|
|
357
|
+
nodes: quickActionNodes,
|
|
358
|
+
}) ?? [])
|
|
359
|
+
: [],
|
|
360
|
+
[node, quickActionNodes],
|
|
361
|
+
)
|
|
362
|
+
// ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any
|
|
363
|
+
// NodeDefinition with `capabilities.selectable`) get the floating menu
|
|
364
|
+
// by default too. Phase 4 collapses these into a single registry check.
|
|
365
|
+
const isValidType = node
|
|
366
|
+
? nodeRegistry.get(node.type)?.presentation?.actionMenu !== false &&
|
|
367
|
+
(ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type))
|
|
368
|
+
: false
|
|
369
|
+
|
|
370
|
+
// Height-drag pill: shown just above the menu only while the selected
|
|
371
|
+
// wall/fence height arrow is being dragged. Length + thickness are fixed
|
|
372
|
+
// during a height drag, so they're computed here; the live height value
|
|
373
|
+
// is updated imperatively in the useFrame (same pattern as the scale).
|
|
374
|
+
const pillNode = node?.type === 'wall' || node?.type === 'fence' ? node : null
|
|
375
|
+
const isHeightDragPill =
|
|
376
|
+
pillNode !== null &&
|
|
377
|
+
activeHandleDrag?.nodeId === selectedId &&
|
|
378
|
+
activeHandleDrag?.label === 'height'
|
|
379
|
+
const pillDims = pillNode ? getHeightPillDimensions(pillNode) : null
|
|
80
380
|
|
|
81
381
|
// Boolean selector, only re-renders when curving availability actually flips.
|
|
82
382
|
const canCurveSelectedWall = useScene((s) => {
|
|
@@ -95,111 +395,103 @@ export function FloatingActionMenu() {
|
|
|
95
395
|
})
|
|
96
396
|
})
|
|
97
397
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (event.key === 'Alt') {
|
|
101
|
-
setAltPressed(true)
|
|
102
|
-
}
|
|
103
|
-
}
|
|
398
|
+
useFrame((state) => {
|
|
399
|
+
if (!(selectedId && node && isValidType && groupRef.current)) return
|
|
104
400
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
401
|
+
// Scale the HTML menu with camera zoom (ortho) or inverse distance
|
|
402
|
+
// (perspective) so it feels anchored to the world, clamped on both ends
|
|
403
|
+
// so it stays readable at extreme zoom-out and doesn't fill the screen
|
|
404
|
+
// when zoomed in close.
|
|
405
|
+
if (menuScaleRef.current) {
|
|
406
|
+
const raw =
|
|
407
|
+
state.camera instanceof THREE.OrthographicCamera
|
|
408
|
+
? state.camera.zoom / REF_ORTHO_ZOOM
|
|
409
|
+
: REF_CAMERA_DISTANCE /
|
|
410
|
+
Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001)
|
|
411
|
+
const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
|
|
412
|
+
menuScaleRef.current.style.transform = `scale(${scale})`
|
|
109
413
|
}
|
|
110
414
|
|
|
111
|
-
|
|
112
|
-
|
|
415
|
+
// Live height readout for the drag pill. The dragged height lands in
|
|
416
|
+
// `useLiveNodeOverrides` (not the scene store) each frame, so read it
|
|
417
|
+
// imperatively here instead of forcing a per-frame React re-render.
|
|
418
|
+
if (
|
|
419
|
+
pillHeightRef.current &&
|
|
420
|
+
(node?.type === 'wall' || node?.type === 'fence') &&
|
|
421
|
+
activeHandleDrag?.nodeId === selectedId &&
|
|
422
|
+
activeHandleDrag?.label === 'height'
|
|
423
|
+
) {
|
|
424
|
+
const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as
|
|
425
|
+
| { height?: number }
|
|
426
|
+
| undefined
|
|
427
|
+
const fallbackHeight = node.type === 'wall' ? DEFAULT_WALL_HEIGHT : FENCE_DEFAULT_HEIGHT
|
|
428
|
+
const liveHeight = override?.height ?? node.height ?? fallbackHeight
|
|
429
|
+
pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}`
|
|
113
430
|
}
|
|
114
431
|
|
|
115
|
-
window.addEventListener('keydown', handleKeyDown)
|
|
116
|
-
window.addEventListener('keyup', handleKeyUp)
|
|
117
|
-
window.addEventListener('blur', handleBlur)
|
|
118
|
-
|
|
119
|
-
return () => {
|
|
120
|
-
window.removeEventListener('keydown', handleKeyDown)
|
|
121
|
-
window.removeEventListener('keyup', handleKeyUp)
|
|
122
|
-
window.removeEventListener('blur', handleBlur)
|
|
123
|
-
}
|
|
124
|
-
}, [])
|
|
125
|
-
|
|
126
|
-
useFrame(() => {
|
|
127
|
-
if (!(selectedId && isValidType && groupRef.current)) return
|
|
128
|
-
|
|
129
432
|
const obj = sceneRegistry.nodes.get(selectedId)
|
|
130
433
|
if (obj) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
434
|
+
obj.updateWorldMatrix(true, false)
|
|
435
|
+
|
|
436
|
+
// Recompute the anchor only when the object genuinely changes —
|
|
437
|
+
// reselected, moved (its own world matrix changed), or resized
|
|
438
|
+
// (a fresh store node on commit, or a live override / handle drag
|
|
439
|
+
// mid-resize). A spinning child changes the head's matrix, not the
|
|
440
|
+
// registered group's, so it never triggers a recompute → the menu
|
|
441
|
+
// holds still.
|
|
442
|
+
// Cheapest guards first: a selection swap, the object's own world
|
|
443
|
+
// transform changing (true every frame during a drag), a live override,
|
|
444
|
+
// or an active handle drag all force a recompute on their own — so skip
|
|
445
|
+
// the geometry traversal (`getObjectGeometryKey` walks the whole subtree
|
|
446
|
+
// reading attribute versions) until none of them fired and a
|
|
447
|
+
// geometry-only change is the only thing left that could move the anchor.
|
|
448
|
+
const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
|
|
449
|
+
const dragActive = activeHandleDrag?.nodeId === selectedId
|
|
450
|
+
const selectionChanged =
|
|
451
|
+
lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
|
|
452
|
+
const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
|
|
453
|
+
|
|
454
|
+
let geometryKey = lastAnchorKeyRef.current.geometryKey
|
|
455
|
+
let needsRecompute = selectionChanged || matrixChanged || overrideActive || dragActive
|
|
456
|
+
// Only when nothing cheaper fired do we pay for the subtree traversal —
|
|
457
|
+
// a geometry-only change is the lone remaining trigger. When a cheaper
|
|
458
|
+
// guard already forced a recompute the stored key is reused; the matrix
|
|
459
|
+
// (or override/drag) keeps recomputing the anchor every frame, so a
|
|
460
|
+
// geometry edit mid-drag is absorbed, and the next idle frame refreshes
|
|
461
|
+
// the key against the live geometry.
|
|
462
|
+
if (!needsRecompute) {
|
|
463
|
+
geometryKey = getObjectGeometryKey(obj)
|
|
464
|
+
if (geometryKey !== lastAnchorKeyRef.current.geometryKey) needsRecompute = true
|
|
139
465
|
}
|
|
140
466
|
|
|
141
|
-
if (
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (startEndpointGroupRef.current) {
|
|
160
|
-
startEndpointGroupRef.current.position.set(
|
|
161
|
-
startWorld.x,
|
|
162
|
-
startWorld.y + endpointYOffset,
|
|
163
|
-
startWorld.z,
|
|
164
|
-
)
|
|
165
|
-
}
|
|
166
|
-
if (endEndpointGroupRef.current) {
|
|
167
|
-
endEndpointGroupRef.current.position.set(
|
|
168
|
-
endWorld.x,
|
|
169
|
-
endWorld.y + endpointYOffset,
|
|
170
|
-
endWorld.z,
|
|
171
|
-
)
|
|
467
|
+
if (needsRecompute) {
|
|
468
|
+
const effectiveNode = getEffectiveNode(node)
|
|
469
|
+
if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) {
|
|
470
|
+
_anchorBox.setFromObject(obj)
|
|
471
|
+
if (!_anchorBox.isEmpty()) {
|
|
472
|
+
_anchorBox.getCenter(_anchorCenter)
|
|
473
|
+
// Position above the object. Per-type offsets clear each kind's
|
|
474
|
+
// in-world chrome (height-resize arrows, measurement labels).
|
|
475
|
+
anchorRef.current.set(
|
|
476
|
+
_anchorCenter.x,
|
|
477
|
+
_anchorBox.max.y + getMenuYOffset(effectiveNode),
|
|
478
|
+
_anchorCenter.z,
|
|
479
|
+
)
|
|
480
|
+
hasAnchorRef.current = true
|
|
481
|
+
}
|
|
482
|
+
} else {
|
|
483
|
+
hasAnchorRef.current = true
|
|
172
484
|
}
|
|
485
|
+
lastMatrixRef.current.copy(obj.matrixWorld)
|
|
486
|
+
lastAnchorKeyRef.current = { id: selectedId, node, geometryKey }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (hasAnchorRef.current) {
|
|
490
|
+
groupRef.current.position.copy(anchorRef.current)
|
|
173
491
|
}
|
|
174
492
|
}
|
|
175
493
|
})
|
|
176
494
|
|
|
177
|
-
const handleMove = useCallback(
|
|
178
|
-
(e: React.MouseEvent) => {
|
|
179
|
-
e.stopPropagation()
|
|
180
|
-
if (!node) return
|
|
181
|
-
sfxEmitter.emit('sfx:item-pick')
|
|
182
|
-
if (
|
|
183
|
-
node.type === 'item' ||
|
|
184
|
-
node.type === 'window' ||
|
|
185
|
-
node.type === 'door' ||
|
|
186
|
-
node.type === 'wall' ||
|
|
187
|
-
node.type === 'fence' ||
|
|
188
|
-
node.type === 'column' ||
|
|
189
|
-
node.type === 'slab' ||
|
|
190
|
-
node.type === 'ceiling' ||
|
|
191
|
-
node.type === 'spawn' ||
|
|
192
|
-
node.type === 'roof' ||
|
|
193
|
-
node.type === 'roof-segment' ||
|
|
194
|
-
node.type === 'stair' ||
|
|
195
|
-
node.type === 'stair-segment'
|
|
196
|
-
) {
|
|
197
|
-
setMovingNode(node as any)
|
|
198
|
-
}
|
|
199
|
-
setSelection({ selectedIds: [] })
|
|
200
|
-
},
|
|
201
|
-
[node, setMovingNode, setSelection],
|
|
202
|
-
)
|
|
203
495
|
const handleCurve = useCallback(
|
|
204
496
|
(e: React.MouseEvent) => {
|
|
205
497
|
e.stopPropagation()
|
|
@@ -207,33 +499,26 @@ export function FloatingActionMenu() {
|
|
|
207
499
|
sfxEmitter.emit('sfx:item-pick')
|
|
208
500
|
if (node.type === 'wall') {
|
|
209
501
|
if (!canCurveSelectedWall) return
|
|
210
|
-
|
|
502
|
+
useInteractionScope.getState().begin(curveReshapeScope(node.id))
|
|
211
503
|
} else if (node.type === 'fence') {
|
|
212
|
-
|
|
504
|
+
useInteractionScope.getState().begin(curveReshapeScope(node.id))
|
|
213
505
|
} else {
|
|
214
506
|
return
|
|
215
507
|
}
|
|
216
508
|
setSelection({ selectedIds: [] })
|
|
217
509
|
},
|
|
218
|
-
[canCurveSelectedWall, node,
|
|
510
|
+
[canCurveSelectedWall, node, setSelection],
|
|
219
511
|
)
|
|
220
|
-
const
|
|
221
|
-
(
|
|
512
|
+
const handleMove = useCallback(
|
|
513
|
+
(e: React.MouseEvent) => {
|
|
222
514
|
e.stopPropagation()
|
|
223
515
|
if (!node) return
|
|
224
516
|
sfxEmitter.emit('sfx:item-pick')
|
|
225
|
-
|
|
226
|
-
setMovingWallEndpoint({ wall: node, endpoint })
|
|
227
|
-
} else if (node.type === 'fence') {
|
|
228
|
-
setMovingFenceEndpoint({ fence: node, endpoint })
|
|
229
|
-
} else {
|
|
230
|
-
return
|
|
231
|
-
}
|
|
517
|
+
setMovingNode(node as any)
|
|
232
518
|
setSelection({ selectedIds: [] })
|
|
233
519
|
},
|
|
234
|
-
[node,
|
|
520
|
+
[node, setMovingNode, setSelection],
|
|
235
521
|
)
|
|
236
|
-
|
|
237
522
|
const handleDuplicate = useCallback(
|
|
238
523
|
(e: React.MouseEvent) => {
|
|
239
524
|
e.stopPropagation()
|
|
@@ -251,6 +536,18 @@ export function FloatingActionMenu() {
|
|
|
251
536
|
|
|
252
537
|
useScene.temporal.getState().pause()
|
|
253
538
|
|
|
539
|
+
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
|
540
|
+
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
|
541
|
+
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
|
542
|
+
if (draft) {
|
|
543
|
+
setMovingNode(draft as any)
|
|
544
|
+
setSelection({ selectedIds: [] })
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
useScene.temporal.getState().resume()
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
|
|
254
551
|
let duplicateInfo = structuredClone(node) as any
|
|
255
552
|
delete duplicateInfo.id
|
|
256
553
|
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
|
@@ -263,6 +560,8 @@ export function FloatingActionMenu() {
|
|
|
263
560
|
duplicate = WindowNode.parse(duplicateInfo)
|
|
264
561
|
} else if (node.type === 'item') {
|
|
265
562
|
duplicate = ItemNode.parse(duplicateInfo)
|
|
563
|
+
} else if (node.type === 'elevator') {
|
|
564
|
+
duplicate = ElevatorNode.parse(duplicateInfo)
|
|
266
565
|
} else if (node.type === 'column') {
|
|
267
566
|
duplicate = ColumnNode.parse(duplicateInfo)
|
|
268
567
|
} else if (node.type === 'wall') {
|
|
@@ -284,6 +583,16 @@ export function FloatingActionMenu() {
|
|
|
284
583
|
} else if (node.type === 'spawn') {
|
|
285
584
|
duplicate = SpawnNode.parse(duplicateInfo)
|
|
286
585
|
}
|
|
586
|
+
|
|
587
|
+
// Registry-driven fallback: any kind with a NodeDefinition can be
|
|
588
|
+
// duplicated through its schema's parse(). Future built-in kinds
|
|
589
|
+
// get duplicate for free.
|
|
590
|
+
if (!duplicate) {
|
|
591
|
+
const def = nodeRegistry.get(node.type)
|
|
592
|
+
if (def) {
|
|
593
|
+
duplicate = def.schema.parse(duplicateInfo) as AnyNode
|
|
594
|
+
}
|
|
595
|
+
}
|
|
287
596
|
} catch (error) {
|
|
288
597
|
console.error('Failed to parse duplicate', error)
|
|
289
598
|
useScene.temporal.getState().resume()
|
|
@@ -296,7 +605,11 @@ export function FloatingActionMenu() {
|
|
|
296
605
|
}
|
|
297
606
|
|
|
298
607
|
if (duplicate) {
|
|
299
|
-
if (
|
|
608
|
+
if (
|
|
609
|
+
duplicate.type === 'door' ||
|
|
610
|
+
duplicate.type === 'window' ||
|
|
611
|
+
duplicate.type === 'elevator'
|
|
612
|
+
) {
|
|
300
613
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
301
614
|
} else if (duplicate.type === 'wall') {
|
|
302
615
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
@@ -322,9 +635,61 @@ export function FloatingActionMenu() {
|
|
|
322
635
|
}
|
|
323
636
|
|
|
324
637
|
// Duplicate children for stair nodes
|
|
638
|
+
} else if (
|
|
639
|
+
duplicate.type === 'item' ||
|
|
640
|
+
duplicate.type === 'chimney' ||
|
|
641
|
+
duplicate.type === 'dormer'
|
|
642
|
+
) {
|
|
643
|
+
// Items, chimneys & dormers use pure drag-to-place: NO node is
|
|
644
|
+
// inserted into the scene until the user clicks to commit. The
|
|
645
|
+
// `setMovingNode` call below hands the clone (with
|
|
646
|
+
// `metadata.isNew = true` + no id) to its move tool —
|
|
647
|
+
// `MoveItemTool` / `MoveChimneyTool` / `MoveDormerTool` — which
|
|
648
|
+
// create a draft and call `createNode` on the commit click.
|
|
649
|
+
// Pre-creating here would drop a second copy into the scene
|
|
650
|
+
// before any click — the furnish-tab "duplicate auto-places an
|
|
651
|
+
// item without clicking" bug. (Item has its own
|
|
652
|
+
// draft-committing move tool, so it must skip the generic
|
|
653
|
+
// registry auto-create branch below.)
|
|
654
|
+
} else if (
|
|
655
|
+
duplicate.type === 'duct-segment' ||
|
|
656
|
+
duplicate.type === 'duct-fitting' ||
|
|
657
|
+
duplicate.type === 'pipe-segment' ||
|
|
658
|
+
duplicate.type === 'lineset' ||
|
|
659
|
+
duplicate.type === 'liquid-line'
|
|
660
|
+
) {
|
|
661
|
+
// Duct runs & fittings, DWV pipe runs, and refrigerant linesets use
|
|
662
|
+
// pure drag-to-place: NO node is inserted into the scene until the
|
|
663
|
+
// commit click. `setMovingNode` below hands the clone (with
|
|
664
|
+
// `metadata.isNew`) to its ghost tool (`MoveDuctSegmentTool` /
|
|
665
|
+
// `MoveDuctFittingTool` / `MovePipeSegmentTool` / `MoveLinesetTool`),
|
|
666
|
+
// which previews a translucent copy inside a footprint bounding box
|
|
667
|
+
// on the cursor and calls `createNode` on the drop click.
|
|
668
|
+
// Pre-creating here would drop a copy before any click — the
|
|
669
|
+
// "auto-places it" bug.
|
|
670
|
+
} else if (nodeRegistry.has(duplicate.type)) {
|
|
671
|
+
// Registry-driven kinds: offset slightly so the duplicate doesn't
|
|
672
|
+
// overlap exactly, then create + hand to the move tool. Mirrors the
|
|
673
|
+
// roof-segment / stair-segment behavior.
|
|
674
|
+
if ('position' in duplicate && Array.isArray((duplicate as any).position)) {
|
|
675
|
+
const pos = (duplicate as { position: [number, number, number] }).position
|
|
676
|
+
;(duplicate as { position: [number, number, number] }).position = [
|
|
677
|
+
pos[0] + 1,
|
|
678
|
+
pos[1],
|
|
679
|
+
pos[2] + 1,
|
|
680
|
+
]
|
|
681
|
+
} else if ('path' in duplicate && Array.isArray((duplicate as any).path)) {
|
|
682
|
+
// Other polyline kinds (pipe / lineset) carry a `path`, not a
|
|
683
|
+
// `position`. Create the copy HIDDEN so nothing is auto-placed:
|
|
684
|
+
// their shared path mover reveals it as a cursor-following
|
|
685
|
+
// preview on the first mouse move and commits on the next click.
|
|
686
|
+
;(duplicate as { visible?: boolean }).visible = false
|
|
687
|
+
}
|
|
688
|
+
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
325
689
|
}
|
|
326
690
|
if (
|
|
327
691
|
duplicate.type === 'item' ||
|
|
692
|
+
duplicate.type === 'elevator' ||
|
|
328
693
|
duplicate.type === 'column' ||
|
|
329
694
|
duplicate.type === 'wall' ||
|
|
330
695
|
duplicate.type === 'fence' ||
|
|
@@ -332,7 +697,10 @@ export function FloatingActionMenu() {
|
|
|
332
697
|
duplicate.type === 'door' ||
|
|
333
698
|
duplicate.type === 'roof-segment' ||
|
|
334
699
|
duplicate.type === 'spawn' ||
|
|
335
|
-
duplicate.type === 'stair-segment'
|
|
700
|
+
duplicate.type === 'stair-segment' ||
|
|
701
|
+
// Registry-driven kinds get picked up by MoveTool's generic
|
|
702
|
+
// fallback (MoveRegistryNodeTool) so the user can reposition.
|
|
703
|
+
nodeRegistry.has(duplicate.type)
|
|
336
704
|
) {
|
|
337
705
|
setMovingNode(duplicate as any)
|
|
338
706
|
} else if (duplicate.type === 'stair') {
|
|
@@ -377,33 +745,57 @@ export function FloatingActionMenu() {
|
|
|
377
745
|
holes: [...currentHoles, newHole],
|
|
378
746
|
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
|
379
747
|
})
|
|
380
|
-
|
|
748
|
+
useInteractionScope
|
|
749
|
+
.getState()
|
|
750
|
+
.begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length }))
|
|
381
751
|
// Re-assert selection so the node stays selected
|
|
382
752
|
setSelection({ selectedIds: [selectedId] })
|
|
383
753
|
},
|
|
384
|
-
[node, selectedId, updateNode,
|
|
754
|
+
[node, selectedId, updateNode, setSelection],
|
|
385
755
|
)
|
|
386
756
|
|
|
387
757
|
const handleDelete = useCallback(
|
|
388
758
|
(e: React.MouseEvent) => {
|
|
389
759
|
e.stopPropagation()
|
|
390
760
|
if (!selectedId) return
|
|
391
|
-
|
|
392
|
-
sfxEmitter.emit('sfx:item-delete')
|
|
393
|
-
} else {
|
|
394
|
-
sfxEmitter.emit('sfx:structure-delete')
|
|
395
|
-
}
|
|
761
|
+
emitDeleteSFX(node?.type)
|
|
396
762
|
setSelection({ selectedIds: [] })
|
|
397
763
|
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
|
398
764
|
},
|
|
399
765
|
[node?.type, selectedId, setSelection],
|
|
400
766
|
)
|
|
401
767
|
|
|
768
|
+
// "Find in catalog": the editor only signals intent — the host (community)
|
|
769
|
+
// listens for `selection:find-node` and reveals the node in its browser.
|
|
770
|
+
const handleFind = useCallback(
|
|
771
|
+
(e: React.MouseEvent) => {
|
|
772
|
+
e.stopPropagation()
|
|
773
|
+
if (node) emitter.emit('selection:find-node', node)
|
|
774
|
+
},
|
|
775
|
+
[node],
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
const handleQuickAction = useCallback(
|
|
779
|
+
(action: NodeQuickAction) => (e: React.MouseEvent) => {
|
|
780
|
+
e.stopPropagation()
|
|
781
|
+
if (!node || action.disabled) return
|
|
782
|
+
const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
|
|
783
|
+
const result =
|
|
784
|
+
action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
|
|
785
|
+
if (result?.selectedIds) setSelection({ selectedIds: result.selectedIds })
|
|
786
|
+
if (result?.selectedIds) {
|
|
787
|
+
const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id)
|
|
788
|
+
sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
[node, setSelection],
|
|
792
|
+
)
|
|
793
|
+
|
|
402
794
|
if (
|
|
403
795
|
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
796
|
+
endpointReshape ||
|
|
797
|
+
isCurveReshape ||
|
|
798
|
+
menuStepBack
|
|
407
799
|
)
|
|
408
800
|
return null
|
|
409
801
|
|
|
@@ -416,86 +808,162 @@ export function FloatingActionMenu() {
|
|
|
416
808
|
pointerEvents: 'auto',
|
|
417
809
|
touchAction: 'none',
|
|
418
810
|
}}
|
|
419
|
-
zIndexRange={[
|
|
811
|
+
zIndexRange={[25, 0]}
|
|
420
812
|
>
|
|
421
|
-
<
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
813
|
+
<div
|
|
814
|
+
className="relative flex flex-col items-center"
|
|
815
|
+
ref={menuScaleRef}
|
|
816
|
+
style={{ transformOrigin: 'center center' }}
|
|
817
|
+
>
|
|
818
|
+
<NodeActionMenu
|
|
819
|
+
onFind={node && canFindNode ? handleFind : undefined}
|
|
820
|
+
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
|
821
|
+
onCurve={
|
|
822
|
+
(node?.type === 'fence' && !isSplineFence(node) && !isCurvedWall(node)) ||
|
|
823
|
+
(node?.type === 'wall' && canCurveSelectedWall)
|
|
824
|
+
? handleCurve
|
|
825
|
+
: undefined
|
|
826
|
+
}
|
|
827
|
+
onMove={
|
|
828
|
+
// Fully registry-driven: any kind that declares
|
|
829
|
+
// `capabilities.movable`, a `floorplanMoveTarget`, or a
|
|
830
|
+
// 3D `affordanceTools.move` mover gets the Move button.
|
|
831
|
+
// Adding a new movable kind never touches this file.
|
|
832
|
+
node && isRegistryMovable(node.type) ? handleMove : undefined
|
|
833
|
+
}
|
|
834
|
+
onDelete={handleDelete}
|
|
835
|
+
onDuplicate={
|
|
836
|
+
node &&
|
|
837
|
+
node.type !== 'spawn' &&
|
|
838
|
+
!DELETE_ONLY_TYPES.includes(node.type) &&
|
|
839
|
+
!HOLE_TYPES.includes(node.type)
|
|
840
|
+
? handleDuplicate
|
|
841
|
+
: undefined
|
|
842
|
+
}
|
|
843
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
844
|
+
onPointerUp={(e) => e.stopPropagation()}
|
|
845
|
+
/>
|
|
846
|
+
{quickActions.length > 0 ? (
|
|
847
|
+
<div
|
|
848
|
+
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"
|
|
849
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
850
|
+
onPointerUp={(e) => e.stopPropagation()}
|
|
851
|
+
>
|
|
852
|
+
{quickActions.map((action) => (
|
|
853
|
+
<button
|
|
854
|
+
aria-label={action.title ?? action.label}
|
|
855
|
+
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"
|
|
856
|
+
disabled={action.disabled}
|
|
857
|
+
key={action.id}
|
|
858
|
+
onClick={handleQuickAction(action)}
|
|
859
|
+
title={action.title ?? action.label}
|
|
860
|
+
type="button"
|
|
861
|
+
>
|
|
862
|
+
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
|
|
863
|
+
<QuickActionIcon action={action} />
|
|
864
|
+
</span>
|
|
865
|
+
<span className="whitespace-nowrap leading-none">{action.label}</span>
|
|
866
|
+
</button>
|
|
867
|
+
))}
|
|
868
|
+
</div>
|
|
869
|
+
) : null}
|
|
870
|
+
{/* Height-drag dimension pill. Absolutely positioned just above
|
|
871
|
+
the menu (away from the height arrow below it) so it rides the
|
|
872
|
+
same scale transform + anchor, never overlaps the menu, and
|
|
873
|
+
needs no menu lift — which is what caused the click flicker.
|
|
874
|
+
Non-interactive. */}
|
|
875
|
+
{isHeightDragPill && pillDims ? (
|
|
876
|
+
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 mb-2">
|
|
877
|
+
<MeasurementPill
|
|
878
|
+
height={pillDims.height}
|
|
879
|
+
length={pillDims.length}
|
|
880
|
+
primary="height"
|
|
881
|
+
ref={pillHeightRef}
|
|
882
|
+
thickness={pillDims.thickness}
|
|
883
|
+
unit={unit}
|
|
884
|
+
/>
|
|
885
|
+
</div>
|
|
886
|
+
) : null}
|
|
887
|
+
{/* HVAC chrome above the menu — same slot as the wall height
|
|
888
|
+
pill. System pill (which tree, run length, equipment reach)
|
|
889
|
+
for every distribution kind; the rotation-axis pill stacks
|
|
890
|
+
under it for duct fittings. */}
|
|
891
|
+
{node && hasPorts(node.type) ? (
|
|
892
|
+
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 mb-2 flex flex-col items-center gap-1">
|
|
893
|
+
<SystemSummaryPill nodeId={node.id} unit={unit} />
|
|
894
|
+
{hasAxisCycling(node.type) ? (
|
|
895
|
+
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
|
896
|
+
<span className="font-medium text-foreground">
|
|
897
|
+
Axis {rotationAxis.toUpperCase()}
|
|
898
|
+
</span>
|
|
899
|
+
<span aria-hidden className="text-muted-foreground">
|
|
900
|
+
·
|
|
901
|
+
</span>
|
|
902
|
+
<span className="text-muted-foreground">R/T rotate</span>
|
|
903
|
+
<span aria-hidden className="text-muted-foreground">
|
|
904
|
+
·
|
|
905
|
+
</span>
|
|
906
|
+
<span className="text-muted-foreground">⌥ axis</span>
|
|
907
|
+
</div>
|
|
908
|
+
) : null}
|
|
909
|
+
</div>
|
|
910
|
+
) : null}
|
|
911
|
+
</div>
|
|
441
912
|
</Html>
|
|
442
913
|
</group>
|
|
443
|
-
|
|
914
|
+
</group>
|
|
915
|
+
)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* System summary pill for a selected distribution kind (HVAC duct / DWV
|
|
920
|
+
* pipe / refrigerant lineset): which supply/return tree it belongs to, its
|
|
921
|
+
* run length, and whether it actually reaches a piece of equipment.
|
|
922
|
+
*
|
|
923
|
+
* Mounted only while an HVAC node is selected, so the full-`nodes`
|
|
924
|
+
* subscription it needs (connectivity changes when ANY joint moves) doesn't
|
|
925
|
+
* re-render the always-mounted parent menu on every unrelated scene tick.
|
|
926
|
+
*/
|
|
927
|
+
function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric' | 'imperial' }) {
|
|
928
|
+
const allNodes = useScene((s) => s.nodes)
|
|
929
|
+
const summary = useMemo(() => summarizeSystemFor(nodeId, allNodes), [nodeId, allNodes])
|
|
930
|
+
if (!summary) return null
|
|
931
|
+
return (
|
|
932
|
+
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
|
933
|
+
<span className="font-medium text-foreground">
|
|
934
|
+
{summary.systems.length > 0
|
|
935
|
+
? summary.systems.map((sys) => sys[0]!.toUpperCase() + sys.slice(1)).join(' + ')
|
|
936
|
+
: 'System'}
|
|
937
|
+
</span>
|
|
938
|
+
{summary.runCount > 0 ? (
|
|
444
939
|
<>
|
|
445
|
-
<
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
</group>
|
|
471
|
-
<group ref={endEndpointGroupRef}>
|
|
472
|
-
<Html
|
|
473
|
-
center
|
|
474
|
-
style={{ pointerEvents: 'auto', touchAction: 'none' }}
|
|
475
|
-
zIndexRange={[100, 0]}
|
|
476
|
-
>
|
|
477
|
-
<button
|
|
478
|
-
aria-label={node.type === 'wall' ? 'Move wall end' : 'Move fence end'}
|
|
479
|
-
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
|
480
|
-
altPressed
|
|
481
|
-
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
|
|
482
|
-
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
483
|
-
}`}
|
|
484
|
-
onClick={(e) => handleEndpointMove('end', e)}
|
|
485
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
486
|
-
title={
|
|
487
|
-
node.type === 'wall'
|
|
488
|
-
? 'Move wall end (Alt to detach)'
|
|
489
|
-
: 'Move fence end (Alt to detach)'
|
|
490
|
-
}
|
|
491
|
-
type="button"
|
|
492
|
-
>
|
|
493
|
-
<Move className="h-4 w-4" />
|
|
494
|
-
</button>
|
|
495
|
-
</Html>
|
|
496
|
-
</group>
|
|
940
|
+
<span aria-hidden className="text-muted-foreground">
|
|
941
|
+
·
|
|
942
|
+
</span>
|
|
943
|
+
<span className="text-muted-foreground">
|
|
944
|
+
{formatMeasurement(summary.runLengthM, unit)} · {summary.runCount}{' '}
|
|
945
|
+
{summary.runCount === 1 ? 'run' : 'runs'}
|
|
946
|
+
</span>
|
|
947
|
+
</>
|
|
948
|
+
) : null}
|
|
949
|
+
{summary.terminalCount > 0 ? (
|
|
950
|
+
<>
|
|
951
|
+
<span aria-hidden className="text-muted-foreground">
|
|
952
|
+
·
|
|
953
|
+
</span>
|
|
954
|
+
<span className="text-muted-foreground">
|
|
955
|
+
{summary.terminalCount} {summary.terminalCount === 1 ? 'register' : 'registers'}
|
|
956
|
+
</span>
|
|
957
|
+
</>
|
|
958
|
+
) : null}
|
|
959
|
+
{summary.connectedToEquipment ? null : (
|
|
960
|
+
<>
|
|
961
|
+
<span aria-hidden className="text-muted-foreground">
|
|
962
|
+
·
|
|
963
|
+
</span>
|
|
964
|
+
<span className="font-medium text-amber-500">⚠ no equipment</span>
|
|
497
965
|
</>
|
|
498
966
|
)}
|
|
499
|
-
</
|
|
967
|
+
</div>
|
|
500
968
|
)
|
|
501
969
|
}
|