@pascal-app/editor 0.9.1 → 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 +12 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -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 +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- 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 +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- 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 +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/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- 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 +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- 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 +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- 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-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- 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 +80 -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 +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -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/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- 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 +188 -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 +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -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/floating-level-selector.tsx +1 -1
- 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 +227 -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/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- 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 +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- 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 +40 -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-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- 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-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -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 +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -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 +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -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 +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- 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 +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/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-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 +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- 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 +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- 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/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 +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -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 +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 +43 -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/level-name.ts +0 -11
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { movementSfxStepKey } from './movement-tick'
|
|
3
|
+
|
|
4
|
+
describe('movementSfxStepKey', () => {
|
|
5
|
+
test('keeps tiny free-move changes in the same bucket', () => {
|
|
6
|
+
expect(
|
|
7
|
+
movementSfxStepKey({
|
|
8
|
+
coords: [1.01, 2.04],
|
|
9
|
+
gridSnapActive: false,
|
|
10
|
+
gridStep: 0.5,
|
|
11
|
+
}),
|
|
12
|
+
).toBe(
|
|
13
|
+
movementSfxStepKey({
|
|
14
|
+
coords: [1.04, 2.01],
|
|
15
|
+
gridSnapActive: false,
|
|
16
|
+
gridStep: 0.5,
|
|
17
|
+
}),
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('changes bucket after crossing the free-move cadence', () => {
|
|
22
|
+
expect(
|
|
23
|
+
movementSfxStepKey({
|
|
24
|
+
coords: [1.01, 2.04],
|
|
25
|
+
gridSnapActive: false,
|
|
26
|
+
gridStep: 0.5,
|
|
27
|
+
}),
|
|
28
|
+
).not.toBe(
|
|
29
|
+
movementSfxStepKey({
|
|
30
|
+
coords: [1.16, 2.04],
|
|
31
|
+
gridSnapActive: false,
|
|
32
|
+
gridStep: 0.5,
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('uses the live grid step when grid snapping is active', () => {
|
|
38
|
+
expect(
|
|
39
|
+
movementSfxStepKey({
|
|
40
|
+
coords: [0.24, 0],
|
|
41
|
+
gridSnapActive: true,
|
|
42
|
+
gridStep: 0.5,
|
|
43
|
+
}),
|
|
44
|
+
).toBe(
|
|
45
|
+
movementSfxStepKey({
|
|
46
|
+
coords: [0.21, 0],
|
|
47
|
+
gridSnapActive: true,
|
|
48
|
+
gridStep: 0.5,
|
|
49
|
+
}),
|
|
50
|
+
)
|
|
51
|
+
expect(
|
|
52
|
+
movementSfxStepKey({
|
|
53
|
+
coords: [0.24, 0],
|
|
54
|
+
gridSnapActive: true,
|
|
55
|
+
gridStep: 0.5,
|
|
56
|
+
}),
|
|
57
|
+
).not.toBe(
|
|
58
|
+
movementSfxStepKey({
|
|
59
|
+
coords: [0.76, 0],
|
|
60
|
+
gridSnapActive: true,
|
|
61
|
+
gridStep: 0.5,
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const DEFAULT_FREE_MOVEMENT_SFX_STEP_M = 0.1
|
|
2
|
+
|
|
3
|
+
type MovementSfxStepKeyArgs = {
|
|
4
|
+
coords: readonly number[]
|
|
5
|
+
gridSnapActive: boolean
|
|
6
|
+
gridStep: number
|
|
7
|
+
freeStep?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function movementSfxStepKey({
|
|
11
|
+
coords,
|
|
12
|
+
gridSnapActive,
|
|
13
|
+
gridStep,
|
|
14
|
+
freeStep = DEFAULT_FREE_MOVEMENT_SFX_STEP_M,
|
|
15
|
+
}: MovementSfxStepKeyArgs): string {
|
|
16
|
+
const step = gridSnapActive && gridStep > 0 ? gridStep : freeStep
|
|
17
|
+
return coords.map((coord) => Math.round(coord / step)).join(',')
|
|
18
|
+
}
|
package/src/lib/sfx-bus.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import mitt from 'mitt'
|
|
2
|
-
import { playSFX } from './sfx-player'
|
|
2
|
+
import { disposeSFX, playSFX } from './sfx-player'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* SFX-specific events that tools can trigger
|
|
@@ -10,12 +10,14 @@ type SFXEvents = {
|
|
|
10
10
|
'sfx:item-pick': undefined
|
|
11
11
|
'sfx:item-place': undefined
|
|
12
12
|
'sfx:item-rotate': undefined
|
|
13
|
+
'sfx:resize': undefined
|
|
13
14
|
'sfx:structure-build-start': undefined
|
|
14
15
|
'sfx:structure-build': undefined
|
|
15
16
|
'sfx:structure-delete': undefined
|
|
16
17
|
'sfx:snapshot-capture': undefined
|
|
17
18
|
'sfx:menu-hover': undefined
|
|
18
19
|
'sfx:menu-click': undefined
|
|
20
|
+
'sfx:paint-apply': undefined
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -26,6 +28,20 @@ export const sfxEmitter = mitt<SFXEvents>()
|
|
|
26
28
|
|
|
27
29
|
let sfxBusInitialized = false
|
|
28
30
|
|
|
31
|
+
const handleGridSnap = () => playSFX('gridSnap')
|
|
32
|
+
const handleItemDelete = () => playSFX('itemDelete')
|
|
33
|
+
const handleItemPick = () => playSFX('itemPick')
|
|
34
|
+
const handleItemPlace = () => playSFX('itemPlace')
|
|
35
|
+
const handleItemRotate = () => playSFX('itemRotate')
|
|
36
|
+
const handleResize = () => playSFX('resize')
|
|
37
|
+
const handleStructureBuildStart = () => playSFX('structureBuildStart')
|
|
38
|
+
const handleStructureBuild = () => playSFX('structureBuildEnd')
|
|
39
|
+
const handleStructureDelete = () => playSFX('structureDelete')
|
|
40
|
+
const handleSnapshotCapture = () => playSFX('snapshotCapture')
|
|
41
|
+
const handleMenuHover = () => playSFX('menuHover')
|
|
42
|
+
const handleMenuClick = () => playSFX('menuClick')
|
|
43
|
+
const handlePaintApply = () => playSFX('paintApply')
|
|
44
|
+
|
|
29
45
|
/**
|
|
30
46
|
* Initialize SFX Bus - connects SFX events to actual sound playback.
|
|
31
47
|
* Safe to call multiple times; re-registration is a no-op once initialized.
|
|
@@ -33,18 +49,39 @@ let sfxBusInitialized = false
|
|
|
33
49
|
export function initSFXBus() {
|
|
34
50
|
if (sfxBusInitialized) return
|
|
35
51
|
sfxBusInitialized = true
|
|
36
|
-
|
|
37
|
-
sfxEmitter.on('sfx:
|
|
38
|
-
sfxEmitter.on('sfx:item-
|
|
39
|
-
sfxEmitter.on('sfx:item-
|
|
40
|
-
sfxEmitter.on('sfx:item-
|
|
41
|
-
sfxEmitter.on('sfx:
|
|
42
|
-
sfxEmitter.on('sfx:structure-build-start',
|
|
43
|
-
sfxEmitter.on('sfx:structure-build',
|
|
44
|
-
sfxEmitter.on('sfx:structure-delete',
|
|
45
|
-
sfxEmitter.on('sfx:snapshot-capture',
|
|
46
|
-
sfxEmitter.on('sfx:menu-hover',
|
|
47
|
-
sfxEmitter.on('sfx:menu-click',
|
|
52
|
+
sfxEmitter.on('sfx:grid-snap', handleGridSnap)
|
|
53
|
+
sfxEmitter.on('sfx:item-delete', handleItemDelete)
|
|
54
|
+
sfxEmitter.on('sfx:item-pick', handleItemPick)
|
|
55
|
+
sfxEmitter.on('sfx:item-place', handleItemPlace)
|
|
56
|
+
sfxEmitter.on('sfx:item-rotate', handleItemRotate)
|
|
57
|
+
sfxEmitter.on('sfx:resize', handleResize)
|
|
58
|
+
sfxEmitter.on('sfx:structure-build-start', handleStructureBuildStart)
|
|
59
|
+
sfxEmitter.on('sfx:structure-build', handleStructureBuild)
|
|
60
|
+
sfxEmitter.on('sfx:structure-delete', handleStructureDelete)
|
|
61
|
+
sfxEmitter.on('sfx:snapshot-capture', handleSnapshotCapture)
|
|
62
|
+
sfxEmitter.on('sfx:menu-hover', handleMenuHover)
|
|
63
|
+
sfxEmitter.on('sfx:menu-click', handleMenuClick)
|
|
64
|
+
sfxEmitter.on('sfx:paint-apply', handlePaintApply)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function disposeSFXBus() {
|
|
68
|
+
if (sfxBusInitialized) {
|
|
69
|
+
sfxEmitter.off('sfx:grid-snap', handleGridSnap)
|
|
70
|
+
sfxEmitter.off('sfx:item-delete', handleItemDelete)
|
|
71
|
+
sfxEmitter.off('sfx:item-pick', handleItemPick)
|
|
72
|
+
sfxEmitter.off('sfx:item-place', handleItemPlace)
|
|
73
|
+
sfxEmitter.off('sfx:item-rotate', handleItemRotate)
|
|
74
|
+
sfxEmitter.off('sfx:resize', handleResize)
|
|
75
|
+
sfxEmitter.off('sfx:structure-build-start', handleStructureBuildStart)
|
|
76
|
+
sfxEmitter.off('sfx:structure-build', handleStructureBuild)
|
|
77
|
+
sfxEmitter.off('sfx:structure-delete', handleStructureDelete)
|
|
78
|
+
sfxEmitter.off('sfx:snapshot-capture', handleSnapshotCapture)
|
|
79
|
+
sfxEmitter.off('sfx:menu-hover', handleMenuHover)
|
|
80
|
+
sfxEmitter.off('sfx:menu-click', handleMenuClick)
|
|
81
|
+
sfxEmitter.off('sfx:paint-apply', handlePaintApply)
|
|
82
|
+
sfxBusInitialized = false
|
|
83
|
+
}
|
|
84
|
+
disposeSFX()
|
|
48
85
|
}
|
|
49
86
|
|
|
50
87
|
/**
|
|
@@ -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,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,10 @@ 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
|
-
// Random stereo pan per play — max absolute offset (0 = center, 1 = hard
|
|
18
|
-
// right). A small value like 0.15 keeps things centred but adds just enough
|
|
19
|
-
// spread to stop repeats from stacking on the same point in the field.
|
|
20
|
-
panJitter?: number
|
|
21
17
|
}
|
|
22
18
|
|
|
23
19
|
const DEFAULT_MIN_INTERVAL_MS = 30
|
|
20
|
+
const SFX_FAILURE_BACKOFF_MS = 5_000
|
|
24
21
|
|
|
25
22
|
// SFX sound definitions
|
|
26
23
|
export const SFX: Record<string, SFXConfig> = {
|
|
@@ -32,52 +29,53 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
32
29
|
],
|
|
33
30
|
rateRange: [0.98, 1.02],
|
|
34
31
|
volumeRange: [0.5, 0.6],
|
|
35
|
-
panJitter: 0.15,
|
|
36
32
|
minIntervalMs: 50,
|
|
37
33
|
},
|
|
38
34
|
itemDelete: {
|
|
39
35
|
src: '/audios/sfx/item_delete.mp3',
|
|
40
36
|
rateRange: [0.9, 1.1],
|
|
41
37
|
volumeRange: [0.9, 1.0],
|
|
42
|
-
panJitter: 0.05,
|
|
43
38
|
},
|
|
44
39
|
itemPick: {
|
|
45
40
|
src: '/audios/sfx/item_pick.mp3',
|
|
46
41
|
rateRange: [0.95, 1.05],
|
|
47
42
|
volumeRange: [0.92, 1.0],
|
|
48
|
-
panJitter: 0.15,
|
|
49
43
|
},
|
|
50
44
|
itemPlace: {
|
|
51
45
|
src: '/audios/sfx/item_place.mp3',
|
|
52
46
|
rateRange: [0.98, 1.02],
|
|
53
47
|
volumeRange: [0.9, 1.0],
|
|
54
|
-
panJitter: 0.15,
|
|
55
48
|
},
|
|
56
49
|
itemRotate: {
|
|
57
50
|
src: '/audios/sfx/item_rotate.mp3',
|
|
58
51
|
rateRange: [0.94, 1.06],
|
|
59
52
|
volumeRange: [0.92, 1.0],
|
|
60
|
-
|
|
53
|
+
},
|
|
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,
|
|
61
62
|
},
|
|
62
63
|
// Fired when a structure draft begins (first click of a wall/slab/etc).
|
|
63
64
|
structureBuildStart: {
|
|
64
65
|
src: '/audios/sfx/structure_build_start.mp3',
|
|
65
66
|
rateRange: [0.95, 1.05],
|
|
66
67
|
volumeRange: [0.88, 1.0],
|
|
67
|
-
panJitter: 0.15,
|
|
68
68
|
},
|
|
69
69
|
// Fired when a structure is committed (segment placed / polygon closed).
|
|
70
70
|
structureBuildEnd: {
|
|
71
71
|
src: '/audios/sfx/structure_build_end.mp3',
|
|
72
72
|
rateRange: [0.95, 1.05],
|
|
73
73
|
volumeRange: [0.88, 1.0],
|
|
74
|
-
panJitter: 0.15,
|
|
75
74
|
},
|
|
76
75
|
structureDelete: {
|
|
77
76
|
src: '/audios/sfx/structure_delete.mp3',
|
|
78
77
|
rateRange: [0.9, 1.1],
|
|
79
78
|
volumeRange: [0.9, 1.0],
|
|
80
|
-
panJitter: 0.08,
|
|
81
79
|
},
|
|
82
80
|
snapshotCapture: {
|
|
83
81
|
// Shutter should sound consistent — no variation.
|
|
@@ -90,7 +88,6 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
90
88
|
src: '/audios/sfx/menu_hover.mp3',
|
|
91
89
|
rateRange: [0.98, 1.02],
|
|
92
90
|
volumeRange: [0.2, 0.3],
|
|
93
|
-
panJitter: 0.1,
|
|
94
91
|
minIntervalMs: 0,
|
|
95
92
|
},
|
|
96
93
|
// Fired when a main category in the Build / Items panels is clicked.
|
|
@@ -98,7 +95,14 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
98
95
|
src: '/audios/sfx/menu_click.mp3',
|
|
99
96
|
rateRange: [0.98, 1.02],
|
|
100
97
|
volumeRange: [0.5, 0.6],
|
|
101
|
-
|
|
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,
|
|
102
106
|
},
|
|
103
107
|
} as const
|
|
104
108
|
|
|
@@ -108,71 +112,105 @@ function randomInRange([min, max]: [number, number]): number {
|
|
|
108
112
|
return min + Math.random() * (max - min)
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
let sfxCache = new Map<SFXName, Howl[]>()
|
|
116
|
+
let sfxAudioContext: AudioContext | null = null
|
|
117
|
+
let sfxRetryAfter = 0
|
|
114
118
|
const lastPlayedAt = new Map<SFXName, number>()
|
|
115
119
|
const lastVariation = new Map<SFXName, number>()
|
|
116
120
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
}
|
|
130
171
|
|
|
131
172
|
/**
|
|
132
173
|
* Play a sound effect with volume based on audio settings
|
|
133
174
|
*/
|
|
134
175
|
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
|
-
}
|
|
140
176
|
const config = SFX[name]!
|
|
177
|
+
const { masterVolume, sfxVolume, muted } = useAudio.getState()
|
|
178
|
+
|
|
179
|
+
if (muted) return
|
|
141
180
|
|
|
142
181
|
// Drop rapid repeats — two plays of the same SFX within minIntervalMs just
|
|
143
182
|
// smear into noise, they don't add useful information.
|
|
144
183
|
const now = performance.now()
|
|
184
|
+
if (now < sfxRetryAfter) return
|
|
145
185
|
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
|
|
146
186
|
const last = lastPlayedAt.get(name)
|
|
147
187
|
if (last !== undefined && now - last < minInterval) return
|
|
148
188
|
lastPlayedAt.set(name, now)
|
|
149
189
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const pan = (Math.random() * 2 - 1) * config.panJitter
|
|
175
|
-
sound.stereo(pan, id)
|
|
190
|
+
try {
|
|
191
|
+
preloadSFX()
|
|
192
|
+
const sounds = sfxCache.get(name)
|
|
193
|
+
if (!sounds || sounds.length === 0) return
|
|
194
|
+
|
|
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
|
|
176
214
|
}
|
|
177
215
|
}
|
|
178
216
|
|
|
@@ -183,9 +221,16 @@ export function updateSFXVolumes() {
|
|
|
183
221
|
const { masterVolume, sfxVolume } = useAudio.getState()
|
|
184
222
|
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
|
|
185
223
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
224
|
+
try {
|
|
225
|
+
if (performance.now() < sfxRetryAfter) return
|
|
226
|
+
preloadSFX()
|
|
227
|
+
sfxCache.forEach((sounds) => {
|
|
228
|
+
sounds.forEach((sound) => {
|
|
229
|
+
sound.volume(finalVolume)
|
|
230
|
+
})
|
|
189
231
|
})
|
|
190
|
-
}
|
|
232
|
+
} catch {
|
|
233
|
+
unloadCachedSounds(false)
|
|
234
|
+
sfxRetryAfter = performance.now() + SFX_FAILURE_BACKOFF_MS
|
|
235
|
+
}
|
|
191
236
|
}
|
|
@@ -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
|
+
})
|