@pascal-app/editor 0.9.1 → 1.0.0-beta.1
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 +14 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +49 -0
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +332 -199
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
- package/src/components/editor/first-person/build-collider-world.ts +52 -5
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +410 -136
- package/src/components/editor/floating-action-menu.tsx +435 -64
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +72 -0
- package/src/components/editor/floorplan-background-selection.ts +7 -20
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +2517 -1492
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +597 -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 +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +49 -6
- package/src/components/editor/index.tsx +289 -35
- package/src/components/editor/measurement-pill.tsx +68 -23
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +409 -180
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +76 -0
- package/src/components/editor/quick-measurement-hud.tsx +34 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +814 -466
- package/src/components/editor/site-edge-labels.tsx +28 -6
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +118 -253
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
- package/src/components/editor/wall-measurement-label.tsx +10 -28
- package/src/components/editor/wall-move-side-handles.tsx +229 -39
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- 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-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +107 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +146 -57
- package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
- package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
- package/src/components/tools/roof/roof-tool.tsx +336 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- 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 +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- 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 +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +101 -42
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +164 -42
- package/src/components/tools/tool-manager.tsx +149 -30
- package/src/components/tools/wall/wall-drafting.test.ts +447 -0
- package/src/components/tools/wall/wall-drafting.ts +304 -108
- package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +98 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +44 -94
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +267 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +16 -3
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +93 -18
- package/src/components/ui/controls/material-picker.tsx +162 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- 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/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +74 -20
- 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 +273 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- 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 +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +36 -14
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +19 -4
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- 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/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- 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 +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
- 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 +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -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 +44 -3
- 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/tab-bar.tsx +42 -28
- 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/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +36 -596
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +438 -70
- package/src/index.tsx +355 -9
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +42 -0
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- 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 +275 -0
- package/src/lib/direct-manipulation.ts +118 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +978 -0
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +52 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +283 -3
- package/src/lib/fresh-planar-placement.ts +85 -1
- package/src/lib/glb-export.test.ts +556 -0
- package/src/lib/glb-export.ts +1071 -0
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +31 -0
- 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 +52 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +243 -0
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- 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 +188 -0
- package/src/lib/measurements.ts +210 -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/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- 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 +325 -0
- package/src/lib/scene-clipboard.ts +310 -21
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +313 -0
- package/src/lib/selection-routing.ts +180 -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 +82 -14
- package/src/lib/sfx-player.test.ts +266 -0
- package/src/lib/sfx-player.ts +262 -69
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -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 +165 -0
- package/src/lib/snapping-mode.ts +191 -0
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +58 -86
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +665 -126
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +51 -0
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +151 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +206 -0
- package/src/store/use-interaction-scope.ts +140 -0
- package/src/store/use-measurement-draft.test.ts +559 -0
- package/src/store/use-measurement-draft.ts +570 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-placement-preview.ts +11 -3
- 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 +53 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/floorplan/selection-tool.ts +0 -271
- package/src/lib/level-name.ts +0 -11
package/src/lib/sfx-player.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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×),
|
|
@@ -14,13 +14,18 @@ type SFXConfig = {
|
|
|
14
14
|
// Minimum gap between two plays of this SFX. Triggers within this window
|
|
15
15
|
// are silently dropped so bursty sequences don't phase-stack into noise.
|
|
16
16
|
minIntervalMs?: number
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type LoopSFXConfig = {
|
|
20
|
+
src: string
|
|
21
|
+
volumeMultiplier: number
|
|
22
|
+
fadeInMs?: number
|
|
23
|
+
fadeOutMs?: number
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
const DEFAULT_MIN_INTERVAL_MS = 30
|
|
27
|
+
const SFX_FAILURE_BACKOFF_MS = 5_000
|
|
28
|
+
const DEFAULT_LOOP_FADE_MS = 90
|
|
24
29
|
|
|
25
30
|
// SFX sound definitions
|
|
26
31
|
export const SFX: Record<string, SFXConfig> = {
|
|
@@ -32,52 +37,53 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
32
37
|
],
|
|
33
38
|
rateRange: [0.98, 1.02],
|
|
34
39
|
volumeRange: [0.5, 0.6],
|
|
35
|
-
panJitter: 0.15,
|
|
36
40
|
minIntervalMs: 50,
|
|
37
41
|
},
|
|
38
42
|
itemDelete: {
|
|
39
43
|
src: '/audios/sfx/item_delete.mp3',
|
|
40
44
|
rateRange: [0.9, 1.1],
|
|
41
45
|
volumeRange: [0.9, 1.0],
|
|
42
|
-
panJitter: 0.05,
|
|
43
46
|
},
|
|
44
47
|
itemPick: {
|
|
45
48
|
src: '/audios/sfx/item_pick.mp3',
|
|
46
49
|
rateRange: [0.95, 1.05],
|
|
47
50
|
volumeRange: [0.92, 1.0],
|
|
48
|
-
panJitter: 0.15,
|
|
49
51
|
},
|
|
50
52
|
itemPlace: {
|
|
51
53
|
src: '/audios/sfx/item_place.mp3',
|
|
52
54
|
rateRange: [0.98, 1.02],
|
|
53
55
|
volumeRange: [0.9, 1.0],
|
|
54
|
-
panJitter: 0.15,
|
|
55
56
|
},
|
|
56
57
|
itemRotate: {
|
|
57
58
|
src: '/audios/sfx/item_rotate.mp3',
|
|
58
59
|
rateRange: [0.94, 1.06],
|
|
59
60
|
volumeRange: [0.92, 1.0],
|
|
60
|
-
|
|
61
|
+
},
|
|
62
|
+
// Ticks as a resize handle is dragged across snap steps. Fires in rapid
|
|
63
|
+
// succession, so it mirrors gridSnap: three variations cycled round-robin
|
|
64
|
+
// with pitch jitter and a gap so the run reads as texture, not a tone.
|
|
65
|
+
resize: {
|
|
66
|
+
src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'],
|
|
67
|
+
rateRange: [0.98, 1.02],
|
|
68
|
+
volumeRange: [0.26, 0.34],
|
|
69
|
+
minIntervalMs: 80,
|
|
61
70
|
},
|
|
62
71
|
// Fired when a structure draft begins (first click of a wall/slab/etc).
|
|
63
72
|
structureBuildStart: {
|
|
64
73
|
src: '/audios/sfx/structure_build_start.mp3',
|
|
65
74
|
rateRange: [0.95, 1.05],
|
|
66
75
|
volumeRange: [0.88, 1.0],
|
|
67
|
-
panJitter: 0.15,
|
|
68
76
|
},
|
|
69
77
|
// Fired when a structure is committed (segment placed / polygon closed).
|
|
70
78
|
structureBuildEnd: {
|
|
71
79
|
src: '/audios/sfx/structure_build_end.mp3',
|
|
72
80
|
rateRange: [0.95, 1.05],
|
|
73
81
|
volumeRange: [0.88, 1.0],
|
|
74
|
-
panJitter: 0.15,
|
|
75
82
|
},
|
|
76
83
|
structureDelete: {
|
|
77
84
|
src: '/audios/sfx/structure_delete.mp3',
|
|
78
85
|
rateRange: [0.9, 1.1],
|
|
79
86
|
volumeRange: [0.9, 1.0],
|
|
80
|
-
panJitter: 0.08,
|
|
81
87
|
},
|
|
82
88
|
snapshotCapture: {
|
|
83
89
|
// Shutter should sound consistent — no variation.
|
|
@@ -90,7 +96,6 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
90
96
|
src: '/audios/sfx/menu_hover.mp3',
|
|
91
97
|
rateRange: [0.98, 1.02],
|
|
92
98
|
volumeRange: [0.2, 0.3],
|
|
93
|
-
panJitter: 0.1,
|
|
94
99
|
minIntervalMs: 0,
|
|
95
100
|
},
|
|
96
101
|
// Fired when a main category in the Build / Items panels is clicked.
|
|
@@ -98,81 +103,254 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
98
103
|
src: '/audios/sfx/menu_click.mp3',
|
|
99
104
|
rateRange: [0.98, 1.02],
|
|
100
105
|
volumeRange: [0.5, 0.6],
|
|
101
|
-
|
|
106
|
+
},
|
|
107
|
+
// Fired when a material is applied to a surface in paint mode. Painting can
|
|
108
|
+
// fire in quick succession across faces, so keep variation + a small gap.
|
|
109
|
+
paintApply: {
|
|
110
|
+
src: '/audios/sfx/paint_apply.mp3',
|
|
111
|
+
rateRange: [0.95, 1.05],
|
|
112
|
+
volumeRange: [0.85, 1.0],
|
|
113
|
+
minIntervalMs: 60,
|
|
102
114
|
},
|
|
103
115
|
} as const
|
|
104
116
|
|
|
105
117
|
export type SFXName = keyof typeof SFX
|
|
106
118
|
|
|
119
|
+
export const LOOP_SFX = {
|
|
120
|
+
terrainRaise: {
|
|
121
|
+
src: '/audios/sfx/terrain_raise.mp3',
|
|
122
|
+
volumeMultiplier: 0.2,
|
|
123
|
+
},
|
|
124
|
+
terrainLower: {
|
|
125
|
+
src: '/audios/sfx/terrain_lower.mp3',
|
|
126
|
+
volumeMultiplier: 0.2,
|
|
127
|
+
},
|
|
128
|
+
terrainFlatten: {
|
|
129
|
+
src: '/audios/sfx/terrain_flatten.mp3',
|
|
130
|
+
volumeMultiplier: 0.2,
|
|
131
|
+
},
|
|
132
|
+
terrainSmooth: {
|
|
133
|
+
src: '/audios/sfx/terrain_smooth.mp3',
|
|
134
|
+
volumeMultiplier: 0.2,
|
|
135
|
+
},
|
|
136
|
+
} as const satisfies Record<string, LoopSFXConfig>
|
|
137
|
+
|
|
138
|
+
export type LoopSFXName = keyof typeof LOOP_SFX
|
|
139
|
+
type CachedSFXName = SFXName | LoopSFXName
|
|
140
|
+
|
|
141
|
+
function loopConfig(name: LoopSFXName): LoopSFXConfig {
|
|
142
|
+
return LOOP_SFX[name]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type SFXPlaybackOptions = {
|
|
146
|
+
source?: 'local' | 'remote'
|
|
147
|
+
stereo?: number
|
|
148
|
+
volumeMultiplier?: number
|
|
149
|
+
}
|
|
150
|
+
|
|
107
151
|
function randomInRange([min, max]: [number, number]): number {
|
|
108
152
|
return min + Math.random() * (max - min)
|
|
109
153
|
}
|
|
110
154
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
155
|
+
let sfxCache = new Map<CachedSFXName, Howl[]>()
|
|
156
|
+
let sfxAudioContext: AudioContext | null = null
|
|
157
|
+
let sfxRetryAfter = 0
|
|
114
158
|
const lastPlayedAt = new Map<SFXName, number>()
|
|
115
159
|
const lastVariation = new Map<SFXName, number>()
|
|
160
|
+
let activeLoop: {
|
|
161
|
+
id: number
|
|
162
|
+
name: LoopSFXName
|
|
163
|
+
sound: Howl
|
|
164
|
+
volume: number
|
|
165
|
+
} | null = null
|
|
166
|
+
let pendingLoop: { name: LoopSFXName; sound: Howl } | null = null
|
|
167
|
+
let requestedLoop: LoopSFXName | null = null
|
|
168
|
+
|
|
169
|
+
function loopVolume(name: LoopSFXName): number {
|
|
170
|
+
const { masterVolume, sfxVolume } = useAudio.getState()
|
|
171
|
+
return (masterVolume / 100) * (sfxVolume / 100) * LOOP_SFX[name].volumeMultiplier
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function stopActiveLoop(fadeMs: number) {
|
|
175
|
+
const active = activeLoop
|
|
176
|
+
if (!active) return
|
|
177
|
+
activeLoop = null
|
|
178
|
+
|
|
179
|
+
if (fadeMs <= 0) {
|
|
180
|
+
active.sound.stop(active.id)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
active.sound.once('fade', () => active.sound.stop(active.id), active.id)
|
|
185
|
+
active.sound.fade(active.volume, 0, fadeMs, active.id)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function unloadCachedSounds(resetPlaybackState: boolean) {
|
|
189
|
+
requestedLoop = null
|
|
190
|
+
pendingLoop = null
|
|
191
|
+
stopActiveLoop(0)
|
|
192
|
+
for (const sounds of sfxCache.values()) {
|
|
193
|
+
for (const sound of sounds) {
|
|
194
|
+
try {
|
|
195
|
+
sound.unload()
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
sfxCache.clear()
|
|
200
|
+
sfxAudioContext = null
|
|
201
|
+
if (resetPlaybackState) {
|
|
202
|
+
sfxRetryAfter = 0
|
|
203
|
+
lastPlayedAt.clear()
|
|
204
|
+
lastVariation.clear()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function cacheNeedsRebuild(): boolean {
|
|
209
|
+
if (sfxCache.size === 0) return true
|
|
210
|
+
if (sfxAudioContext !== Howler.ctx) return true
|
|
211
|
+
for (const sounds of sfxCache.values()) {
|
|
212
|
+
if (sounds.some((sound) => sound.state() === 'unloaded')) return true
|
|
213
|
+
}
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
116
216
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
217
|
+
export function preloadSFX() {
|
|
218
|
+
if (!cacheNeedsRebuild()) return
|
|
219
|
+
unloadCachedSounds(false)
|
|
220
|
+
|
|
221
|
+
const definitions = { ...SFX, ...LOOP_SFX }
|
|
222
|
+
for (const [name, config] of Object.entries(definitions)) {
|
|
223
|
+
const sources = Array.isArray(config.src) ? config.src : [config.src]
|
|
224
|
+
sfxCache.set(
|
|
225
|
+
name as CachedSFXName,
|
|
226
|
+
sources.map(
|
|
227
|
+
(src) =>
|
|
228
|
+
new Howl({
|
|
229
|
+
loop: name in LOOP_SFX,
|
|
230
|
+
src: [src],
|
|
231
|
+
preload: true,
|
|
232
|
+
volume: 0.5,
|
|
233
|
+
}),
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
sfxAudioContext = Howler.ctx ?? null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function disposeSFX() {
|
|
241
|
+
unloadCachedSounds(true)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function startLoopSFX(name: LoopSFXName) {
|
|
245
|
+
const { muted } = useAudio.getState()
|
|
246
|
+
if (muted) {
|
|
247
|
+
stopLoopSFX()
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
requestedLoop = name
|
|
252
|
+
const current = activeLoop
|
|
253
|
+
if (current?.name === name && current.sound.playing(current.id)) return
|
|
254
|
+
if (current) stopActiveLoop(loopConfig(current.name).fadeOutMs ?? DEFAULT_LOOP_FADE_MS)
|
|
255
|
+
|
|
256
|
+
const now = performance.now()
|
|
257
|
+
if (now < sfxRetryAfter) return
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
preloadSFX()
|
|
261
|
+
// A fresh Howler context rebuilds the cache and clears stale playback
|
|
262
|
+
// state. Restore this current press after that cleanup so a first-use loop
|
|
263
|
+
// can still begin when its asset finishes loading.
|
|
264
|
+
requestedLoop = name
|
|
265
|
+
const sound = sfxCache.get(name)?.[0]
|
|
266
|
+
if (!sound) return
|
|
267
|
+
if (sound.state() !== 'loaded') {
|
|
268
|
+
if (pendingLoop?.name === name && pendingLoop.sound === sound) return
|
|
269
|
+
pendingLoop = { name, sound }
|
|
270
|
+
sound.once('load', () => {
|
|
271
|
+
if (pendingLoop?.sound === sound) pendingLoop = null
|
|
272
|
+
if (requestedLoop === name) startLoopSFX(name)
|
|
273
|
+
})
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const config = loopConfig(name)
|
|
278
|
+
const volume = loopVolume(name)
|
|
279
|
+
const id = sound.play()
|
|
280
|
+
sound.volume(0, id)
|
|
281
|
+
activeLoop = { id, name, sound, volume }
|
|
282
|
+
sound.fade(0, volume, config.fadeInMs ?? DEFAULT_LOOP_FADE_MS, id)
|
|
283
|
+
} catch {
|
|
284
|
+
unloadCachedSounds(false)
|
|
285
|
+
sfxRetryAfter = now + SFX_FAILURE_BACKOFF_MS
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function stopLoopSFX() {
|
|
290
|
+
requestedLoop = null
|
|
291
|
+
pendingLoop = null
|
|
292
|
+
const fadeMs = activeLoop ? (loopConfig(activeLoop.name).fadeOutMs ?? DEFAULT_LOOP_FADE_MS) : 0
|
|
293
|
+
stopActiveLoop(fadeMs)
|
|
294
|
+
}
|
|
130
295
|
|
|
131
296
|
/**
|
|
132
297
|
* Play a sound effect with volume based on audio settings
|
|
133
298
|
*/
|
|
134
|
-
export function playSFX(name: SFXName) {
|
|
135
|
-
const sounds = sfxCache.get(name)
|
|
136
|
-
if (!sounds || sounds.length === 0) {
|
|
137
|
-
console.warn(`SFX not found: ${name}`)
|
|
138
|
-
return
|
|
139
|
-
}
|
|
299
|
+
export function playSFX(name: SFXName, options: SFXPlaybackOptions = {}) {
|
|
140
300
|
const config = SFX[name]!
|
|
301
|
+
const { masterVolume, sfxVolume, muted } = useAudio.getState()
|
|
302
|
+
|
|
303
|
+
if (muted) return
|
|
141
304
|
|
|
142
305
|
// Drop rapid repeats — two plays of the same SFX within minIntervalMs just
|
|
143
306
|
// smear into noise, they don't add useful information.
|
|
144
307
|
const now = performance.now()
|
|
308
|
+
if (now < sfxRetryAfter) return
|
|
145
309
|
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
|
|
146
|
-
const
|
|
310
|
+
const source = options.source ?? 'local'
|
|
311
|
+
const playbackKey = `${source}:${name}`
|
|
312
|
+
const last = lastPlayedAt.get(playbackKey)
|
|
147
313
|
if (last !== undefined && now - last < minInterval) return
|
|
148
|
-
|
|
314
|
+
// Local feedback stays legible when a collaborator makes the same kind of
|
|
315
|
+
// change at nearly the same time; the quieter remote cue yields instead.
|
|
316
|
+
const lastLocal = lastPlayedAt.get(`local:${name}`)
|
|
317
|
+
if (source === 'remote' && lastLocal !== undefined && now - lastLocal < 120) return
|
|
318
|
+
lastPlayedAt.set(playbackKey, now)
|
|
149
319
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
index = (index + 1) % sounds.length
|
|
155
|
-
}
|
|
156
|
-
lastVariation.set(name, index)
|
|
157
|
-
const sound = sounds[index]!
|
|
320
|
+
try {
|
|
321
|
+
preloadSFX()
|
|
322
|
+
const sounds = sfxCache.get(name)
|
|
323
|
+
if (!sounds || sounds.length === 0) return
|
|
158
324
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
325
|
+
// Pick a random variation, avoiding an immediate repeat of the last one so
|
|
326
|
+
// consecutive plays don't land on the same file.
|
|
327
|
+
let index = Math.floor(Math.random() * sounds.length)
|
|
328
|
+
if (sounds.length > 1 && index === lastVariation.get(name)) {
|
|
329
|
+
index = (index + 1) % sounds.length
|
|
330
|
+
}
|
|
331
|
+
lastVariation.set(name, index)
|
|
332
|
+
const sound = sounds[index]!
|
|
333
|
+
// Howler queues per-play mutations while a sound is loading. If its global
|
|
334
|
+
// AudioContext is replaced before that queue drains, stereo setup can try
|
|
335
|
+
// to connect nodes from different contexts and throw asynchronously.
|
|
336
|
+
if (sound.state() !== 'loaded') return
|
|
337
|
+
const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
|
|
338
|
+
const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
|
|
339
|
+
const volumeMultiplier = Number.isFinite(options.volumeMultiplier)
|
|
340
|
+
? Math.max(0, Math.min(2, options.volumeMultiplier!))
|
|
341
|
+
: 1
|
|
342
|
+
const rate = config.rateRange ? randomInRange(config.rateRange) : 1
|
|
343
|
+
const id = sound.play()
|
|
344
|
+
sound.volume(baseVolume * volumeJitter * volumeMultiplier, id)
|
|
345
|
+
if (Number.isFinite(options.stereo)) {
|
|
346
|
+
sound.stereo(Math.max(-1, Math.min(1, options.stereo!)), id)
|
|
347
|
+
}
|
|
348
|
+
if (rate !== 1) sound.rate(rate, id)
|
|
349
|
+
} catch {
|
|
350
|
+
// Optional audio must never abort an editor input callback. Rebuild from
|
|
351
|
+
// the current Howler context after a backoff instead of retrying every pointer cue.
|
|
352
|
+
unloadCachedSounds(false)
|
|
353
|
+
sfxRetryAfter = now + SFX_FAILURE_BACKOFF_MS
|
|
176
354
|
}
|
|
177
355
|
}
|
|
178
356
|
|
|
@@ -180,12 +358,27 @@ export function playSFX(name: SFXName) {
|
|
|
180
358
|
* Update all cached SFX volumes (useful when settings change)
|
|
181
359
|
*/
|
|
182
360
|
export function updateSFXVolumes() {
|
|
183
|
-
const { masterVolume, sfxVolume } = useAudio.getState()
|
|
361
|
+
const { masterVolume, muted, sfxVolume } = useAudio.getState()
|
|
362
|
+
if (muted) {
|
|
363
|
+
stopLoopSFX()
|
|
364
|
+
return
|
|
365
|
+
}
|
|
184
366
|
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
|
|
185
367
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
368
|
+
try {
|
|
369
|
+
if (performance.now() < sfxRetryAfter) return
|
|
370
|
+
preloadSFX()
|
|
371
|
+
sfxCache.forEach((sounds) => {
|
|
372
|
+
sounds.forEach((sound) => {
|
|
373
|
+
sound.volume(finalVolume)
|
|
374
|
+
})
|
|
189
375
|
})
|
|
190
|
-
|
|
376
|
+
if (activeLoop) {
|
|
377
|
+
activeLoop.volume = loopVolume(activeLoop.name)
|
|
378
|
+
activeLoop.sound.volume(activeLoop.volume, activeLoop.id)
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
unloadCachedSounds(false)
|
|
382
|
+
sfxRetryAfter = performance.now() + SFX_FAILURE_BACKOFF_MS
|
|
383
|
+
}
|
|
191
384
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { Mode, Phase } from '../store/use-editor'
|
|
3
|
+
import { siteBoundaryHandlesEnabled } from './site-boundary'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The site handles are the one affordance both views draw from the same state, so
|
|
7
|
+
* they are also the one place a 2D/3D parity break hides: each pane used to derive
|
|
8
|
+
* this rule locally, and the sculpt exclusion existed in the 3D copy only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MODES: Mode[] = ['select', 'edit', 'delete', 'build', 'material-paint', 'terrain-sculpt']
|
|
12
|
+
const PHASES: Phase[] = ['site', 'structure', 'furnish']
|
|
13
|
+
|
|
14
|
+
describe('siteBoundaryHandlesEnabled', () => {
|
|
15
|
+
test('sculpt hides the handles in every phase', () => {
|
|
16
|
+
// Including `site` — which is the case that matters, because arming sculpt
|
|
17
|
+
// *promotes* the phase to `site`, so this is the only combination the brush is
|
|
18
|
+
// ever actually in.
|
|
19
|
+
for (const phase of PHASES) {
|
|
20
|
+
expect(siteBoundaryHandlesEnabled({ mode: 'terrain-sculpt', phase })).toBe(false)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('the site phase offers them in every other mode', () => {
|
|
25
|
+
for (const mode of MODES.filter((m) => m !== 'terrain-sculpt')) {
|
|
26
|
+
expect(siteBoundaryHandlesEnabled({ mode, phase: 'site' })).toBe(true)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('select mode offers them outside the site phase', () => {
|
|
31
|
+
// The flags double as the affordance that *enters* site editing, so select mode
|
|
32
|
+
// has to show them from structure/furnish or the lot becomes unreachable.
|
|
33
|
+
expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'structure' })).toBe(true)
|
|
34
|
+
expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'furnish' })).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('a drafting or painting mode outside the site phase hides them', () => {
|
|
38
|
+
for (const mode of ['edit', 'delete', 'build', 'material-paint'] as Mode[]) {
|
|
39
|
+
expect(siteBoundaryHandlesEnabled({ mode, phase: 'structure' })).toBe(false)
|
|
40
|
+
expect(siteBoundaryHandlesEnabled({ mode, phase: 'furnish' })).toBe(false)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('sculpt is excluded before the phase term, not alongside it', () => {
|
|
45
|
+
// Ordering is the whole fix: `phase === 'site' || mode === 'select'` alone is
|
|
46
|
+
// *true* while sculpting, so an implementation that ORs the sculpt check in
|
|
47
|
+
// rather than early-returning would still show the handles.
|
|
48
|
+
const sculptInSite = siteBoundaryHandlesEnabled({ mode: 'terrain-sculpt', phase: 'site' })
|
|
49
|
+
const selectInSite = siteBoundaryHandlesEnabled({ mode: 'select', phase: 'site' })
|
|
50
|
+
expect(sculptInSite).toBe(false)
|
|
51
|
+
expect(selectInSite).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
})
|
package/src/lib/site-boundary.ts
CHANGED
|
@@ -1 +1,28 @@
|
|
|
1
|
+
import type { Mode, Phase } from '../store/use-editor'
|
|
2
|
+
|
|
1
3
|
export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether the site's vertex/midpoint handles are grabbable right now.
|
|
7
|
+
*
|
|
8
|
+
* Shared by the 3D boundary editor (`tool-manager.tsx`) and the floorplan's SVG
|
|
9
|
+
* handles (`floorplan-panel.tsx`) because they must agree, and until this existed
|
|
10
|
+
* they did not: each derived the rule from `phase`/`mode` locally, and the sculpt
|
|
11
|
+
* clause was added to only one of them.
|
|
12
|
+
*
|
|
13
|
+
* The disagreement was reachable in exactly one configuration, which is why it was
|
|
14
|
+
* easy to miss: arming sculpt from the 2D view promotes `viewMode` to `split` *and*
|
|
15
|
+
* the phase to `site`, so the floorplan's `phase === 'site'` term went true at the
|
|
16
|
+
* same moment the 3D editor's `!sculpting` term went false — handles gone in one
|
|
17
|
+
* pane, live in the other. Grabbing one then calls `begin({kind: 'handle-drag'})`,
|
|
18
|
+
* which atomically replaces the held `sculpting` scope: the brush stays armed and
|
|
19
|
+
* still paints, while the rest of the editor believes a boundary drag is running.
|
|
20
|
+
*
|
|
21
|
+
* Sculpt is excluded rather than merely deprioritised because the flags sit *on* the
|
|
22
|
+
* ground the brush is aimed at — leaving them mounted puts a grabbable handle under
|
|
23
|
+
* every dab near the lot line, in either view.
|
|
24
|
+
*/
|
|
25
|
+
export function siteBoundaryHandlesEnabled(args: { mode: Mode; phase: Phase }): boolean {
|
|
26
|
+
if (args.mode === 'terrain-sculpt') return false
|
|
27
|
+
return args.phase === 'site' || args.mode === 'select'
|
|
28
|
+
}
|
|
@@ -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
|
+
})
|