@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,112 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { resolveSelectModeHelpHints } from './contextual-help'
|
|
3
|
+
|
|
4
|
+
describe('resolveSelectModeHelpHints', () => {
|
|
5
|
+
test('stays hidden in idle select mode with no selection', () => {
|
|
6
|
+
expect(
|
|
7
|
+
resolveSelectModeHelpHints({
|
|
8
|
+
selectedCount: 0,
|
|
9
|
+
hasMovableSelection: false,
|
|
10
|
+
hasRotatableSelection: false,
|
|
11
|
+
commandPressed: false,
|
|
12
|
+
shiftPressed: false,
|
|
13
|
+
}),
|
|
14
|
+
).toEqual([])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('shows multi-select guidance when a modifier is held without selection', () => {
|
|
18
|
+
expect(
|
|
19
|
+
resolveSelectModeHelpHints({
|
|
20
|
+
selectedCount: 0,
|
|
21
|
+
hasMovableSelection: false,
|
|
22
|
+
hasRotatableSelection: false,
|
|
23
|
+
commandPressed: true,
|
|
24
|
+
shiftPressed: false,
|
|
25
|
+
}),
|
|
26
|
+
).toEqual([
|
|
27
|
+
{
|
|
28
|
+
keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
|
|
29
|
+
label: 'Add or remove objects from the selection',
|
|
30
|
+
active: true,
|
|
31
|
+
},
|
|
32
|
+
])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('shows direct manipulation tips for selected movable and rotatable nodes', () => {
|
|
36
|
+
const hints = resolveSelectModeHelpHints({
|
|
37
|
+
selectedCount: 1,
|
|
38
|
+
hasMovableSelection: true,
|
|
39
|
+
hasRotatableSelection: true,
|
|
40
|
+
commandPressed: false,
|
|
41
|
+
shiftPressed: false,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(hints).toContainEqual({
|
|
45
|
+
keys: ['Left click'],
|
|
46
|
+
label: 'Drag selected movable object',
|
|
47
|
+
})
|
|
48
|
+
expect(hints).toContainEqual({
|
|
49
|
+
keys: ['Cmd/Ctrl', 'Right click'],
|
|
50
|
+
label: 'Drag left or right to rotate selected object',
|
|
51
|
+
})
|
|
52
|
+
// Cmd/Ctrl and Shift click both toggle selection membership (3D selection
|
|
53
|
+
// manager and 2D floorplan alike) — advertised as a single or-group row.
|
|
54
|
+
expect(hints).toContainEqual({
|
|
55
|
+
keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
|
|
56
|
+
label: 'Add or remove objects from the selection',
|
|
57
|
+
active: false,
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('multi-selection advertises the group move + rotate gestures', () => {
|
|
62
|
+
const hints = resolveSelectModeHelpHints({
|
|
63
|
+
selectedCount: 3,
|
|
64
|
+
hasMovableSelection: true,
|
|
65
|
+
hasRotatableSelection: true,
|
|
66
|
+
commandPressed: false,
|
|
67
|
+
shiftPressed: false,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(hints).toEqual([
|
|
71
|
+
{
|
|
72
|
+
keys: ['Left click'],
|
|
73
|
+
label: 'Click or drag the selection to move it as one',
|
|
74
|
+
},
|
|
75
|
+
{ keys: ['R / T'], label: 'Rotate the selection ±45°' },
|
|
76
|
+
{
|
|
77
|
+
keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
|
|
78
|
+
label: 'Add or remove objects from the selection',
|
|
79
|
+
active: false,
|
|
80
|
+
},
|
|
81
|
+
{ keys: ['Esc'], label: 'Clear the selection (or click outside)' },
|
|
82
|
+
])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('holding a modifier keeps the same rows and only lights the selection one', () => {
|
|
86
|
+
// Guides/snapping are governed by the snapping mode (Shift toggles it),
|
|
87
|
+
// so no modifier-specific "freely / with guides / bypass" variants exist.
|
|
88
|
+
const hints = resolveSelectModeHelpHints({
|
|
89
|
+
selectedCount: 1,
|
|
90
|
+
hasMovableSelection: true,
|
|
91
|
+
hasRotatableSelection: true,
|
|
92
|
+
commandPressed: true,
|
|
93
|
+
shiftPressed: true,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(hints).toEqual([
|
|
97
|
+
{
|
|
98
|
+
keys: ['Left click'],
|
|
99
|
+
label: 'Drag selected movable object',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
keys: ['Cmd/Ctrl', 'Right click'],
|
|
103
|
+
label: 'Drag left or right to rotate selected object',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
|
|
107
|
+
label: 'Add or remove objects from the selection',
|
|
108
|
+
active: true,
|
|
109
|
+
},
|
|
110
|
+
])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export type ContextualShortcutHint = {
|
|
2
|
+
// A combo of keys pressed together (rendered joined by "+"). An entry may
|
|
3
|
+
// itself be an array of alternatives (rendered joined by "/"), e.g.
|
|
4
|
+
// [['Cmd/Ctrl', 'Shift'], 'Left click'] → "⌘ / ⇧ + click".
|
|
5
|
+
keys: Array<string | string[]>
|
|
6
|
+
label: string
|
|
7
|
+
// Optional secondary line under the label for a terser qualifier
|
|
8
|
+
// (e.g. "disable 15° snap"). The HUD wraps both lines rather than truncating.
|
|
9
|
+
subtitle?: string
|
|
10
|
+
active?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// `activeHandleDrag.label` value a rotate gizmo sets while dragging, so the
|
|
14
|
+
// contextual HUD can surface the Shift = free-rotation toggle for the duration
|
|
15
|
+
// (mirrors how wall drafting advertises Shift). Distinct from resize handles,
|
|
16
|
+
// which route their own measurement label here.
|
|
17
|
+
export const ROTATE_HANDLE_DRAG_LABEL = 'rotate-handle'
|
|
18
|
+
|
|
19
|
+
// `activeHandleDrag.label` a plain resize / radial-resize arrow sets while
|
|
20
|
+
// dragging (when it carries no dimension `measureLabel`). It exists only so the
|
|
21
|
+
// interaction scope is non-idle during a resize, which keeps the idle
|
|
22
|
+
// select-mode hints off-screen — a resize is its own action, not a selection.
|
|
23
|
+
export const RESIZE_HANDLE_DRAG_LABEL = 'resize-handle'
|
|
24
|
+
|
|
25
|
+
// `activeHandleDrag.label`s for the multi-selection group drags: the body
|
|
26
|
+
// drag sessions (2D floorplan + 3D ground plane) and the rotate gizmo. Kept
|
|
27
|
+
// here (not in the session/gizmo modules) so `snapping-mode.ts` can resolve
|
|
28
|
+
// the group move to the 'item' snap context without importing component code.
|
|
29
|
+
export const GROUP_MOVE_DRAG_LABEL = 'group-move-handle'
|
|
30
|
+
export const GROUP_ROTATE_DRAG_LABEL = 'group-rotate-handle'
|
|
31
|
+
|
|
32
|
+
// Hints shown while a rotate gizmo is mid-drag: Shift bypasses the angle step
|
|
33
|
+
// (free rotation), the same toggle wall drafting exposes. `active` lights the
|
|
34
|
+
// pill while Shift is held.
|
|
35
|
+
export function resolveRotateHandleHelpHints(shiftPressed: boolean): ContextualShortcutHint[] {
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
keys: [SHIFT_KEY],
|
|
39
|
+
label: shiftPressed ? 'Rotating freely (no angle step)' : 'Hold to rotate freely',
|
|
40
|
+
active: shiftPressed,
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type SelectModeHelpContext = {
|
|
46
|
+
selectedCount: number
|
|
47
|
+
hasMovableSelection: boolean
|
|
48
|
+
hasRotatableSelection: boolean
|
|
49
|
+
commandPressed: boolean
|
|
50
|
+
shiftPressed: boolean
|
|
51
|
+
// When a single MEP node is selected its in-world handle rig (click a dot to
|
|
52
|
+
// reveal move arrows) is the real editing path, so the panel leads with the
|
|
53
|
+
// handle-specific hints instead of just the generic Cmd-drag tips.
|
|
54
|
+
mepSelection?: 'run' | 'fitting' | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const COMMAND_KEY = 'Cmd/Ctrl'
|
|
58
|
+
const LEFT_CLICK = 'Left click'
|
|
59
|
+
const RIGHT_CLICK = 'Right click'
|
|
60
|
+
const SHIFT_KEY = 'Shift'
|
|
61
|
+
const CLICK = 'Click'
|
|
62
|
+
const ALT_KEY = 'Alt'
|
|
63
|
+
const ROTATE_KEYS = 'R / T'
|
|
64
|
+
const ESC_KEY = 'Esc'
|
|
65
|
+
|
|
66
|
+
export function resolveSelectModeHelpHints({
|
|
67
|
+
selectedCount,
|
|
68
|
+
hasMovableSelection,
|
|
69
|
+
hasRotatableSelection,
|
|
70
|
+
commandPressed,
|
|
71
|
+
shiftPressed,
|
|
72
|
+
mepSelection = null,
|
|
73
|
+
}: SelectModeHelpContext): ContextualShortcutHint[] {
|
|
74
|
+
const hints: ContextualShortcutHint[] = []
|
|
75
|
+
|
|
76
|
+
if (selectedCount === 0) {
|
|
77
|
+
if (!commandPressed && !shiftPressed) return hints
|
|
78
|
+
|
|
79
|
+
hints.push({
|
|
80
|
+
keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK],
|
|
81
|
+
label: 'Add or remove objects from the selection',
|
|
82
|
+
active: true,
|
|
83
|
+
})
|
|
84
|
+
return hints
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Multi-selection — advertise the group gestures: drag any member to slide
|
|
88
|
+
// the whole selection (2D floor plan body drag + the 3D move gizmo), R / T
|
|
89
|
+
// to step-rotate the group around its bounding-box center.
|
|
90
|
+
if (selectedCount > 1) {
|
|
91
|
+
hints.push({
|
|
92
|
+
keys: [LEFT_CLICK],
|
|
93
|
+
label: 'Click or drag the selection to move it as one',
|
|
94
|
+
})
|
|
95
|
+
hints.push({ keys: [ROTATE_KEYS], label: 'Rotate the selection ±45°' })
|
|
96
|
+
hints.push({
|
|
97
|
+
keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK],
|
|
98
|
+
label: 'Add or remove objects from the selection',
|
|
99
|
+
active: commandPressed || shiftPressed,
|
|
100
|
+
})
|
|
101
|
+
hints.push({ keys: [ESC_KEY], label: 'Clear the selection (or click outside)' })
|
|
102
|
+
return hints
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// MEP handle workflow — duct/pipe runs and fittings are edited through the
|
|
106
|
+
// in-world arrow rig that a click on the handle dot reveals, so surface those
|
|
107
|
+
// hints first. A run endpoint's side / up-down arrows swing the run and Alt
|
|
108
|
+
// detaches the joint mid-drag; a fitting's cluster adds rotate arcs, with
|
|
109
|
+
// R / T (and Alt to switch axis) for keyboard rotation.
|
|
110
|
+
if (mepSelection === 'run') {
|
|
111
|
+
hints.push({ keys: [CLICK], label: 'Click a handle dot to show move arrows' })
|
|
112
|
+
hints.push({ keys: [ALT_KEY], label: 'Detach the joint while dragging an arrow' })
|
|
113
|
+
} else if (mepSelection === 'fitting') {
|
|
114
|
+
hints.push({ keys: [CLICK], label: 'Click the handle dot to show move + rotate handles' })
|
|
115
|
+
hints.push({ keys: [ROTATE_KEYS], label: 'Rotate ±45°' })
|
|
116
|
+
hints.push({ keys: [ALT_KEY], label: 'Switch the rotation axis (Y → X → Z)' })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The rows are the same whatever modifier is held — guides/snapping are
|
|
120
|
+
// governed by the snapping mode (Shift toggles it), not by the modifier of
|
|
121
|
+
// this gesture, so there are no modifier-specific variants to advertise.
|
|
122
|
+
// Holding Cmd/Ctrl or Shift just lights the selection row.
|
|
123
|
+
if (hasMovableSelection) {
|
|
124
|
+
hints.push({
|
|
125
|
+
keys: [LEFT_CLICK],
|
|
126
|
+
label: 'Drag selected movable object',
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (hasRotatableSelection) {
|
|
131
|
+
hints.push({
|
|
132
|
+
keys: [COMMAND_KEY, RIGHT_CLICK],
|
|
133
|
+
label: 'Drag left or right to rotate selected object',
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
hints.push({
|
|
138
|
+
keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK],
|
|
139
|
+
label: 'Add or remove objects from the selection',
|
|
140
|
+
active: commandPressed || shiftPressed,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
return hints
|
|
144
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type ContinuationContext = 'wall' | 'fence' | 'point' | 'cabinet'
|
|
2
|
+
export type ContinuationMode = string
|
|
3
|
+
|
|
4
|
+
export const CONTINUATION_PROFILES: Record<
|
|
5
|
+
ContinuationContext,
|
|
6
|
+
{
|
|
7
|
+
options: ContinuationMode[]
|
|
8
|
+
default: ContinuationMode
|
|
9
|
+
labels: Record<string, string>
|
|
10
|
+
icons: Record<string, string>
|
|
11
|
+
}
|
|
12
|
+
> = {
|
|
13
|
+
wall: {
|
|
14
|
+
options: ['room', 'single'],
|
|
15
|
+
default: 'room',
|
|
16
|
+
labels: { room: 'Room (auto-close)', single: 'Single wall' },
|
|
17
|
+
icons: { room: 'lucide:square', single: 'lucide:minus' },
|
|
18
|
+
},
|
|
19
|
+
fence: {
|
|
20
|
+
options: ['single', 'continuous', 'curved'],
|
|
21
|
+
default: 'continuous',
|
|
22
|
+
labels: {
|
|
23
|
+
continuous: 'Continuous',
|
|
24
|
+
single: 'Single fence',
|
|
25
|
+
curved: 'Curved fence',
|
|
26
|
+
},
|
|
27
|
+
icons: {
|
|
28
|
+
continuous: 'lucide:waypoints',
|
|
29
|
+
single: 'lucide:minus',
|
|
30
|
+
curved: 'lucide:spline',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
point: {
|
|
34
|
+
options: ['once', 'repeat'],
|
|
35
|
+
default: 'once',
|
|
36
|
+
labels: { once: 'Place once', repeat: 'Place multiple' },
|
|
37
|
+
icons: { once: 'lucide:target', repeat: 'lucide:copy-plus' },
|
|
38
|
+
},
|
|
39
|
+
cabinet: {
|
|
40
|
+
options: ['single', 'continuous'],
|
|
41
|
+
default: 'single',
|
|
42
|
+
labels: { single: 'Single cabinet', continuous: 'Continuous run' },
|
|
43
|
+
icons: { single: 'lucide:minus', continuous: 'lucide:waypoints' },
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const POINT_KINDS = new Set(['item', 'door', 'window', 'shelf', 'column'])
|
|
48
|
+
|
|
49
|
+
export function nextContinuation(
|
|
50
|
+
context: ContinuationContext,
|
|
51
|
+
current: ContinuationMode,
|
|
52
|
+
): ContinuationMode {
|
|
53
|
+
const profile = CONTINUATION_PROFILES[context]
|
|
54
|
+
const index = profile.options.indexOf(current)
|
|
55
|
+
if (index === -1) return profile.default
|
|
56
|
+
return profile.options[(index + 1) % profile.options.length] ?? profile.default
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function continuationContextOf(kind: string): ContinuationContext | null {
|
|
60
|
+
if (kind === 'wall') return 'wall'
|
|
61
|
+
if (kind === 'fence') return 'fence'
|
|
62
|
+
if (kind === 'cabinet') return 'cabinet'
|
|
63
|
+
return POINT_KINDS.has(kind) ? 'point' : null
|
|
64
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeDefinition,
|
|
5
|
+
DEFAULT_ANGLE_STEP,
|
|
6
|
+
nodeRegistry,
|
|
7
|
+
registerNode,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import {
|
|
11
|
+
canDirectMoveNode,
|
|
12
|
+
resolveDirectManipulationNode,
|
|
13
|
+
resolveDirectRotationDragDelta,
|
|
14
|
+
snapDirectRotationDelta,
|
|
15
|
+
} from './direct-manipulation'
|
|
16
|
+
|
|
17
|
+
function registerTestDefinition(kind: string, overrides: Partial<AnyNodeDefinition>) {
|
|
18
|
+
if (nodeRegistry.has(kind)) return
|
|
19
|
+
registerNode({
|
|
20
|
+
kind,
|
|
21
|
+
schemaVersion: 1,
|
|
22
|
+
schema: z.object({ type: z.literal(kind) }) as never,
|
|
23
|
+
category: 'structure',
|
|
24
|
+
defaults: () => ({ type: kind }) as never,
|
|
25
|
+
capabilities: {},
|
|
26
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
27
|
+
...overrides,
|
|
28
|
+
} as AnyNodeDefinition)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('snapDirectRotationDelta', () => {
|
|
32
|
+
test('snaps rotation deltas to the default angle increment', () => {
|
|
33
|
+
expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.49, false)).toBe(0)
|
|
34
|
+
expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.51, false)).toBeCloseTo(
|
|
35
|
+
DEFAULT_ANGLE_STEP,
|
|
36
|
+
)
|
|
37
|
+
expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * -1.49, false)).toBeCloseTo(
|
|
38
|
+
-DEFAULT_ANGLE_STEP,
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('keeps the raw rotation delta while free-rotating', () => {
|
|
43
|
+
const rawDelta = DEFAULT_ANGLE_STEP * 0.42
|
|
44
|
+
expect(snapDirectRotationDelta(rawDelta, true)).toBe(rawDelta)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('resolveDirectRotationDragDelta', () => {
|
|
49
|
+
test('maps horizontal pointer motion to the direct rotation delta direction', () => {
|
|
50
|
+
const radiansPerPixel = DEFAULT_ANGLE_STEP / 12
|
|
51
|
+
|
|
52
|
+
expect(resolveDirectRotationDragDelta(100, 112, radiansPerPixel, false)).toBeCloseTo(
|
|
53
|
+
-DEFAULT_ANGLE_STEP,
|
|
54
|
+
)
|
|
55
|
+
expect(resolveDirectRotationDragDelta(100, 88, radiansPerPixel, false)).toBeCloseTo(
|
|
56
|
+
DEFAULT_ANGLE_STEP,
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('keeps unsnapped drag deltas while free-rotating', () => {
|
|
61
|
+
expect(resolveDirectRotationDragDelta(100, 103, 0.1, true)).toBeCloseTo(-0.3)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('canDirectMoveNode', () => {
|
|
66
|
+
// Accepts kinds with a 3D-mountable move tool (`movable` or
|
|
67
|
+
// `affordanceTools.move`); floorplan-only movers (zone) are excluded.
|
|
68
|
+
test('rejects floorplan-only move targets (no 3D tool mounts)', () => {
|
|
69
|
+
const kind = 'direct-move-floorplan-only-test'
|
|
70
|
+
registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
|
|
71
|
+
|
|
72
|
+
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('rejects MEP kinds that own move through bespoke selection affordances', () => {
|
|
76
|
+
for (const kind of [
|
|
77
|
+
'duct-segment',
|
|
78
|
+
'duct-fitting',
|
|
79
|
+
'pipe-segment',
|
|
80
|
+
'pipe-fitting',
|
|
81
|
+
'lineset',
|
|
82
|
+
'liquid-line',
|
|
83
|
+
]) {
|
|
84
|
+
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('accepts kinds with a bespoke move tool', () => {
|
|
89
|
+
const kind = 'direct-move-bespoke-tool-test'
|
|
90
|
+
registerTestDefinition(kind, {
|
|
91
|
+
affordanceTools: {
|
|
92
|
+
move: async () => ({ default: () => null }),
|
|
93
|
+
} as never,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('accepts nodes with the generic movable capability', () => {
|
|
100
|
+
const kind = 'direct-move-movable-test'
|
|
101
|
+
registerTestDefinition(kind, {
|
|
102
|
+
capabilities: {
|
|
103
|
+
movable: { axes: ['x', 'z'], gridSnap: true },
|
|
104
|
+
},
|
|
105
|
+
} as Partial<AnyNodeDefinition>)
|
|
106
|
+
|
|
107
|
+
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('rejects kinds with no registered move path', () => {
|
|
111
|
+
const kind = 'direct-move-none-test'
|
|
112
|
+
registerTestDefinition(kind, {})
|
|
113
|
+
|
|
114
|
+
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('resolveDirectManipulationNode', () => {
|
|
119
|
+
test('routes proxied members to their assembly for direct transforms', () => {
|
|
120
|
+
const group = {
|
|
121
|
+
id: 'direct_manipulation_group',
|
|
122
|
+
type: 'direct-manipulation-group-test',
|
|
123
|
+
} as unknown as AnyNode
|
|
124
|
+
const member = {
|
|
125
|
+
id: 'direct_manipulation_member',
|
|
126
|
+
type: 'direct-manipulation-member-test',
|
|
127
|
+
metadata: { nodeSelectionProxyId: group.id },
|
|
128
|
+
} as unknown as AnyNode
|
|
129
|
+
|
|
130
|
+
expect(
|
|
131
|
+
resolveDirectManipulationNode(member, {
|
|
132
|
+
[group.id]: group,
|
|
133
|
+
[member.id]: member,
|
|
134
|
+
}),
|
|
135
|
+
).toBe(group)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('falls back to the selected node when the proxy target is missing', () => {
|
|
139
|
+
const member = {
|
|
140
|
+
id: 'direct_manipulation_orphan_member',
|
|
141
|
+
type: 'direct-manipulation-member-test',
|
|
142
|
+
metadata: { nodeSelectionProxyId: 'missing_group' },
|
|
143
|
+
} as unknown as AnyNode
|
|
144
|
+
|
|
145
|
+
expect(resolveDirectManipulationNode(member, { [member.id]: member })).toBe(member)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('routes parent-frame children to their rotatable parent', () => {
|
|
149
|
+
const parentKind = 'direct-manipulation-parent-frame-parent-test'
|
|
150
|
+
const childKind = 'direct-manipulation-parent-frame-child-test'
|
|
151
|
+
registerTestDefinition(parentKind, {
|
|
152
|
+
capabilities: { rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] } },
|
|
153
|
+
})
|
|
154
|
+
registerTestDefinition(childKind, {
|
|
155
|
+
capabilities: {
|
|
156
|
+
movable: {
|
|
157
|
+
axes: ['x', 'z'],
|
|
158
|
+
gridSnap: true,
|
|
159
|
+
parentFrame: {
|
|
160
|
+
resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
|
|
161
|
+
(node.parentId ? nodes[node.parentId] : null) ?? null,
|
|
162
|
+
parentRotationY: () => 0,
|
|
163
|
+
localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
|
|
164
|
+
local[0],
|
|
165
|
+
local[1],
|
|
166
|
+
local[2],
|
|
167
|
+
],
|
|
168
|
+
planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
|
|
169
|
+
planX,
|
|
170
|
+
localY,
|
|
171
|
+
planZ,
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const parent = { id: 'direct_manipulation_parent', type: parentKind } as unknown as AnyNode
|
|
179
|
+
const child = {
|
|
180
|
+
id: 'direct_manipulation_child',
|
|
181
|
+
type: childKind,
|
|
182
|
+
parentId: parent.id,
|
|
183
|
+
} as unknown as AnyNode
|
|
184
|
+
|
|
185
|
+
expect(
|
|
186
|
+
resolveDirectManipulationNode(child, {
|
|
187
|
+
[parent.id]: parent,
|
|
188
|
+
[child.id]: child,
|
|
189
|
+
}),
|
|
190
|
+
).toBe(parent)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type ArcResizeHandle,
|
|
4
|
+
createSceneApi,
|
|
5
|
+
DEFAULT_ANGLE_STEP,
|
|
6
|
+
type HandleDescriptor,
|
|
7
|
+
hasRegistry3DMoveTool,
|
|
8
|
+
isMovable,
|
|
9
|
+
nodeRegistry,
|
|
10
|
+
resolveSelectionProxyId,
|
|
11
|
+
type SceneApi,
|
|
12
|
+
useScene,
|
|
13
|
+
} from '@pascal-app/core'
|
|
14
|
+
|
|
15
|
+
function resolveHandles(node: AnyNode): HandleDescriptor<AnyNode>[] {
|
|
16
|
+
const handles = nodeRegistry.get(node.type)?.handles
|
|
17
|
+
if (!handles) return []
|
|
18
|
+
return (
|
|
19
|
+
typeof handles === 'function' ? handles(node as never) : handles
|
|
20
|
+
) as HandleDescriptor<AnyNode>[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getDirectRotateHandle(node: AnyNode): ArcResizeHandle<AnyNode> | null {
|
|
24
|
+
for (const handle of resolveHandles(node)) {
|
|
25
|
+
if (handle.kind === 'arc-resize' && handle.shape === 'rotate') {
|
|
26
|
+
return handle as ArcResizeHandle<AnyNode>
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function canDirectRotateNode(node: AnyNode): boolean {
|
|
33
|
+
return (
|
|
34
|
+
getDirectRotateHandle(node) !== null ||
|
|
35
|
+
nodeRegistry.get(node.type)?.capabilities?.rotatable !== undefined
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const BESPOKE_SELECTION_MOVE_KINDS = new Set([
|
|
40
|
+
'duct-segment',
|
|
41
|
+
'duct-fitting',
|
|
42
|
+
'pipe-segment',
|
|
43
|
+
'pipe-fitting',
|
|
44
|
+
'lineset',
|
|
45
|
+
'liquid-line',
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
export function canDirectMoveNode(node: AnyNode): boolean {
|
|
49
|
+
// These MEP kinds own move through bespoke selection rigs (latch cubes,
|
|
50
|
+
// directional arrows, grid-driven previews). Sending body drags/clicks
|
|
51
|
+
// through the generic direct-move handoff conflicts with that path and can
|
|
52
|
+
// leave the editor appearing frozen while their mover waits for the wrong
|
|
53
|
+
// gesture stream.
|
|
54
|
+
if (BESPOKE_SELECTION_MOVE_KINDS.has(node.type)) return false
|
|
55
|
+
// 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that
|
|
56
|
+
// mounts in 3D — distinct from `isRegistryMovable`, which also accepts
|
|
57
|
+
// floorplan-only movers (zone) for the 2D plan.
|
|
58
|
+
if (!hasRegistry3DMoveTool(node.type)) return false
|
|
59
|
+
// Bespoke movers (`affordanceTools.move`) own their constraints and often
|
|
60
|
+
// deliberately omit `capabilities.movable` — only gate registry-movable
|
|
61
|
+
// kinds on `isMovable`, so a per-node `movable.override` (e.g. a cabinet
|
|
62
|
+
// run locked behind its selection proxy) can opt out of direct move.
|
|
63
|
+
if (nodeRegistry.get(node.type)?.affordanceTools?.move) return true
|
|
64
|
+
return isMovable(node)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resolveDirectManipulationNode(
|
|
68
|
+
node: AnyNode,
|
|
69
|
+
nodes: Readonly<Record<string, AnyNode | undefined>>,
|
|
70
|
+
): AnyNode {
|
|
71
|
+
const target = nodes[resolveSelectionProxyId(node, nodes)] ?? node
|
|
72
|
+
const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
|
|
73
|
+
const parent = parentFrame?.resolveParent(target, nodes as Readonly<Record<string, AnyNode>>)
|
|
74
|
+
return parent && canDirectRotateNode(parent) ? parent : target
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function snapDirectRotationDelta(delta: number, free: boolean): number {
|
|
78
|
+
return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resolveDirectRotationDragDelta(
|
|
82
|
+
startX: number,
|
|
83
|
+
clientX: number,
|
|
84
|
+
radiansPerPixel: number,
|
|
85
|
+
free: boolean,
|
|
86
|
+
): number {
|
|
87
|
+
return snapDirectRotationDelta((startX - clientX) * radiansPerPixel, free)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resolveDirectRotationPatch(
|
|
91
|
+
node: AnyNode,
|
|
92
|
+
delta: number,
|
|
93
|
+
sceneApi: SceneApi = createSceneApi(useScene),
|
|
94
|
+
): Partial<AnyNode> | null {
|
|
95
|
+
const rotateHandle = getDirectRotateHandle(node)
|
|
96
|
+
if (rotateHandle) {
|
|
97
|
+
return rotateHandle.apply(node, delta, sceneApi) as Partial<AnyNode>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rotation = (node as { rotation?: unknown }).rotation
|
|
101
|
+
if (typeof rotation === 'number') {
|
|
102
|
+
return { rotation: rotation - delta } as Partial<AnyNode>
|
|
103
|
+
}
|
|
104
|
+
if (Array.isArray(rotation)) {
|
|
105
|
+
const [rx = 0, ry = 0, rz = 0] = rotation as [number?, number?, number?]
|
|
106
|
+
return { rotation: [rx, ry - delta, rz] } as Partial<AnyNode>
|
|
107
|
+
}
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AnyNode, EditorApi } from '@pascal-app/core'
|
|
2
|
+
import useEditor from '../store/use-editor'
|
|
3
|
+
import useInteractionScope from '../store/use-interaction-scope'
|
|
4
|
+
import {
|
|
5
|
+
controlPointReshapeScope,
|
|
6
|
+
endpointReshapeScope,
|
|
7
|
+
tangentReshapeScope,
|
|
8
|
+
} from './interaction/scope'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Concrete {@link EditorApi} backed by `useEditor` + the interaction scope.
|
|
12
|
+
* Descriptors call into editor state through this interface; the editor owns
|
|
13
|
+
* the actual store wiring so core stays decoupled.
|
|
14
|
+
*
|
|
15
|
+
* `engageMove` no longer clears any in-progress endpoint drag or curve gesture:
|
|
16
|
+
* `setMovingNode` begins the `moving` scope, and the scope is single-owner, so
|
|
17
|
+
* it atomically replaces any prior reshape — there is no separate flag to reset.
|
|
18
|
+
*/
|
|
19
|
+
export function createEditorApi(): EditorApi {
|
|
20
|
+
return {
|
|
21
|
+
engageMove(node: AnyNode) {
|
|
22
|
+
const editor = useEditor.getState()
|
|
23
|
+
editor.setPlacementDragMode(false)
|
|
24
|
+
// `setMovingNode` is typed against a narrower union than `AnyNode`
|
|
25
|
+
// (every concrete kind enumerated). Descriptors pass any node; the
|
|
26
|
+
// cast lets registry-driven move kinds through without forcing a
|
|
27
|
+
// schema-level type widening.
|
|
28
|
+
editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
|
|
29
|
+
},
|
|
30
|
+
engageMoveDrag(node: AnyNode) {
|
|
31
|
+
const editor = useEditor.getState()
|
|
32
|
+
// Flag drag mode BEFORE mounting the move tool so the coordinator reads
|
|
33
|
+
// it at setup and wires its commit-on-release listener.
|
|
34
|
+
editor.setPlacementDragMode(true)
|
|
35
|
+
editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
|
|
36
|
+
},
|
|
37
|
+
engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
|
|
38
|
+
// Endpoint reshape is kind-agnostic: the scope carries the node id + which
|
|
39
|
+
// endpoint, and consumers recover the node from the scene. Adding a new
|
|
40
|
+
// endpoint-draggable kind needs no entry here.
|
|
41
|
+
useInteractionScope.getState().begin(endpointReshapeScope(node.id, endpoint))
|
|
42
|
+
},
|
|
43
|
+
engageControlPointMove(node: AnyNode, index: number) {
|
|
44
|
+
useInteractionScope.getState().begin(controlPointReshapeScope(node.id, index))
|
|
45
|
+
},
|
|
46
|
+
engageTangentMove(node: AnyNode, index: number, side: 'in' | 'out') {
|
|
47
|
+
useInteractionScope.getState().begin(tangentReshapeScope(node.id, index, side))
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|