@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,124 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
type FakeContext = { id: string }
|
|
4
|
+
|
|
5
|
+
let activeContext: FakeContext = { id: 'first' }
|
|
6
|
+
let throwOnPlay = false
|
|
7
|
+
const instances: FakeHowl[] = []
|
|
8
|
+
|
|
9
|
+
class FakeHowl {
|
|
10
|
+
stateValue: 'loaded' | 'unloaded' = 'loaded'
|
|
11
|
+
unloadCount = 0
|
|
12
|
+
playCount = 0
|
|
13
|
+
|
|
14
|
+
constructor(_options: unknown) {
|
|
15
|
+
instances.push(this)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
play() {
|
|
19
|
+
this.playCount++
|
|
20
|
+
if (throwOnPlay) throw new DOMException('stale graph', 'InvalidAccessError')
|
|
21
|
+
return 1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
volume() {
|
|
25
|
+
return this
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
rate() {
|
|
29
|
+
return this
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
state() {
|
|
33
|
+
return this.stateValue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
unload() {
|
|
37
|
+
this.unloadCount++
|
|
38
|
+
this.stateValue = 'unloaded'
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fakeHowler = {
|
|
44
|
+
get ctx() {
|
|
45
|
+
return activeContext
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
mock.module('howler', () => ({ Howl: FakeHowl, Howler: fakeHowler }))
|
|
50
|
+
mock.module('../store/use-audio', () => ({
|
|
51
|
+
default: {
|
|
52
|
+
getState: () => ({ masterVolume: 100, muted: false, sfxVolume: 100 }),
|
|
53
|
+
},
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
const { disposeSFX, playSFX, preloadSFX } = await import('./sfx-player')
|
|
57
|
+
const { disposeSFXBus, initSFXBus, triggerSFX } = await import('./sfx-bus')
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
disposeSFXBus()
|
|
61
|
+
activeContext = { id: 'first' }
|
|
62
|
+
throwOnPlay = false
|
|
63
|
+
instances.length = 0
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
disposeSFXBus()
|
|
68
|
+
mock.restore()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('SFX audio context lifecycle', () => {
|
|
72
|
+
test('reuses the preloaded cache while the Howler context is unchanged', () => {
|
|
73
|
+
preloadSFX()
|
|
74
|
+
const initialCount = instances.length
|
|
75
|
+
|
|
76
|
+
playSFX('itemDelete')
|
|
77
|
+
|
|
78
|
+
expect(instances).toHaveLength(initialCount)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('rebuilds every cached Howl before playing against a new context', () => {
|
|
82
|
+
preloadSFX()
|
|
83
|
+
const oldSounds = [...instances]
|
|
84
|
+
const initialCount = instances.length
|
|
85
|
+
activeContext = { id: 'second' }
|
|
86
|
+
|
|
87
|
+
playSFX('itemDelete')
|
|
88
|
+
|
|
89
|
+
expect(oldSounds.every((sound) => sound.unloadCount === 1)).toBe(true)
|
|
90
|
+
expect(instances.length).toBe(initialCount * 2)
|
|
91
|
+
expect(instances.slice(initialCount).some((sound) => sound.playCount === 1)).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('contains a stale graph failure and backs off instead of rebuilding per cue', () => {
|
|
95
|
+
preloadSFX()
|
|
96
|
+
const initialCount = instances.length
|
|
97
|
+
throwOnPlay = true
|
|
98
|
+
|
|
99
|
+
expect(() => playSFX('menuHover')).not.toThrow()
|
|
100
|
+
expect(instances.slice(0, initialCount).every((sound) => sound.unloadCount === 1)).toBe(true)
|
|
101
|
+
|
|
102
|
+
throwOnPlay = false
|
|
103
|
+
playSFX('menuHover')
|
|
104
|
+
expect(instances.length).toBe(initialCount)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('disposes idempotently and recreates sounds after remount', () => {
|
|
108
|
+
preloadSFX()
|
|
109
|
+
const initialCount = instances.length
|
|
110
|
+
|
|
111
|
+
disposeSFX()
|
|
112
|
+
disposeSFX()
|
|
113
|
+
playSFX('itemDelete')
|
|
114
|
+
|
|
115
|
+
expect(instances.length).toBe(initialCount * 2)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('does not let an emitted SFX failure escape an editor callback', () => {
|
|
119
|
+
initSFXBus()
|
|
120
|
+
throwOnPlay = true
|
|
121
|
+
|
|
122
|
+
expect(() => triggerSFX('sfx:item-delete')).not.toThrow()
|
|
123
|
+
})
|
|
124
|
+
})
|
package/src/lib/sfx-player.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { Howl } from 'howler'
|
|
1
|
+
import { Howl, Howler } from 'howler'
|
|
2
2
|
import useAudio from '../store/use-audio'
|
|
3
3
|
|
|
4
4
|
// Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
|
|
5
5
|
// so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones — enough to kill the
|
|
6
6
|
// machine-gun feeling when the same SFX fires in rapid succession.
|
|
7
7
|
type SFXConfig = {
|
|
8
|
-
|
|
8
|
+
// One file, or several pre-rendered variations cycled round-robin per play.
|
|
9
|
+
src: string | string[]
|
|
9
10
|
// Random playback-rate range applied per play (1 = unchanged).
|
|
10
11
|
rateRange?: [number, number]
|
|
11
12
|
// Random volume multiplier range applied per play (1 = unchanged).
|
|
@@ -13,62 +14,96 @@ type SFXConfig = {
|
|
|
13
14
|
// Minimum gap between two plays of this SFX. Triggers within this window
|
|
14
15
|
// are silently dropped so bursty sequences don't phase-stack into noise.
|
|
15
16
|
minIntervalMs?: number
|
|
16
|
-
// Random stereo pan per play — max absolute offset (0 = center, 1 = hard
|
|
17
|
-
// right). A small value like 0.15 keeps things centred but adds just enough
|
|
18
|
-
// spread to stop repeats from stacking on the same point in the field.
|
|
19
|
-
panJitter?: number
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
const DEFAULT_MIN_INTERVAL_MS = 30
|
|
20
|
+
const SFX_FAILURE_BACKOFF_MS = 5_000
|
|
23
21
|
|
|
24
22
|
// SFX sound definitions
|
|
25
23
|
export const SFX: Record<string, SFXConfig> = {
|
|
26
24
|
gridSnap: {
|
|
27
|
-
src:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
src: [
|
|
26
|
+
'/audios/sfx/grid_snap_0.mp3',
|
|
27
|
+
'/audios/sfx/grid_snap_1.mp3',
|
|
28
|
+
'/audios/sfx/grid_snap_2.mp3',
|
|
29
|
+
],
|
|
30
|
+
rateRange: [0.98, 1.02],
|
|
31
|
+
volumeRange: [0.5, 0.6],
|
|
32
|
+
minIntervalMs: 50,
|
|
31
33
|
},
|
|
32
34
|
itemDelete: {
|
|
33
35
|
src: '/audios/sfx/item_delete.mp3',
|
|
34
36
|
rateRange: [0.9, 1.1],
|
|
35
37
|
volumeRange: [0.9, 1.0],
|
|
36
|
-
panJitter: 0.15,
|
|
37
38
|
},
|
|
38
39
|
itemPick: {
|
|
39
40
|
src: '/audios/sfx/item_pick.mp3',
|
|
40
|
-
rateRange: [0.
|
|
41
|
+
rateRange: [0.95, 1.05],
|
|
41
42
|
volumeRange: [0.92, 1.0],
|
|
42
|
-
panJitter: 0.15,
|
|
43
43
|
},
|
|
44
44
|
itemPlace: {
|
|
45
45
|
src: '/audios/sfx/item_place.mp3',
|
|
46
|
-
rateRange: [0.98, 1.
|
|
46
|
+
rateRange: [0.98, 1.02],
|
|
47
47
|
volumeRange: [0.9, 1.0],
|
|
48
|
-
panJitter: 0.15,
|
|
49
48
|
},
|
|
50
49
|
itemRotate: {
|
|
51
50
|
src: '/audios/sfx/item_rotate.mp3',
|
|
52
51
|
rateRange: [0.94, 1.06],
|
|
53
52
|
volumeRange: [0.92, 1.0],
|
|
54
|
-
panJitter: 0.15,
|
|
55
53
|
},
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
// Ticks as a resize handle is dragged across snap steps. Fires in rapid
|
|
55
|
+
// succession, so it mirrors gridSnap: three variations cycled round-robin
|
|
56
|
+
// with pitch jitter and a gap so the run reads as texture, not a tone.
|
|
57
|
+
resize: {
|
|
58
|
+
src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'],
|
|
59
|
+
rateRange: [0.98, 1.02],
|
|
60
|
+
volumeRange: [0.26, 0.34],
|
|
61
|
+
minIntervalMs: 80,
|
|
62
|
+
},
|
|
63
|
+
// Fired when a structure draft begins (first click of a wall/slab/etc).
|
|
64
|
+
structureBuildStart: {
|
|
65
|
+
src: '/audios/sfx/structure_build_start.mp3',
|
|
66
|
+
rateRange: [0.95, 1.05],
|
|
67
|
+
volumeRange: [0.88, 1.0],
|
|
68
|
+
},
|
|
69
|
+
// Fired when a structure is committed (segment placed / polygon closed).
|
|
70
|
+
structureBuildEnd: {
|
|
71
|
+
src: '/audios/sfx/structure_build_end.mp3',
|
|
58
72
|
rateRange: [0.95, 1.05],
|
|
59
73
|
volumeRange: [0.88, 1.0],
|
|
60
|
-
panJitter: 0.15,
|
|
61
74
|
},
|
|
62
75
|
structureDelete: {
|
|
63
76
|
src: '/audios/sfx/structure_delete.mp3',
|
|
64
77
|
rateRange: [0.9, 1.1],
|
|
65
78
|
volumeRange: [0.9, 1.0],
|
|
66
|
-
panJitter: 0.15,
|
|
67
79
|
},
|
|
68
80
|
snapshotCapture: {
|
|
69
81
|
// Shutter should sound consistent — no variation.
|
|
70
82
|
src: '/audios/sfx/snapshot_capture.mp3',
|
|
71
83
|
},
|
|
84
|
+
// Soft tick when hovering a main category in the Build / Items panels.
|
|
85
|
+
// Kept quiet and rate-locked so sweeping across the grid reads as texture,
|
|
86
|
+
// not a melody.
|
|
87
|
+
menuHover: {
|
|
88
|
+
src: '/audios/sfx/menu_hover.mp3',
|
|
89
|
+
rateRange: [0.98, 1.02],
|
|
90
|
+
volumeRange: [0.2, 0.3],
|
|
91
|
+
minIntervalMs: 0,
|
|
92
|
+
},
|
|
93
|
+
// Fired when a main category in the Build / Items panels is clicked.
|
|
94
|
+
menuClick: {
|
|
95
|
+
src: '/audios/sfx/menu_click.mp3',
|
|
96
|
+
rateRange: [0.98, 1.02],
|
|
97
|
+
volumeRange: [0.5, 0.6],
|
|
98
|
+
},
|
|
99
|
+
// Fired when a material is applied to a surface in paint mode. Painting can
|
|
100
|
+
// fire in quick succession across faces, so keep variation + a small gap.
|
|
101
|
+
paintApply: {
|
|
102
|
+
src: '/audios/sfx/paint_apply.mp3',
|
|
103
|
+
rateRange: [0.95, 1.05],
|
|
104
|
+
volumeRange: [0.85, 1.0],
|
|
105
|
+
minIntervalMs: 60,
|
|
106
|
+
},
|
|
72
107
|
} as const
|
|
73
108
|
|
|
74
109
|
export type SFXName = keyof typeof SFX
|
|
@@ -77,56 +112,105 @@ function randomInRange([min, max]: [number, number]): number {
|
|
|
77
112
|
return min + Math.random() * (max - min)
|
|
78
113
|
}
|
|
79
114
|
|
|
80
|
-
|
|
81
|
-
|
|
115
|
+
let sfxCache = new Map<SFXName, Howl[]>()
|
|
116
|
+
let sfxAudioContext: AudioContext | null = null
|
|
117
|
+
let sfxRetryAfter = 0
|
|
82
118
|
const lastPlayedAt = new Map<SFXName, number>()
|
|
119
|
+
const lastVariation = new Map<SFXName, number>()
|
|
83
120
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
121
|
+
function unloadCachedSounds(resetPlaybackState: boolean) {
|
|
122
|
+
for (const sounds of sfxCache.values()) {
|
|
123
|
+
for (const sound of sounds) {
|
|
124
|
+
try {
|
|
125
|
+
sound.unload()
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
sfxCache.clear()
|
|
130
|
+
sfxAudioContext = null
|
|
131
|
+
if (resetPlaybackState) {
|
|
132
|
+
sfxRetryAfter = 0
|
|
133
|
+
lastPlayedAt.clear()
|
|
134
|
+
lastVariation.clear()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function cacheNeedsRebuild(): boolean {
|
|
139
|
+
if (sfxCache.size === 0) return true
|
|
140
|
+
if (sfxAudioContext !== Howler.ctx) return true
|
|
141
|
+
for (const sounds of sfxCache.values()) {
|
|
142
|
+
if (sounds.some((sound) => sound.state() === 'unloaded')) return true
|
|
143
|
+
}
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function preloadSFX() {
|
|
148
|
+
if (!cacheNeedsRebuild()) return
|
|
149
|
+
unloadCachedSounds(false)
|
|
150
|
+
|
|
151
|
+
for (const [name, config] of Object.entries(SFX)) {
|
|
152
|
+
const sources = Array.isArray(config.src) ? config.src : [config.src]
|
|
153
|
+
sfxCache.set(
|
|
154
|
+
name as SFXName,
|
|
155
|
+
sources.map(
|
|
156
|
+
(src) =>
|
|
157
|
+
new Howl({
|
|
158
|
+
src: [src],
|
|
159
|
+
preload: true,
|
|
160
|
+
volume: 0.5,
|
|
161
|
+
}),
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
sfxAudioContext = Howler.ctx ?? null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function disposeSFX() {
|
|
169
|
+
unloadCachedSounds(true)
|
|
170
|
+
}
|
|
93
171
|
|
|
94
172
|
/**
|
|
95
173
|
* Play a sound effect with volume based on audio settings
|
|
96
174
|
*/
|
|
97
175
|
export function playSFX(name: SFXName) {
|
|
98
|
-
const sound = sfxCache.get(name)
|
|
99
|
-
if (!sound) {
|
|
100
|
-
console.warn(`SFX not found: ${name}`)
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
176
|
const config = SFX[name]!
|
|
177
|
+
const { masterVolume, sfxVolume, muted } = useAudio.getState()
|
|
178
|
+
|
|
179
|
+
if (muted) return
|
|
104
180
|
|
|
105
181
|
// Drop rapid repeats — two plays of the same SFX within minIntervalMs just
|
|
106
182
|
// smear into noise, they don't add useful information.
|
|
107
183
|
const now = performance.now()
|
|
184
|
+
if (now < sfxRetryAfter) return
|
|
108
185
|
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
|
|
109
186
|
const last = lastPlayedAt.get(name)
|
|
110
187
|
if (last !== undefined && now - last < minInterval) return
|
|
111
188
|
lastPlayedAt.set(name, now)
|
|
112
189
|
|
|
113
|
-
|
|
190
|
+
try {
|
|
191
|
+
preloadSFX()
|
|
192
|
+
const sounds = sfxCache.get(name)
|
|
193
|
+
if (!sounds || sounds.length === 0) return
|
|
114
194
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
195
|
+
// Pick a random variation, avoiding an immediate repeat of the last one so
|
|
196
|
+
// consecutive plays don't land on the same file.
|
|
197
|
+
let index = Math.floor(Math.random() * sounds.length)
|
|
198
|
+
if (sounds.length > 1 && index === lastVariation.get(name)) {
|
|
199
|
+
index = (index + 1) % sounds.length
|
|
200
|
+
}
|
|
201
|
+
lastVariation.set(name, index)
|
|
202
|
+
const sound = sounds[index]!
|
|
203
|
+
const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
|
|
204
|
+
const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
|
|
205
|
+
const rate = config.rateRange ? randomInRange(config.rateRange) : 1
|
|
206
|
+
const id = sound.play()
|
|
207
|
+
sound.volume(baseVolume * volumeJitter, id)
|
|
208
|
+
if (rate !== 1) sound.rate(rate, id)
|
|
209
|
+
} catch {
|
|
210
|
+
// Optional audio must never abort an editor input callback. Rebuild from
|
|
211
|
+
// the current Howler context after a backoff instead of retrying every pointer cue.
|
|
212
|
+
unloadCachedSounds(false)
|
|
213
|
+
sfxRetryAfter = now + SFX_FAILURE_BACKOFF_MS
|
|
130
214
|
}
|
|
131
215
|
}
|
|
132
216
|
|
|
@@ -137,7 +221,16 @@ export function updateSFXVolumes() {
|
|
|
137
221
|
const { masterVolume, sfxVolume } = useAudio.getState()
|
|
138
222
|
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
|
|
139
223
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
224
|
+
try {
|
|
225
|
+
if (performance.now() < sfxRetryAfter) return
|
|
226
|
+
preloadSFX()
|
|
227
|
+
sfxCache.forEach((sounds) => {
|
|
228
|
+
sounds.forEach((sound) => {
|
|
229
|
+
sound.volume(finalVolume)
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
} catch {
|
|
233
|
+
unloadCachedSounds(false)
|
|
234
|
+
sfxRetryAfter = performance.now() + SFX_FAILURE_BACKOFF_MS
|
|
235
|
+
}
|
|
143
236
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary'
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { type AnyNode, WallNode } from '@pascal-app/core'
|
|
3
|
+
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
|
|
4
|
+
import { resolveSlabEdgeBandSnap } from './slab-plan-snap'
|
|
5
|
+
|
|
6
|
+
function sceneWithWall(thickness = 0.1) {
|
|
7
|
+
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness })
|
|
8
|
+
const nodes: Record<string, AnyNode> = { [wall.id]: wall }
|
|
9
|
+
return { wall, nodes }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('resolveSlabEdgeBandSnap', () => {
|
|
13
|
+
test('magnetic: an edge inside the wall band sticks to the centerline and shows the beacon', () => {
|
|
14
|
+
const { wall, nodes } = sceneWithWall()
|
|
15
|
+
useWallSnapIndicator.getState().clear()
|
|
16
|
+
|
|
17
|
+
const snap = resolveSlabEdgeBandSnap({
|
|
18
|
+
edge: [
|
|
19
|
+
[0.5, 0.08],
|
|
20
|
+
[3.5, 0.08],
|
|
21
|
+
],
|
|
22
|
+
nodes,
|
|
23
|
+
referencePoint: [1, 0.08],
|
|
24
|
+
magnetic: true,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(snap).not.toBeNull()
|
|
28
|
+
expect(snap!.wallId).toBe(wall.id)
|
|
29
|
+
expect(snap!.edge[0][1]).toBeCloseTo(0)
|
|
30
|
+
expect(snap!.edge[1][1]).toBeCloseTo(0)
|
|
31
|
+
|
|
32
|
+
const beacon = useWallSnapIndicator.getState().point
|
|
33
|
+
expect(beacon).not.toBeNull()
|
|
34
|
+
expect(beacon!.kind).toBe('wall')
|
|
35
|
+
expect(beacon!.wallIds).toEqual([wall.id])
|
|
36
|
+
// Beacon hugs the reference point projected onto the snapped edge.
|
|
37
|
+
expect(beacon!.x).toBeCloseTo(1)
|
|
38
|
+
expect(beacon!.z).toBeCloseTo(0)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('non-magnetic: only the tight connect stick remains', () => {
|
|
42
|
+
const { nodes } = sceneWithWall()
|
|
43
|
+
|
|
44
|
+
// 8cm off the centerline: inside the band but beyond the connect radius.
|
|
45
|
+
expect(
|
|
46
|
+
resolveSlabEdgeBandSnap({
|
|
47
|
+
edge: [
|
|
48
|
+
[0.5, 0.08],
|
|
49
|
+
[3.5, 0.08],
|
|
50
|
+
],
|
|
51
|
+
nodes,
|
|
52
|
+
magnetic: false,
|
|
53
|
+
}),
|
|
54
|
+
).toBeNull()
|
|
55
|
+
expect(useWallSnapIndicator.getState().point).toBeNull()
|
|
56
|
+
|
|
57
|
+
// 4cm: genuinely dropped on the wall — still sticks.
|
|
58
|
+
expect(
|
|
59
|
+
resolveSlabEdgeBandSnap({
|
|
60
|
+
edge: [
|
|
61
|
+
[0.5, 0.04],
|
|
62
|
+
[3.5, 0.04],
|
|
63
|
+
],
|
|
64
|
+
nodes,
|
|
65
|
+
magnetic: false,
|
|
66
|
+
}),
|
|
67
|
+
).not.toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('clears the beacon when the edge leaves every band', () => {
|
|
71
|
+
const { nodes } = sceneWithWall()
|
|
72
|
+
|
|
73
|
+
resolveSlabEdgeBandSnap({
|
|
74
|
+
edge: [
|
|
75
|
+
[0.5, 0.05],
|
|
76
|
+
[3.5, 0.05],
|
|
77
|
+
],
|
|
78
|
+
nodes,
|
|
79
|
+
magnetic: true,
|
|
80
|
+
})
|
|
81
|
+
expect(useWallSnapIndicator.getState().point).not.toBeNull()
|
|
82
|
+
|
|
83
|
+
resolveSlabEdgeBandSnap({
|
|
84
|
+
edge: [
|
|
85
|
+
[0.5, 1.5],
|
|
86
|
+
[3.5, 1.5],
|
|
87
|
+
],
|
|
88
|
+
nodes,
|
|
89
|
+
magnetic: true,
|
|
90
|
+
})
|
|
91
|
+
expect(useWallSnapIndicator.getState().point).toBeNull()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { type AnyNode, snapSlabEdgeToWallBand, useScene } from '@pascal-app/core'
|
|
2
|
+
import { WALL_CONNECT_SNAP_RADIUS } from '../components/tools/wall/wall-drafting'
|
|
3
|
+
import useAlignmentGuides from '../store/use-alignment-guides'
|
|
4
|
+
import { isMagneticSnapActive } from '../store/use-editor'
|
|
5
|
+
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
|
|
6
|
+
import {
|
|
7
|
+
clearSurfacePlanSnapFeedback,
|
|
8
|
+
getLevelWalls,
|
|
9
|
+
resolveSurfacePlanPointSnap,
|
|
10
|
+
SURFACE_ALIGNMENT_THRESHOLD_M,
|
|
11
|
+
type SurfacePlanSnapInput,
|
|
12
|
+
type SurfacePlanSnapResult,
|
|
13
|
+
} from './surface-plan-snap'
|
|
14
|
+
|
|
15
|
+
const SLAB_SNAP_MOVING_ID = '__slab_snap__'
|
|
16
|
+
|
|
17
|
+
export const SLAB_ALIGNMENT_THRESHOLD_M = SURFACE_ALIGNMENT_THRESHOLD_M
|
|
18
|
+
export type SlabPlanSnapInput = SurfacePlanSnapInput
|
|
19
|
+
export type SlabPlanSnapResult = SurfacePlanSnapResult
|
|
20
|
+
|
|
21
|
+
export function clearSlabSnapFeedback() {
|
|
22
|
+
clearSurfacePlanSnapFeedback()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveSlabPlanPointSnap(input: SlabPlanSnapInput): SlabPlanSnapResult {
|
|
26
|
+
return resolveSurfacePlanPointSnap({
|
|
27
|
+
...input,
|
|
28
|
+
highlightWalls: input.highlightWalls ?? false,
|
|
29
|
+
movingId: input.movingId ?? SLAB_SNAP_MOVING_ID,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type SlabEdgeBandSnapInput = {
|
|
34
|
+
/** Candidate edge endpoints after the raw/grid perpendicular translation. */
|
|
35
|
+
edge: [[number, number], [number, number]]
|
|
36
|
+
levelId?: string | null
|
|
37
|
+
nodes?: Readonly<Record<string, AnyNode>>
|
|
38
|
+
/**
|
|
39
|
+
* Plan point (typically the cursor) the snap beacon should hug along
|
|
40
|
+
* the wall. Falls back to the snapped edge's midpoint.
|
|
41
|
+
*/
|
|
42
|
+
referencePoint?: [number, number]
|
|
43
|
+
/** Override the mode-driven magnetic gate (tests). */
|
|
44
|
+
magnetic?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SlabEdgeBandSnapResult = {
|
|
48
|
+
/** The candidate edge translated onto the wall centerline. */
|
|
49
|
+
edge: [[number, number], [number, number]]
|
|
50
|
+
wallId: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Edge-level slab reshape snap against wall footprint bands. Unlike the
|
|
55
|
+
* cursor-based `resolveSlabPlanPointSnap`, this tests the DRAGGED EDGE
|
|
56
|
+
* itself (band adoption, span overlap — same permissiveness as the
|
|
57
|
+
* render rule) and sticks it onto the wall CENTERLINE, the canonical
|
|
58
|
+
* stored position; the render rule then places it flush with the face.
|
|
59
|
+
* The beacon, the live preview and the committed polygon therefore all
|
|
60
|
+
* agree. In non-magnetic modes only a tight connect-radius stick
|
|
61
|
+
* remains, mirroring the sanctioned wall connect snap. Publishes /
|
|
62
|
+
* clears the wall-snap beacon as a side effect.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveSlabEdgeBandSnap(
|
|
65
|
+
input: SlabEdgeBandSnapInput,
|
|
66
|
+
): SlabEdgeBandSnapResult | null {
|
|
67
|
+
const nodes = input.nodes ?? useScene.getState().nodes
|
|
68
|
+
const walls = getLevelWalls(nodes, input.levelId)
|
|
69
|
+
const magnetic = input.magnetic ?? isMagneticSnapActive()
|
|
70
|
+
|
|
71
|
+
const snap = snapSlabEdgeToWallBand(
|
|
72
|
+
input.edge[0],
|
|
73
|
+
input.edge[1],
|
|
74
|
+
walls,
|
|
75
|
+
magnetic ? undefined : { maxLateral: WALL_CONNECT_SNAP_RADIUS },
|
|
76
|
+
)
|
|
77
|
+
if (!snap) {
|
|
78
|
+
useWallSnapIndicator.getState().clear()
|
|
79
|
+
useAlignmentGuides.getState().clear()
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const [a, b] = snap.edge
|
|
84
|
+
const dx = b[0] - a[0]
|
|
85
|
+
const dz = b[1] - a[1]
|
|
86
|
+
const lengthSquared = dx * dx + dz * dz
|
|
87
|
+
let beacon: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
|
88
|
+
if (input.referencePoint && lengthSquared > 1e-12) {
|
|
89
|
+
const t = Math.max(
|
|
90
|
+
0,
|
|
91
|
+
Math.min(
|
|
92
|
+
1,
|
|
93
|
+
((input.referencePoint[0] - a[0]) * dx + (input.referencePoint[1] - a[1]) * dz) /
|
|
94
|
+
lengthSquared,
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
beacon = [a[0] + dx * t, a[1] + dz * t]
|
|
98
|
+
}
|
|
99
|
+
useWallSnapIndicator.getState().set({
|
|
100
|
+
x: beacon[0],
|
|
101
|
+
z: beacon[1],
|
|
102
|
+
kind: 'wall',
|
|
103
|
+
wallIds: [snap.wallId],
|
|
104
|
+
})
|
|
105
|
+
useAlignmentGuides.getState().clear()
|
|
106
|
+
|
|
107
|
+
return { edge: snap.edge, wallId: snap.wallId }
|
|
108
|
+
}
|