@pascal-app/editor 1.1.0 → 1.1.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.
Files changed (260) hide show
  1. package/package.json +9 -15
  2. package/src/components/editor/action-menu-button.tsx +1 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +1 -1
  4. package/src/components/editor/custom-camera-controls.tsx +11 -11
  5. package/src/components/editor/editor-layout-v2.tsx +1 -1
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +20 -1981
  8. package/src/components/editor/floating-action-menu.tsx +1 -1
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floor-region-controls.tsx +6 -2
  11. package/src/components/editor/floorplan-navigation-presentation.test.ts +0 -40
  12. package/src/components/editor/floorplan-navigation-presentation.ts +0 -66
  13. package/src/components/editor/floorplan-panel.tsx +26 -22
  14. package/src/components/editor/floorplan-wall-typing-keys.ts +1 -1
  15. package/src/components/editor/follow-pascal.test.tsx +1 -2
  16. package/src/components/editor/follow-pascal.tsx +1 -1
  17. package/src/components/editor/footprint-height-handle.tsx +1 -1
  18. package/src/components/editor/group-actions.ts +1 -2
  19. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  20. package/src/components/editor/group-move-3d.ts +1 -2
  21. package/src/components/editor/group-rotate-handle.tsx +1 -2
  22. package/src/components/editor/handles/use-handle-drag.ts +1 -2
  23. package/src/components/editor/index.tsx +24 -14
  24. package/src/components/editor/inspector-extension-actions.tsx +1 -1
  25. package/src/components/editor/node-arrow-handles.tsx +1 -1
  26. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  27. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  28. package/src/components/editor/room-controls.tsx +7 -4
  29. package/src/components/editor/room-handles-2d.tsx +2 -3
  30. package/src/components/editor/room-handles.tsx +1 -2
  31. package/src/components/editor/room-highlight.tsx +6 -105
  32. package/src/components/editor/room-labels.test.ts +8 -822
  33. package/src/components/editor/room-labels.tsx +40 -1671
  34. package/src/components/editor/room-webgpu-materials.test.ts +1 -1
  35. package/src/components/editor/selection-manager.tsx +5 -3
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/snapshot-capture-overlay.tsx +2 -5
  38. package/src/components/editor/use-floorplan-background-placement.ts +2 -1
  39. package/src/components/editor/views/plugins-view.tsx +1 -1
  40. package/src/components/editor/views/view-bar.tsx +9 -9
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +1 -1
  43. package/src/components/editor/wall-pull-grip.tsx +2 -2
  44. package/src/components/editor-2d/floorplan-group-move.tsx +2 -3
  45. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1 -1
  46. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -1
  47. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +1 -2
  48. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +0 -37
  49. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +0 -30
  50. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +13 -3
  51. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +1 -2
  52. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +8 -10
  53. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +25 -33
  54. package/src/components/systems/roof/roof-edit-system.tsx +4 -3
  55. package/src/components/tools/distribution/distribution-run-tool.tsx +1 -1
  56. package/src/components/tools/item/placement-history.test.tsx +1 -1
  57. package/src/components/tools/item/use-placement-coordinator.tsx +2 -3
  58. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +6 -2
  59. package/src/components/tools/registry/move-registry-node-tool.tsx +1 -2
  60. package/src/components/tools/shared/cursor-sphere.tsx +1 -1
  61. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  62. package/src/components/tools/shared/placement-box.tsx +1 -1
  63. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  64. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  65. package/src/components/ui/action-menu/camera-actions.tsx +2 -2
  66. package/src/components/ui/action-menu/control-modes.tsx +1 -1
  67. package/src/components/ui/action-menu/follow-pascal-pill.tsx +1 -1
  68. package/src/components/ui/action-menu/index.tsx +3 -3
  69. package/src/components/ui/action-menu/measurement-control.tsx +3 -3
  70. package/src/components/ui/action-menu/placement.ts +0 -18
  71. package/src/components/ui/action-menu/point-ask-button.tsx +1 -1
  72. package/src/components/ui/action-menu/view-toggles.tsx +5 -5
  73. package/src/components/ui/controls/finish-swatch.tsx +1 -1
  74. package/src/components/ui/controls/material-paint-panel.tsx +2 -2
  75. package/src/components/ui/controls/scene-material-list.tsx +2 -2
  76. package/src/components/ui/controls/select-control.tsx +1 -1
  77. package/src/components/ui/controls/terrain-sculpt-panel.tsx +1 -1
  78. package/src/components/ui/floating-level-selector.tsx +3 -3
  79. package/src/components/ui/helpers/contextual-helper-panel.tsx +1 -1
  80. package/src/components/ui/helpers/helper-manager.test.tsx +1 -1
  81. package/src/components/ui/helpers/helper-manager.tsx +1 -1
  82. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  83. package/src/components/ui/panels/collections/collections-popover.tsx +2 -2
  84. package/src/components/ui/panels/floor-foundation-panel.tsx +2 -2
  85. package/src/components/ui/panels/floor-opening-panel.tsx +4 -4
  86. package/src/components/ui/panels/node-display.ts +1 -1
  87. package/src/components/ui/panels/panel-manager.tsx +1 -1
  88. package/src/components/ui/panels/panel-wrapper.tsx +1 -1
  89. package/src/components/ui/panels/parametric-inspector.tsx +1 -1
  90. package/src/components/ui/panels/room-construction-rows.tsx +6 -6
  91. package/src/components/ui/panels/room-name-options.ts +1 -5
  92. package/src/components/ui/panels/room-panel.tsx +6 -8
  93. package/src/components/ui/primitives/color-dot.tsx +2 -2
  94. package/src/components/ui/primitives/opacity-control.tsx +2 -2
  95. package/src/components/ui/primitives/sidebar.tsx +3 -3
  96. package/src/components/ui/right-stack.tsx +1 -1
  97. package/src/components/ui/sidebar/icon-rail.tsx +1 -1
  98. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  99. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  100. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +1 -1
  101. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +2 -2
  102. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +1 -1
  103. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -1
  104. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +1 -1
  106. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -2
  107. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +1 -1
  108. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  109. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +46 -7
  110. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +1 -1
  111. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +14 -5
  112. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +3 -3
  114. package/src/components/ui/sidebar/tab-bar.tsx +1 -1
  115. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  116. package/src/components/ui/wall-type/wall-type-menu.tsx +1 -1
  117. package/src/components/viewer-overlay.tsx +1 -2
  118. package/src/hooks/use-auto-frame.ts +1 -2
  119. package/src/hooks/use-keyboard.ts +1 -2
  120. package/src/hooks/use-selected-room.ts +5 -7
  121. package/src/i18n/floor-footprint-name.ts +1 -1
  122. package/src/i18n/locales/en/messages.po +180 -182
  123. package/src/i18n/locales/es/messages.po +180 -182
  124. package/src/i18n/locales/fr/messages.po +180 -182
  125. package/src/i18n/locales/ja/messages.po +180 -182
  126. package/src/i18n/locales/zh-CN/messages.po +180 -182
  127. package/src/i18n/locales.test.ts +1 -1
  128. package/src/index.tsx +109 -131
  129. package/src/lib/below-grade.test.tsx +4 -4
  130. package/src/lib/camera-facing.test.ts +1 -1
  131. package/src/lib/camera-facing.ts +2 -3
  132. package/src/lib/editor-level-display.ts +1 -1
  133. package/src/lib/floor-footprints.test.ts +2 -2
  134. package/src/lib/floor-opening-draft.ts +1 -1
  135. package/src/lib/floorplan/annotation-visibility.test.ts +1 -1
  136. package/src/lib/floorplan/annotation-visibility.ts +1 -1
  137. package/src/lib/floorplan/floorplan-export.test.ts +1 -1
  138. package/src/lib/floorplan/floorplan-export.tsx +8 -11
  139. package/src/lib/floorplan/floorplan-extension.ts +4 -157
  140. package/src/lib/floorplan/floorplan-mode.ts +2 -1
  141. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +1 -1
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +8 -6
  143. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +3 -4
  144. package/src/lib/floorplan/geometry.ts +2 -112
  145. package/src/lib/floorplan/index.ts +0 -9
  146. package/src/lib/floorplan/items.ts +2 -1
  147. package/src/lib/floorplan/schedules.ts +6 -3
  148. package/src/lib/floorplan/sheet-export.ts +7 -3
  149. package/src/lib/floorplan/site-plan/site-parts.ts +7 -1
  150. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +1 -1
  151. package/src/lib/follow-pascal-presentation.ts +1 -1
  152. package/src/lib/glb-export.ts +1 -1
  153. package/src/lib/material-paint.ts +1 -1
  154. package/src/lib/measurements.test.ts +2 -1
  155. package/src/lib/mezzanine-selection.test.ts +1 -1
  156. package/src/lib/paint-region-mode.ts +1 -1
  157. package/src/lib/point-ask/session.ts +1 -2
  158. package/src/lib/roof-duplication.test.ts +100 -0
  159. package/src/lib/roof-duplication.ts +30 -5
  160. package/src/lib/room-handle-drag.ts +2 -12
  161. package/src/lib/room-name-catalog.test.ts +2 -1
  162. package/src/lib/room-name-messages.test.ts +2 -1
  163. package/src/lib/room-name-messages.ts +1 -118
  164. package/src/lib/room-name-storage.test.ts +2 -1
  165. package/src/lib/room-selection-commands.ts +2 -2
  166. package/src/lib/room-selection.test.ts +6 -6
  167. package/src/lib/room-transform-session.ts +1 -2
  168. package/src/lib/room-zone-routing.ts +2 -2
  169. package/src/lib/selection-routing.test.ts +0 -28
  170. package/src/lib/selection-routing.ts +1 -40
  171. package/src/lib/sfx-bus.ts +8 -32
  172. package/src/lib/sfx-player.test.ts +72 -1
  173. package/src/lib/sfx-player.ts +49 -337
  174. package/src/lib/viewer-selection.test.ts +38 -10
  175. package/src/lib/wall-pull.test.ts +1 -1
  176. package/src/lib/wall-pull.ts +1 -27
  177. package/src/store/use-editor.tsx +17 -26
  178. package/src/store/use-floorplan-annotation-visibility.ts +5 -5
  179. package/src/store/use-wall-region-handles-selection.ts +1 -2
  180. package/src/components/editor/__bench__/room-label-placement.bench.ts +0 -108
  181. package/src/components/editor/first-person/build-collider-world.test.ts +0 -596
  182. package/src/components/editor/first-person/build-collider-world.ts +0 -549
  183. package/src/components/editor/first-person/ground-holes.test.ts +0 -128
  184. package/src/components/editor/first-person/ground-holes.ts +0 -130
  185. package/src/components/editor/first-person/mechanism-targets.test.ts +0 -162
  186. package/src/components/editor/first-person/mechanism-targets.ts +0 -88
  187. package/src/components/editor/first-person/terrain-collider.test.ts +0 -132
  188. package/src/components/editor/first-person/terrain-collider.ts +0 -212
  189. package/src/components/editor/floorplan-camera-sync.test.ts +0 -249
  190. package/src/components/editor/floorplan-camera-sync.ts +0 -213
  191. package/src/components/editor/localized-html.tsx +0 -22
  192. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +0 -236
  193. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +0 -708
  194. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +0 -385
  195. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +0 -670
  196. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +0 -89
  197. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +0 -106
  198. package/src/components/editor-2d/svg-paths.ts +0 -124
  199. package/src/components/ui/action-menu/action-button.tsx +0 -89
  200. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +0 -42
  201. package/src/components/ui/primitives/button.tsx +0 -69
  202. package/src/components/ui/primitives/dropdown-menu.tsx +0 -232
  203. package/src/components/ui/primitives/popover.tsx +0 -42
  204. package/src/components/ui/primitives/switch.tsx +0 -29
  205. package/src/components/ui/primitives/tooltip.tsx +0 -57
  206. package/src/components/ui/scene-loader.tsx +0 -73
  207. package/src/components/viewer/floorplan-compass-button.tsx +0 -67
  208. package/src/components/viewer/floorplan-preview-geometry.test.ts +0 -107
  209. package/src/components/viewer/floorplan-preview-geometry.ts +0 -281
  210. package/src/components/viewer/floorplan-preview-navigation.test.ts +0 -43
  211. package/src/components/viewer/floorplan-preview-navigation.ts +0 -62
  212. package/src/components/viewer/floorplan-preview-visibility.test.ts +0 -35
  213. package/src/components/viewer/floorplan-preview-visibility.ts +0 -19
  214. package/src/components/viewer/floorplan-preview.tsx +0 -1065
  215. package/src/components/viewer/render-settings-menu.tsx +0 -294
  216. package/src/components/viewer/sky-control.tsx +0 -710
  217. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +0 -140
  218. package/src/components/viewer/use-viewer-ui-sounds.test.ts +0 -15
  219. package/src/components/viewer/use-viewer-ui-sounds.ts +0 -46
  220. package/src/components/viewer/viewer-controls-bar.tsx +0 -496
  221. package/src/components/viewer/viewer-hover-label.tsx +0 -42
  222. package/src/components/viewer/viewer-rooms.tsx +0 -248
  223. package/src/components/viewer/viewer-scene-header.tsx +0 -286
  224. package/src/components/viewer/viewer-selection-manager.tsx +0 -124
  225. package/src/components/viewer/viewer-stage-modes.test.ts +0 -38
  226. package/src/components/viewer/viewer-stage-modes.ts +0 -49
  227. package/src/components/viewer/viewer-stage-switcher.tsx +0 -97
  228. package/src/components/viewer/viewer-stage.test.tsx +0 -47
  229. package/src/components/viewer/viewer-stage.tsx +0 -221
  230. package/src/components/walkthrough-hud.tsx +0 -193
  231. package/src/hooks/use-mobile.ts +0 -19
  232. package/src/i18n/level-name.ts +0 -39
  233. package/src/i18n/scene-theme-name.ts +0 -16
  234. package/src/lib/below-grade.ts +0 -133
  235. package/src/lib/camera-pose.test.ts +0 -237
  236. package/src/lib/camera-pose.ts +0 -172
  237. package/src/lib/floorplan/floorplan-extension.test.ts +0 -42
  238. package/src/lib/floorplan/floorplan-readonly.test.ts +0 -47
  239. package/src/lib/floorplan/floorplan-readonly.ts +0 -95
  240. package/src/lib/floorplan/marks.test.ts +0 -213
  241. package/src/lib/floorplan/marks.ts +0 -238
  242. package/src/lib/measurements.ts +0 -8
  243. package/src/lib/node-display-label.ts +0 -37
  244. package/src/lib/room-assembly-overlay.test.ts +0 -275
  245. package/src/lib/room-assembly-overlay.ts +0 -265
  246. package/src/lib/room-selection.ts +0 -417
  247. package/src/lib/scene-bounds.test.ts +0 -327
  248. package/src/lib/scene-bounds.ts +0 -233
  249. package/src/lib/typing-target.ts +0 -9
  250. package/src/lib/viewer-selection.ts +0 -595
  251. package/src/lib/zone-content.ts +0 -133
  252. package/src/store/camera-pose-store.test.ts +0 -29
  253. package/src/store/camera-pose-store.ts +0 -27
  254. package/src/store/navigation-sync-pose-store.test.ts +0 -34
  255. package/src/store/navigation-sync-pose-store.ts +0 -27
  256. package/src/store/use-audio.test.ts +0 -146
  257. package/src/store/use-audio.tsx +0 -169
  258. package/src/store/use-first-person-hud.ts +0 -40
  259. package/src/store/use-follow-pascal.test.ts +0 -62
  260. package/src/store/use-follow-pascal.ts +0 -53
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
2
- import useAudio from '../store/use-audio'
2
+ import { emitter } from '@pascal-app/core'
3
+ import { configureViewer } from '@pascal-app/viewer'
3
4
 
