@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.
Files changed (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. 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
+ }
@@ -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
- // Map SFX events to sound playback
37
- sfxEmitter.on('sfx:grid-snap', () => playSFX('gridSnap'))
38
- sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))
39
- sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
40
- sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
41
- sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
42
- sfxEmitter.on('sfx:structure-build-start', () => playSFX('structureBuildStart'))
43
- sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
44
- sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
45
- sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture'))
46
- sfxEmitter.on('sfx:menu-hover', () => playSFX('menuHover'))
47
- sfxEmitter.on('sfx:menu-click', () => playSFX('menuClick'))
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
+ })
@@ -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
- panJitter: 0.15,
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
- panJitter: 0.1,
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
- // Preload all SFX sounds. Each variation gets its own Howl so they can overlap
112
- // and be cycled round-robin.
113
- const sfxCache = new Map<SFXName, Howl[]>()
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
- // Initialize all sounds
118
- Object.entries(SFX).forEach(([name, config]) => {
119
- const sources = Array.isArray(config.src) ? config.src : [config.src]
120
- const sounds = sources.map(
121
- (src) =>
122
- new Howl({
123
- src: [src],
124
- preload: true,
125
- volume: 0.5, // Will be adjusted by the bus
126
- }),
127
- )
128
- sfxCache.set(name as SFXName, sounds)
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
- // Pick a random variation, avoiding an immediate repeat of the last one so
151
- // consecutive plays don't land on the same file.
152
- let index = Math.floor(Math.random() * sounds.length)
153
- if (sounds.length > 1 && index === lastVariation.get(name)) {
154
- index = (index + 1) % sounds.length
155
- }
156
- lastVariation.set(name, index)
157
- const sound = sounds[index]!
158
-
159
- const { masterVolume, sfxVolume, muted } = useAudio.getState()
160
-
161
- if (muted) return
162
-
163
- // Calculate final volume (masterVolume and sfxVolume are 0-100)
164
- const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
165
- const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
166
- const rate = config.rateRange ? randomInRange(config.rateRange) : 1
167
-
168
- // Apply per-play variation using the returned sound id so overlapping plays
169
- // don't fight over shared properties on the Howl.
170
- const id = sound.play()
171
- sound.volume(baseVolume * volumeJitter, id)
172
- if (rate !== 1) sound.rate(rate, id)
173
- if (config.panJitter) {
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
- sfxCache.forEach((sounds) => {
187
- sounds.forEach((sound) => {
188
- sound.volume(finalVolume)
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
+ })