4
5
  type FakeContext = { id: string }
5
6
 
@@ -93,12 +94,18 @@ const fakeHowler = {
93
94
 
94
95
  mock.module('howler', () => ({ Howl: FakeHowl, Howler: fakeHowler }))
95
96
 
97
+ const sharedSounds = await import('@pascal-app/viewer/sfx')
98
+ const howlsOnImport = instances.length
99
+
100
+ const { useAudioSettings: useAudio } = sharedSounds
101
+
96
102
  const { disposeSFX, playSFX, preloadSFX, startLoopSFX, stopLoopSFX, updateSFXVolumes } =
97
103
  await import('./sfx-player')
98
104
  const { disposeSFXBus, initSFXBus, sfxEmitter, triggerSFX } = await import('./sfx-bus')
99
105
 
100
106
  beforeEach(() => {
101
107
  disposeSFXBus()
108
+ configureViewer({ sfxBaseUrl: '/audios/sfx' })
102
109
  activeContext = { id: 'first' }
103
110
  initialState = 'loaded'
104
111
  useAudio.setState({ masterVolume: 100, muted: false, sfxVolume: 100 })
@@ -108,6 +115,7 @@ beforeEach(() => {
108
115
 
109
116
  afterAll(() => {
110
117
  disposeSFXBus()
118
+ configureViewer({})
111
119
  mock.restore()
112
120
  })
113
121
 
@@ -325,3 +333,66 @@ describe('SFX audio context lifecycle', () => {
325
333
  }
326
334
  })
327
335
  })
336
+
337
+ describe('public viewer SFX entry', () => {
338
+ test('importing the public entry creates no Howl instances', () => {
339
+ expect(howlsOnImport).toBe(0)
340
+ })
341
+
342
+ test('resolves the default and configured SFX origins at playback time', () => {
343
+ configureViewer({})
344
+ sharedSounds.playSound('menuClick')
345
+ expect(
346
+ instances.some((s) => s.src === 'https://editor.pascal.app/audios/sfx/menu_click.mp3'),
347
+ ).toBe(true)
348
+ const previous = [...instances]
349
+ configureViewer({ sfxBaseUrl: 'https://sounds.example/custom/' })
350
+ sharedSounds.playSound('switchOn')
351
+ expect(previous.every((s) => s.unloadCount === 1)).toBe(true)
352
+ expect(instances.find((s) => s.playCount === 1 && s.src?.endsWith('switch_on.mp3'))?.src).toBe(
353
+ 'https://sounds.example/custom/switch_on.mp3',
354
+ )
355
+ configureViewer({ sfxBaseUrl: '/audios/sfx' })
356
+ sharedSounds.playSound('switchOff')
357
+ expect(
358
+ instances.find((s) => s.playCount === 1 && s.src === '/audios/sfx/switch_off.mp3'),
359
+ ).toBeDefined()
360
+ })
361
+
362
+ test('interaction subscriptions share delivery and detach independently and idempotently', () => {
363
+ const first = sharedSounds.initInteractionSounds()
364
+ const second = sharedSounds.initInteractionSounds()
365
+ emitter.emit('interaction:sound', 'switch-on')
366
+ expect(instances.filter((s) => s.playCount === 1)).toHaveLength(1)
367
+ first()
368
+ first()
369
+ emitter.emit('interaction:sound', 'switch-off')
370
+ expect(instances.filter((s) => s.playCount === 1)).toHaveLength(2)
371
+ second()
372
+ emitter.emit('interaction:sound', 'garage')
373
+ expect(instances.filter((s) => s.playCount === 1)).toHaveLength(2)
374
+ })
375
+
376
+ test('interaction playback uses window and panel gains and the shared mute', () => {
377
+ const stop = sharedSounds.initInteractionSounds()
378
+ emitter.emit('interaction:sound', 'window-swing')
379
+ const swing = instances.find((s) => s.src === '/audios/sfx/door_swing.mp3')
380
+ expect(swing?.volumeCalls.find(([, id]) => id === 1)?.[0]).toBeCloseTo(0.6)
381
+ emitter.emit('interaction:sound', 'panel-open')
382
+ const panel = instances.find((s) => s.src === '/audios/sfx/shutters_open.mp3')
383
+ expect(panel?.volumeCalls.find(([, id]) => id === 1)?.[0]).toBeCloseTo(0.75)
384
+ useAudio.getState().setMuted(true)
385
+ emitter.emit('interaction:sound', 'switch-on')
386
+ expect(instances.find((s) => s.src === '/audios/sfx/switch_on.mp3')?.playCount).toBe(0)
387
+ stop()
388
+ })
389
+
390
+ test('viewer and editor UI cues reuse the same Howl cache and settings store', () => {
391
+ preloadSFX()
392
+ const count = instances.length
393
+ sharedSounds.playSound('menuHover')
394
+ playSFX('menuHover')
395
+ expect(instances).toHaveLength(count)
396
+ expect(instances.find((s) => s.src === '/audios/sfx/menu_hover.mp3')?.playCount).toBe(2)
397
+ })
398
+ })
@@ -1,414 +1,126 @@
1
- import { Howl, Howler } from 'howler'
2
- import useAudio from '../store/use-audio'
3
-
4
- // Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
5
- // so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones — enough to kill the
6
- // machine-gun feeling when the same SFX fires in rapid succession.
7
- type SFXConfig = {
8
- // One file, or several pre-rendered variations cycled round-robin per play.
9
- src: string | string[]
10
- // Random playback-rate range applied per play (1 = unchanged).
11
- rateRange?: [number, number]
12
- // Random volume multiplier range applied per play (1 = unchanged).
13
- volumeRange?: [number, number]
14
- // Minimum gap between two plays of this SFX. Triggers within this window
15
- // are silently dropped so bursty sequences don't phase-stack into noise.
16
- minIntervalMs?: number
17
- }
18
-
19
- type LoopSFXConfig = {
20
- src: string
21
- volumeMultiplier: number
22
- fadeInMs?: number
23
- fadeOutMs?: number
24
- }
25
-
26
- const DEFAULT_MIN_INTERVAL_MS = 30
27
- const SFX_FAILURE_BACKOFF_MS = 5_000
28
- const DEFAULT_LOOP_FADE_MS = 90
29
-
30
- // SFX sound definitions
31
- export const SFX: Record<string, SFXConfig> = {
1
+ import {
2
+ disposeSounds,
3
+ playSound,
4
+ preloadSounds,
5
+ SOUNDS,
6
+ type SoundDefinition,
7
+ type SoundPlaybackOptions,
8
+ startSoundLoop,
9
+ stopSoundLoop,
10
+ updateSoundVolumes,
11
+ } from '@pascal-app/viewer/sfx'
12
+
13
+ export const SFX = {
14
+ ...SOUNDS,
32
15
  gridSnap: {
33
- src: [
34
- '/audios/sfx/grid_snap_0.mp3',
35
- '/audios/sfx/grid_snap_1.mp3',
36
- '/audios/sfx/grid_snap_2.mp3',
37
- ],
16
+ src: ['grid_snap_0.mp3', 'grid_snap_1.mp3', 'grid_snap_2.mp3'],
38
17
  rateRange: [0.98, 1.02],
39
18
  volumeRange: [0.5, 0.6],
40
19
  minIntervalMs: 50,
41
20
  },
42
21
  itemDelete: {
43
- src: '/audios/sfx/item_delete.mp3',
22
+ src: 'item_delete.mp3',
44
23
  rateRange: [0.9, 1.1],
45
24
  volumeRange: [0.9, 1.0],
46
25
  },
47
26
  itemPick: {
48
- src: '/audios/sfx/item_pick.mp3',
27
+ src: 'item_pick.mp3',
49
28
  rateRange: [0.95, 1.05],
50
29
  volumeRange: [0.92, 1.0],
51
30
  },
52
31
  itemPlace: {
53
- src: '/audios/sfx/item_place.mp3',
32
+ src: 'item_place.mp3',
54
33
  rateRange: [0.98, 1.02],
55
34
  volumeRange: [0.9, 1.0],
56
35
  },
57
36
  itemRotate: {
58
- src: '/audios/sfx/item_rotate.mp3',
37
+ src: 'item_rotate.mp3',
59
38
  rateRange: [0.94, 1.06],
60
39
  volumeRange: [0.92, 1.0],
61
40
  },
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
41
  resize: {
66
- src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'],
42
+ src: ['resize_0.mp3', 'resize_1.mp3', 'resize_2.mp3'],
67
43
  rateRange: [0.98, 1.02],
68
44
  volumeRange: [0.26, 0.34],
69
45
  minIntervalMs: 80,
70
46
  },
71
- // Fired when a structure draft begins (first click of a wall/slab/etc).
72
47
  structureBuildStart: {
73
- src: '/audios/sfx/structure_build_start.mp3',
48
+ src: 'structure_build_start.mp3',
74
49
  rateRange: [0.95, 1.05],
75
50
  volumeRange: [0.88, 1.0],
76
51
  },
77
- // Fired when a structure is committed (segment placed / polygon closed).
78
52
  structureBuildEnd: {
79
- src: '/audios/sfx/structure_build_end.mp3',
53
+ src: 'structure_build_end.mp3',
80
54
  rateRange: [0.95, 1.05],
81
55
  volumeRange: [0.88, 1.0],
82
56
  },
83
57
  structureDelete: {
84
- src: '/audios/sfx/structure_delete.mp3',
58
+ src: 'structure_delete.mp3',
85
59
  rateRange: [0.9, 1.1],
86
60
  volumeRange: [0.9, 1.0],
87
61
  },
88
62
  snapshotCapture: {
89
63
  // Shutter should sound consistent — no variation.
90
- src: '/audios/sfx/snapshot_capture.mp3',
91
- },
92
- // Soft tick when hovering a main category in the Build / Items panels.
93
- // Kept quiet and rate-locked so sweeping across the grid reads as texture,
94
- // not a melody.
95
- menuHover: {
96
- src: '/audios/sfx/menu_hover.mp3',
97
- rateRange: [0.98, 1.02],
98
- volumeRange: [0.2, 0.3],
99
- minIntervalMs: 0,
100
- },
101
- // Fired when a main category in the Build / Items panels is clicked.
102
- menuClick: {
103
- src: '/audios/sfx/menu_click.mp3',
104
- rateRange: [0.98, 1.02],
105
- volumeRange: [0.5, 0.6],
64
+ src: 'snapshot_capture.mp3',
106
65
  },
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
66
  paintApply: {
110
- src: '/audios/sfx/paint_apply.mp3',
67
+ src: 'paint_apply.mp3',
111
68
  rateRange: [0.95, 1.05],
112
69
  volumeRange: [0.85, 1.0],
113
70
  minIntervalMs: 60,
114
71
  },
115
- // A three-second jingle for finishing something, not a click cue. No pitch or
116
- // volume jitter — a fanfare that lands a semitone off reads as broken rather
117
- // than as varied — and a gap longer than the sound itself, so two milestones
118
- // that land together play once instead of phasing over each other.
119
72
  success: {
120
- src: '/audios/sfx/success.mp3',
73
+ src: 'success.mp3',
121
74
  minIntervalMs: 3_000,
122
75
  },
123
- // An agent's turn, heard from another tab or another app: it ended with its work done, it failed,
124
- // or it waits on the person. Two-note chimes (`scripts/synth-turn-cues.ts`), so no jitter, as for
125
- // `success`. The host plays each once per turn; no gap here could tell two turns from one.
126
- turnDone: { src: '/audios/sfx/turn_done.mp3' },
127
- turnFailed: { src: '/audios/sfx/turn_failed.mp3' },
128
- turnNeedsInput: { src: '/audios/sfx/turn_needs_input.mp3' },
129
- // Operating the building from a viewer's callout or a walkthrough: recorded
130
- // door and shutter movements; the switches and the viewer's hover, select and
131
- // deselect ticks are the marketing house's synth cues (`scripts/synth-viewer-cues.ts`),
132
- // already mixed against the doors, so they play at full volume without jitter.
133
- doorOpen: { src: '/audios/sfx/door_open.mp3', rateRange: [0.96, 1.04], volumeRange: [0.9, 1] },
134
- doorClose: { src: '/audios/sfx/door_close.mp3', rateRange: [0.96, 1.04], volumeRange: [0.9, 1] },
135
- // Air moving as a leaf swings shut, ahead of the latch (`scripts/synth-viewer-cues.ts`).
136
- doorSwing: { src: '/audios/sfx/door_swing.mp3', rateRange: [0.97, 1.03] },
137
- garageDoor: { src: '/audios/sfx/garage_door.mp3', volumeRange: [0.9, 1] },
138
- shuttersOpen: { src: '/audios/sfx/shutters_open.mp3', rateRange: [0.96, 1.04] },
139
- shuttersClose: { src: '/audios/sfx/shutters_close.mp3', rateRange: [0.96, 1.04] },
140
- switchOn: { src: '/audios/sfx/switch_on.mp3' },
141
- switchOff: { src: '/audios/sfx/switch_off.mp3' },
142
- viewerHover: { src: '/audios/sfx/viewer_hover.mp3', minIntervalMs: 95 },
143
- viewerSelect: { src: '/audios/sfx/viewer_select.mp3', minIntervalMs: 90 },
144
- viewerDeselect: { src: '/audios/sfx/viewer_deselect.mp3' },
145
- } as const
76
+ turnDone: { src: 'turn_done.mp3' },
77
+ turnFailed: { src: 'turn_failed.mp3' },
78
+ turnNeedsInput: { src: 'turn_needs_input.mp3' },
79
+ } satisfies Record<string, SoundDefinition>
146
80
 
147
81
  export type SFXName = keyof typeof SFX
148
82
 
149
83
  export const LOOP_SFX = {
150
84
  terrainRaise: {
151
- src: '/audios/sfx/terrain_raise.mp3',
85
+ src: 'terrain_raise.mp3',
86
+ loop: true,
152
87
  volumeMultiplier: 0.2,
153
88
  },
154
89
  terrainLower: {
155
- src: '/audios/sfx/terrain_lower.mp3',
90
+ src: 'terrain_lower.mp3',
91
+ loop: true,
156
92
  volumeMultiplier: 0.2,
157
93
  },
158
94
  terrainFlatten: {
159
- src: '/audios/sfx/terrain_flatten.mp3',
95
+ src: 'terrain_flatten.mp3',
96
+ loop: true,
160
97
  volumeMultiplier: 0.2,
161
98
  },
162
99
  terrainSmooth: {
163
- src: '/audios/sfx/terrain_smooth.mp3',
100
+ src: 'terrain_smooth.mp3',
101
+ loop: true,
164
102
  volumeMultiplier: 0.2,
165
103
  },
166
- } as const satisfies Record<string, LoopSFXConfig>
104
+ } as const satisfies Record<string, SoundDefinition>
167
105
 
168
106
  export type LoopSFXName = keyof typeof LOOP_SFX
169
- type CachedSFXName = SFXName | LoopSFXName
170
-
171
- function loopConfig(name: LoopSFXName): LoopSFXConfig {
172
- return LOOP_SFX[name]
173
- }
174
-
175
- export type SFXPlaybackOptions = {
176
- source?: 'local' | 'remote'
177
- stereo?: number
178
- volumeMultiplier?: number
179
- }
180
-
181
- function randomInRange([min, max]: [number, number]): number {
182
- return min + Math.random() * (max - min)
183
- }
184
-
185
- let sfxCache = new Map<CachedSFXName, Howl[]>()
186
- let sfxAudioContext: AudioContext | null = null
187
- let sfxRetryAfter = 0
188
- const lastPlayedAt = new Map<SFXName, number>()
189
- const lastVariation = new Map<SFXName, number>()
190
- let activeLoop: {
191
- id: number
192
- name: LoopSFXName
193
- sound: Howl
194
- volume: number
195
- } | null = null
196
- let pendingLoop: { name: LoopSFXName; sound: Howl } | null = null
197
- let requestedLoop: LoopSFXName | null = null
198
-
199
- function loopVolume(name: LoopSFXName): number {
200
- const { masterVolume, sfxVolume } = useAudio.getState()
201
- return (masterVolume / 100) * (sfxVolume / 100) * LOOP_SFX[name].volumeMultiplier
202
- }
203
-
204
- function stopActiveLoop(fadeMs: number) {
205
- const active = activeLoop
206
- if (!active) return
207
- activeLoop = null
208
107
 
209
- if (fadeMs <= 0) {
210
- active.sound.stop(active.id)
211
- return
212
- }
213
-
214
- active.sound.once('fade', () => active.sound.stop(active.id), active.id)
215
- active.sound.fade(active.volume, 0, fadeMs, active.id)
216
- }
217
-
218
- function unloadCachedSounds(resetPlaybackState: boolean) {
219
- requestedLoop = null
220
- pendingLoop = null
221
- stopActiveLoop(0)
222
- for (const sounds of sfxCache.values()) {
223
- for (const sound of sounds) {
224
- try {
225
- sound.unload()
226
- } catch {}
227
- }
228
- }
229
- sfxCache.clear()
230
- sfxAudioContext = null
231
- if (resetPlaybackState) {
232
- sfxRetryAfter = 0
233
- lastPlayedAt.clear()
234
- lastVariation.clear()
235
- }
236
- }
237
-
238
- function cacheNeedsRebuild(): boolean {
239
- if (sfxCache.size === 0) return true
240
- if (sfxAudioContext !== Howler.ctx) return true
241
- for (const sounds of sfxCache.values()) {
242
- if (sounds.some((sound) => sound.state() === 'unloaded')) return true
243
- }
244
- return false
245
- }
108
+ export type SFXPlaybackOptions = SoundPlaybackOptions
246
109
 
247
110
  export function preloadSFX() {
248
- if (!cacheNeedsRebuild()) return
249
- unloadCachedSounds(false)
250
-
251
- const definitions = { ...SFX, ...LOOP_SFX }
252
- for (const [name, config] of Object.entries(definitions)) {
253
- const sources = Array.isArray(config.src) ? config.src : [config.src]
254
- sfxCache.set(
255
- name as CachedSFXName,
256
- sources.map(
257
- (src) =>
258
- new Howl({
259
- loop: name in LOOP_SFX,
260
- src: [src],
261
- preload: true,
262
- volume: 0.5,
263
- }),
264
- ),
265
- )
266
- }
267
- sfxAudioContext = Howler.ctx ?? null
111
+ preloadSounds([...Object.values(SFX), ...Object.values(LOOP_SFX)])
268
112
  }
269
113
 
270
- export function disposeSFX() {
271
- unloadCachedSounds(true)
114
+ export function playSFX(name: SFXName, options?: SFXPlaybackOptions) {
115
+ preloadSFX()
116
+ playSound(SFX[name], options)
272
117
  }
273
118
 
274
119
  export function startLoopSFX(name: LoopSFXName) {
275
- const { muted } = useAudio.getState()
276
- if (muted) {
277
- stopLoopSFX()
278
- return
279
- }
280
-
281
- requestedLoop = name
282
- const current = activeLoop
283
- if (current?.name === name && current.sound.playing(current.id)) return
284
- if (current) stopActiveLoop(loopConfig(current.name).fadeOutMs ?? DEFAULT_LOOP_FADE_MS)
285
-
286
- const now = performance.now()
287
- if (now < sfxRetryAfter) return
288
-
289
- try {
290
- preloadSFX()
291
- // A fresh Howler context rebuilds the cache and clears stale playback
292
- // state. Restore this current press after that cleanup so a first-use loop
293
- // can still begin when its asset finishes loading.
294
- requestedLoop = name
295
- const sound = sfxCache.get(name)?.[0]
296
- if (!sound) return
297
- if (sound.state() !== 'loaded') {
298
- if (pendingLoop?.name === name && pendingLoop.sound === sound) return
299
- pendingLoop = { name, sound }
300
- sound.once('load', () => {
301
- if (pendingLoop?.sound === sound) pendingLoop = null
302
- if (requestedLoop === name) startLoopSFX(name)
303
- })
304
- return
305
- }
306
-
307
- const config = loopConfig(name)
308
- const volume = loopVolume(name)
309
- const id = sound.play()
310
- sound.volume(0, id)
311
- activeLoop = { id, name, sound, volume }
312
- sound.fade(0, volume, config.fadeInMs ?? DEFAULT_LOOP_FADE_MS, id)
313
- } catch {
314
- unloadCachedSounds(false)
315
- sfxRetryAfter = now + SFX_FAILURE_BACKOFF_MS
316
- }
120
+ preloadSFX()
121
+ startSoundLoop(LOOP_SFX[name])
317
122
  }
318
123
 
319
- export function stopLoopSFX() {
320
- requestedLoop = null
321
- pendingLoop = null
322
- const fadeMs = activeLoop ? (loopConfig(activeLoop.name).fadeOutMs ?? DEFAULT_LOOP_FADE_MS) : 0
323
- stopActiveLoop(fadeMs)
324
- }
325
-
326
- /**
327
- * Play a sound effect with volume based on audio settings
328
- */
329
- export function playSFX(name: SFXName, options: SFXPlaybackOptions = {}) {
330
- const config = SFX[name]!
331
- const { masterVolume, sfxVolume, muted } = useAudio.getState()
332
-
333
- if (muted) return
334
-
335
- // Drop rapid repeats — two plays of the same SFX within minIntervalMs just
336
- // smear into noise, they don't add useful information.
337
- const now = performance.now()
338
- if (now < sfxRetryAfter) return
339
- const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
340
- const source = options.source ?? 'local'
341
- const playbackKey = `${source}:${name}`
342
- const last = lastPlayedAt.get(playbackKey)
343
- if (last !== undefined && now - last < minInterval) return
344
- // Local feedback stays legible when a collaborator makes the same kind of
345
- // change at nearly the same time; the quieter remote cue yields instead.
346
- const lastLocal = lastPlayedAt.get(`local:${name}`)
347
- if (source === 'remote' && lastLocal !== undefined && now - lastLocal < 120) return
348
- lastPlayedAt.set(playbackKey, now)
349
-
350
- try {
351
- preloadSFX()
352
- const sounds = sfxCache.get(name)
353
- if (!sounds || sounds.length === 0) return
354
-
355
- // Pick a random variation, avoiding an immediate repeat of the last one so
356
- // consecutive plays don't land on the same file.
357
- let index = Math.floor(Math.random() * sounds.length)
358
- if (sounds.length > 1 && index === lastVariation.get(name)) {
359
- index = (index + 1) % sounds.length
360
- }
361
- lastVariation.set(name, index)
362
- const sound = sounds[index]!
363
- // Howler queues per-play mutations while a sound is loading. If its global
364
- // AudioContext is replaced before that queue drains, stereo setup can try
365
- // to connect nodes from different contexts and throw asynchronously.
366
- if (sound.state() !== 'loaded') return
367
- const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
368
- const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
369
- const volumeMultiplier = Number.isFinite(options.volumeMultiplier)
370
- ? Math.max(0, Math.min(2, options.volumeMultiplier!))
371
- : 1
372
- const rate = config.rateRange ? randomInRange(config.rateRange) : 1
373
- const id = sound.play()
374
- sound.volume(baseVolume * volumeJitter * volumeMultiplier, id)
375
- if (Number.isFinite(options.stereo)) {
376
- sound.stereo(Math.max(-1, Math.min(1, options.stereo!)), id)
377
- }
378
- if (rate !== 1) sound.rate(rate, id)
379
- } catch {
380
- // Optional audio must never abort an editor input callback. Rebuild from
381
- // the current Howler context after a backoff instead of retrying every pointer cue.
382
- unloadCachedSounds(false)
383
- sfxRetryAfter = now + SFX_FAILURE_BACKOFF_MS
384
- }
385
- }
386
-
387
- /**
388
- * Update all cached SFX volumes (useful when settings change)
389
- */
390
- export function updateSFXVolumes() {
391
- const { masterVolume, muted, sfxVolume } = useAudio.getState()
392
- if (muted) {
393
- stopLoopSFX()
394
- return
395
- }
396
- const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
397
-
398
- try {
399
- if (performance.now() < sfxRetryAfter) return
400
- preloadSFX()
401
- sfxCache.forEach((sounds) => {
402
- sounds.forEach((sound) => {
403
- sound.volume(finalVolume)
404
- })
405
- })
406
- if (activeLoop) {
407
- activeLoop.volume = loopVolume(activeLoop.name)
408
- activeLoop.sound.volume(activeLoop.volume, activeLoop.id)
409
- }
410
- } catch {
411
- unloadCachedSounds(false)
412
- sfxRetryAfter = performance.now() + SFX_FAILURE_BACKOFF_MS
413
- }
414
- }
124
+ export const stopLoopSFX = stopSoundLoop
125
+ export const updateSFXVolumes = updateSoundVolumes
126
+ export const disposeSFX = disposeSounds
@@ -9,6 +9,8 @@ import {
9
9
  ItemNode,
10
10
  LevelNode,
11
11
  type NodeEvent,
12
+ RoofNode,
13
+ RoofSegmentNode,
12
14
  SlabNode,
13
15
  sceneRegistry,
14
16
  UnitNode,
@@ -18,25 +20,25 @@ import {
18
20
  } from '@pascal-app/core'
19
21
  import { migrateRoomZones } from '@pascal-app/core/scene-migrations'
20
22
  import { resolveGlbPick, useViewer } from '@pascal-app/viewer'
21
- import { CameraControlsImpl } from '@react-three/drei'
22
- import { createElement } from 'react'
23
- import * as THREE from 'three'
24
- import { Group, Vector3 } from 'three'
25
- import { viewerHoverLabel } from '../components/viewer/viewer-hover-label'
26
- import { ViewerSelectionManager } from '../components/viewer/viewer-selection-manager'
27
- import { publishCameraPose } from '../store/camera-pose-store'
28
- import useEditor, { takePreviewCameraRestore } from '../store/use-editor'
29
- import { withSelectionHarness } from '../test-utils/selection-harness'
30
23
  import {
31
24
  frameViewerCamera,
25
+ publishCameraPose,
32
26
  resolveViewerClick,
33
27
  resolveViewerHover,
34
28
  selectViewerRoom,
35
29
  type ViewerSelection,
30
+ ViewerSelectionManager,
36
31
  viewerBreadcrumb,
32
+ viewerHoverLabel,
37
33
  viewerPickFromNodeEvent,
38
34
  viewerSidebarSections,
39
- } from './viewer-selection'
35
+ } from '@pascal-app/viewer/ui'
36
+ import { CameraControlsImpl } from '@react-three/drei'
37
+ import { createElement } from 'react'
38
+ import * as THREE from 'three'
39
+ import { Group, Vector3 } from 'three'
40
+ import useEditor, { takePreviewCameraRestore } from '../store/use-editor'
41
+ import { withSelectionHarness } from '../test-utils/selection-harness'
40
42
 
41
43
  const english = {
42
44
  _: (descriptor: { id: string; message?: string }) => descriptor.message ?? descriptor.id,
@@ -164,6 +166,32 @@ describe('viewer selection rules', () => {
164
166
  }
165
167
  })
166
168
 
169
+ test("a roof's segment or its face of another kind selects the whole roof", () => {
170
+ const segment = RoofSegmentNode.parse({ id: 'rseg_main', parentId: 'roof_main' })
171
+ // A plugin's roof plane (`capabilities.roofs`): the click picks its roof as a segment's does.
172
+ const plane = {
173
+ object: 'node',
174
+ id: 'roofplane_south',
175
+ type: 'test:roof-plane',
176
+ parentId: 'roof_main',
177
+ visible: true,
178
+ metadata: {},
179
+ } as unknown as AnyNode
180
+ const roof = RoofNode.parse({
181
+ id: 'roof_main',
182
+ parentId: levelId,
183
+ children: [segment.id, plane.id],
184
+ })
185
+ const { nodes } = scene([roof])
186
+ nodes[segment.id] = segment
187
+ nodes[plane.id] = plane
188
+ for (const nodeId of [segment.id, plane.id])
189
+ expect(resolveViewerClick(nothing, { nodeId, point: [2, 2] }, nodes)).toMatchObject({
190
+ levelId,
191
+ selectedIds: [roof.id],
192
+ })
193
+ })
194
+
167
195
  test('empty space clears the room and the element and keeps the floor', () => {
168
196
  const { nodes, kitchen } = scene()
169
197
  const drilled: ViewerSelection = {
@@ -12,6 +12,7 @@ import {
12
12
  WallNode as WallSchema,
13
13
  wallAssemblyPatch,
14
14
  wallClosesRoom,
15
+ wallPullSeed,
15
16
  } from '@pascal-app/core'
16
17
  import { useViewer } from '@pascal-app/viewer'
17
18
  import { createWallOnCurrentLevel } from '../components/tools/wall/wall-drafting'
@@ -23,7 +24,6 @@ import {
23
24
  startWallPull,
24
25
  useWallPullRun,
25
26
  wallDraftIsRun,
26
- wallPullSeed,
27
27
  wallPullTypePatch,
28
28
  } from './wall-pull'
29
29
  import { isWallRunFree, snapWallRunDraftPoint, wallRunDirection } from './wall-run-snap'