@pascal-app/editor 0.9.2 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +236 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -16,8 +16,16 @@ type SFXConfig = {
16
16
  minIntervalMs?: number
17
17
  }
18
18
 
19
+ type LoopSFXConfig = {
20
+ src: string
21
+ volumeMultiplier: number
22
+ fadeInMs?: number
23
+ fadeOutMs?: number
24
+ }
25
+
19
26
  const DEFAULT_MIN_INTERVAL_MS = 30
20
27
  const SFX_FAILURE_BACKOFF_MS = 5_000
28
+ const DEFAULT_LOOP_FADE_MS = 90
21
29
 
22
30
  // SFX sound definitions
23
31
  export const SFX: Record<string, SFXConfig> = {
@@ -108,17 +116,79 @@ export const SFX: Record<string, SFXConfig> = {
108
116
 
109
117
  export type SFXName = keyof typeof SFX
110
118
 
119
+ export const LOOP_SFX = {
120
+ terrainRaise: {
121
+ src: '/audios/sfx/terrain_raise.mp3',
122
+ volumeMultiplier: 0.2,
123
+ },
124
+ terrainLower: {
125
+ src: '/audios/sfx/terrain_lower.mp3',
126
+ volumeMultiplier: 0.2,
127
+ },
128
+ terrainFlatten: {
129
+ src: '/audios/sfx/terrain_flatten.mp3',
130
+ volumeMultiplier: 0.2,
131
+ },
132
+ terrainSmooth: {
133
+ src: '/audios/sfx/terrain_smooth.mp3',
134
+ volumeMultiplier: 0.2,
135
+ },
136
+ } as const satisfies Record<string, LoopSFXConfig>
137
+
138
+ export type LoopSFXName = keyof typeof LOOP_SFX
139
+ type CachedSFXName = SFXName | LoopSFXName
140
+
141
+ function loopConfig(name: LoopSFXName): LoopSFXConfig {
142
+ return LOOP_SFX[name]
143
+ }
144
+
145
+ export type SFXPlaybackOptions = {
146
+ source?: 'local' | 'remote'
147
+ stereo?: number
148
+ volumeMultiplier?: number
149
+ }
150
+
111
151
  function randomInRange([min, max]: [number, number]): number {
112
152
  return min + Math.random() * (max - min)
113
153
  }
114
154
 
115
- let sfxCache = new Map<SFXName, Howl[]>()
155
+ let sfxCache = new Map<CachedSFXName, Howl[]>()
116
156
  let sfxAudioContext: AudioContext | null = null
117
157
  let sfxRetryAfter = 0
118
158
  const lastPlayedAt = new Map<SFXName, number>()
119
159
  const lastVariation = new Map<SFXName, number>()
160
+ let activeLoop: {
161
+ id: number
162
+ name: LoopSFXName
163
+ sound: Howl
164
+ volume: number
165
+ } | null = null
166
+ let pendingLoop: { name: LoopSFXName; sound: Howl } | null = null
167
+ let requestedLoop: LoopSFXName | null = null
168
+
169
+ function loopVolume(name: LoopSFXName): number {
170
+ const { masterVolume, sfxVolume } = useAudio.getState()
171
+ return (masterVolume / 100) * (sfxVolume / 100) * LOOP_SFX[name].volumeMultiplier
172
+ }
173
+
174
+ function stopActiveLoop(fadeMs: number) {
175
+ const active = activeLoop
176
+ if (!active) return
177
+ activeLoop = null
178
+
179
+ if (fadeMs <= 0) {
180
+ active.sound.stop(active.id)
181
+ return
182
+ }
183
+
184
+ active.sound.once('fade', () => active.sound.stop(active.id), active.id)
185
+ active.sound.fade(active.volume, 0, fadeMs, active.id)
186
+ }
120
187
 
121
188
  function unloadCachedSounds(resetPlaybackState: boolean) {
189
+ requestedLoop = null
190
+ pendingLoop = null
191
+ stopActiveLoop(0)
122
192
  for (const sounds of sfxCache.values()) {
123
193
  for (const sound of sounds) {
124
194
  try {
@@ -148,13 +218,15 @@ export function preloadSFX() {
148
218
  if (!cacheNeedsRebuild()) return
149
219
  unloadCachedSounds(false)
150
220
 
151
- for (const [name, config] of Object.entries(SFX)) {
221
+ const definitions = { ...SFX, ...LOOP_SFX }
222
+ for (const [name, config] of Object.entries(definitions)) {
152
223
  const sources = Array.isArray(config.src) ? config.src : [config.src]
153
224
  sfxCache.set(
154
- name as SFXName,
225
+ name as CachedSFXName,
155
226
  sources.map(
156
227
  (src) =>
157
228
  new Howl({
229
+ loop: name in LOOP_SFX,
158
230
  src: [src],
159
231
  preload: true,
160
232
  volume: 0.5,
@@ -169,10 +241,62 @@ export function disposeSFX() {
169
241
  unloadCachedSounds(true)
170
242
  }
171
243
 
244
+ export function startLoopSFX(name: LoopSFXName) {
245
+ const { muted } = useAudio.getState()
246
+ if (muted) {
247
+ stopLoopSFX()
248
+ return
249
+ }
250
+
251
+ requestedLoop = name
252
+ const current = activeLoop
253
+ if (current?.name === name && current.sound.playing(current.id)) return
254
+ if (current) stopActiveLoop(loopConfig(current.name).fadeOutMs ?? DEFAULT_LOOP_FADE_MS)
255
+
256
+ const now = performance.now()
257
+ if (now < sfxRetryAfter) return
258
+
259
+ try {
260
+ preloadSFX()
261
+ // A fresh Howler context rebuilds the cache and clears stale playback
262
+ // state. Restore this current press after that cleanup so a first-use loop
263
+ // can still begin when its asset finishes loading.
264
+ requestedLoop = name
265
+ const sound = sfxCache.get(name)?.[0]
266
+ if (!sound) return
267
+ if (sound.state() !== 'loaded') {
268
+ if (pendingLoop?.name === name && pendingLoop.sound === sound) return
269
+ pendingLoop = { name, sound }
270
+ sound.once('load', () => {
271
+ if (pendingLoop?.sound === sound) pendingLoop = null
272
+ if (requestedLoop === name) startLoopSFX(name)
273
+ })
274
+ return
275
+ }
276
+
277
+ const config = loopConfig(name)
278
+ const volume = loopVolume(name)
279
+ const id = sound.play()
280
+ sound.volume(0, id)
281
+ activeLoop = { id, name, sound, volume }
282
+ sound.fade(0, volume, config.fadeInMs ?? DEFAULT_LOOP_FADE_MS, id)
283
+ } catch {
284
+ unloadCachedSounds(false)
285
+ sfxRetryAfter = now + SFX_FAILURE_BACKOFF_MS
286
+ }
287
+ }
288
+
289
+ export function stopLoopSFX() {
290
+ requestedLoop = null
291
+ pendingLoop = null
292
+ const fadeMs = activeLoop ? (loopConfig(activeLoop.name).fadeOutMs ?? DEFAULT_LOOP_FADE_MS) : 0
293
+ stopActiveLoop(fadeMs)
294
+ }
295
+
172
296
  /**
173
297
  * Play a sound effect with volume based on audio settings
174
298
  */
175
- export function playSFX(name: SFXName) {
299
+ export function playSFX(name: SFXName, options: SFXPlaybackOptions = {}) {
176
300
  const config = SFX[name]!
177
301
  const { masterVolume, sfxVolume, muted } = useAudio.getState()
178
302
 
@@ -183,9 +307,15 @@ export function playSFX(name: SFXName) {
183
307
  const now = performance.now()
184
308
  if (now < sfxRetryAfter) return
185
309
  const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
186
- const last = lastPlayedAt.get(name)
310
+ const source = options.source ?? 'local'
311
+ const playbackKey = `${source}:${name}`
312
+ const last = lastPlayedAt.get(playbackKey)
187
313
  if (last !== undefined && now - last < minInterval) return
188
- lastPlayedAt.set(name, now)
314
+ // Local feedback stays legible when a collaborator makes the same kind of
315
+ // change at nearly the same time; the quieter remote cue yields instead.
316
+ const lastLocal = lastPlayedAt.get(`local:${name}`)
317
+ if (source === 'remote' && lastLocal !== undefined && now - lastLocal < 120) return
318
+ lastPlayedAt.set(playbackKey, now)
189
319
 
190
320
  try {
191
321
  preloadSFX()
@@ -200,11 +330,21 @@ export function playSFX(name: SFXName) {
200
330
  }
201
331
  lastVariation.set(name, index)
202
332
  const sound = sounds[index]!
333
+ // Howler queues per-play mutations while a sound is loading. If its global
334
+ // AudioContext is replaced before that queue drains, stereo setup can try
335
+ // to connect nodes from different contexts and throw asynchronously.
336
+ if (sound.state() !== 'loaded') return
203
337
  const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
204
338
  const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
339
+ const volumeMultiplier = Number.isFinite(options.volumeMultiplier)
340
+ ? Math.max(0, Math.min(2, options.volumeMultiplier!))
341
+ : 1
205
342
  const rate = config.rateRange ? randomInRange(config.rateRange) : 1
206
343
  const id = sound.play()
207
- sound.volume(baseVolume * volumeJitter, id)
344
+ sound.volume(baseVolume * volumeJitter * volumeMultiplier, id)
345
+ if (Number.isFinite(options.stereo)) {
346
+ sound.stereo(Math.max(-1, Math.min(1, options.stereo!)), id)
347
+ }
208
348
  if (rate !== 1) sound.rate(rate, id)
209
349
  } catch {
210
350
  // Optional audio must never abort an editor input callback. Rebuild from
@@ -218,7 +358,11 @@ export function playSFX(name: SFXName) {
218
358
  * Update all cached SFX volumes (useful when settings change)
219
359
  */
220
360
  export function updateSFXVolumes() {
221
- const { masterVolume, sfxVolume } = useAudio.getState()
361
+ const { masterVolume, muted, sfxVolume } = useAudio.getState()
362
+ if (muted) {
363
+ stopLoopSFX()
364
+ return
365
+ }
222
366
  const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
223
367
 
224
368
  try {
@@ -229,6 +373,10 @@ export function updateSFXVolumes() {
229
373
  sound.volume(finalVolume)
230
374
  })
231
375
  })
376
+ if (activeLoop) {
377
+ activeLoop.volume = loopVolume(activeLoop.name)
378
+ activeLoop.sound.volume(activeLoop.volume, activeLoop.id)
379
+ }
232
380
  } catch {
233
381
  unloadCachedSounds(false)
234
382
  sfxRetryAfter = performance.now() + SFX_FAILURE_BACKOFF_MS
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { Mode, Phase } from '../store/use-editor'
3
+ import { siteBoundaryHandlesEnabled } from './site-boundary'
4
+
5
+ /**
6
+ * The site handles are the one affordance both views draw from the same state, so
7
+ * they are also the one place a 2D/3D parity break hides: each pane used to derive
8
+ * this rule locally, and the sculpt exclusion existed in the 3D copy only.
9
+ */
10
+
11
+ const MODES: Mode[] = ['select', 'edit', 'delete', 'build', 'material-paint', 'terrain-sculpt']
12
+ const PHASES: Phase[] = ['site', 'structure', 'furnish']
13
+
14
+ describe('siteBoundaryHandlesEnabled', () => {
15
+ test('sculpt hides the handles in every phase', () => {
16
+ // Including `site` — which is the case that matters, because arming sculpt
17
+ // *promotes* the phase to `site`, so this is the only combination the brush is
18
+ // ever actually in.
19
+ for (const phase of PHASES) {
20
+ expect(siteBoundaryHandlesEnabled({ mode: 'terrain-sculpt', phase })).toBe(false)
21
+ }
22
+ })
23
+
24
+ test('the site phase offers them in every other mode', () => {
25
+ for (const mode of MODES.filter((m) => m !== 'terrain-sculpt')) {
26
+ expect(siteBoundaryHandlesEnabled({ mode, phase: 'site' })).toBe(true)
27
+ }
28
+ })
29
+
30
+ test('select mode offers them outside the site phase', () => {
31
+ // The flags double as the affordance that *enters* site editing, so select mode
32
+ // has to show them from structure/furnish or the lot becomes unreachable.
33
+ expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'structure' })).toBe(true)
34
+ expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'furnish' })).toBe(true)
35
+ })
36
+
37
+ test('a drafting or painting mode outside the site phase hides them', () => {
38
+ for (const mode of ['edit', 'delete', 'build', 'material-paint'] as Mode[]) {
39
+ expect(siteBoundaryHandlesEnabled({ mode, phase: 'structure' })).toBe(false)
40
+ expect(siteBoundaryHandlesEnabled({ mode, phase: 'furnish' })).toBe(false)
41
+ }
42
+ })
43
+
44
+ test('sculpt is excluded before the phase term, not alongside it', () => {
45
+ // Ordering is the whole fix: `phase === 'site' || mode === 'select'` alone is
46
+ // *true* while sculpting, so an implementation that ORs the sculpt check in
47
+ // rather than early-returning would still show the handles.
48
+ const sculptInSite = siteBoundaryHandlesEnabled({ mode: 'terrain-sculpt', phase: 'site' })
49
+ const selectInSite = siteBoundaryHandlesEnabled({ mode: 'select', phase: 'site' })
50
+ expect(sculptInSite).toBe(false)
51
+ expect(selectInSite).toBe(true)
52
+ })
53
+ })
@@ -1 +1,28 @@
1
+ import type { Mode, Phase } from '../store/use-editor'
2
+
1
3
  export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary'
4
+
5
+ /**
6
+ * Whether the site's vertex/midpoint handles are grabbable right now.
7
+ *
8
+ * Shared by the 3D boundary editor (`tool-manager.tsx`) and the floorplan's SVG
9
+ * handles (`floorplan-panel.tsx`) because they must agree, and until this existed
10
+ * they did not: each derived the rule from `phase`/`mode` locally, and the sculpt
11
+ * clause was added to only one of them.
12
+ *
13
+ * The disagreement was reachable in exactly one configuration, which is why it was
14
+ * easy to miss: arming sculpt from the 2D view promotes `viewMode` to `split` *and*
15
+ * the phase to `site`, so the floorplan's `phase === 'site'` term went true at the
16
+ * same moment the 3D editor's `!sculpting` term went false — handles gone in one
17
+ * pane, live in the other. Grabbing one then calls `begin({kind: 'handle-drag'})`,
18
+ * which atomically replaces the held `sculpting` scope: the brush stays armed and
19
+ * still paints, while the rest of the editor believes a boundary drag is running.
20
+ *
21
+ * Sculpt is excluded rather than merely deprioritised because the flags sit *on* the
22
+ * ground the brush is aimed at — leaving them mounted puts a grabbable handle under
23
+ * every dab near the lot line, in either view.
24
+ */
25
+ export function siteBoundaryHandlesEnabled(args: { mode: Mode; phase: Phase }): boolean {
26
+ if (args.mode === 'terrain-sculpt') return false
27
+ return args.phase === 'site' || args.mode === 'select'
28
+ }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
+ import { ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
2
3
  import {
3
4
  cycleSnappingModeIn,
4
5
  DEFAULT_SNAPPING_MODE,
@@ -84,11 +85,20 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
84
85
  zone: 'structural',
85
86
  }
86
87
  const profileOf = (t: string) => declared[t]
88
+ const profileOfNode = (id: string) =>
89
+ id === 'cabinet-module_1' ? declared.item : id === 'wall_1' ? declared.wall : undefined
87
90
  const ctx = (
88
- scope: { kind: string; nodeType?: string; reshape?: string; tool?: string },
91
+ scope: {
92
+ kind: string
93
+ nodeType?: string
94
+ reshape?: string
95
+ nodeId?: string
96
+ tool?: string
97
+ handle?: string
98
+ },
89
99
  mode = 'select',
90
100
  tool: string | null = null,
91
- ) => snapContextOf({ scope, mode, tool, profileOf })
101
+ ) => snapContextOf({ scope, mode, tool, profileOf, profileOfNode })
92
102
 
93
103
  it('translating a whole structural node has no angle (polygon, not wall)', () => {
94
104
  expect(ctx({ kind: 'moving', nodeType: 'wall' })).toBe('polygon')
@@ -96,6 +106,15 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
96
106
  expect(ctx({ kind: 'placing', nodeType: 'item' }, 'build', 'item')).toBe('item')
97
107
  })
98
108
 
109
+ it('resolves handle drags from the target node profile', () => {
110
+ expect(ctx({ kind: 'handle-drag', nodeId: 'cabinet-module_1' })).toBe('item')
111
+ expect(ctx({ kind: 'handle-drag', nodeId: 'wall_1' })).toBe('polygon')
112
+ expect(ctx({ kind: 'handle-drag', nodeId: 'unknown_1' })).toBeNull()
113
+ expect(
114
+ ctx({ kind: 'handle-drag', nodeId: 'cabinet-module_1', handle: ROTATE_HANDLE_DRAG_LABEL }),
115
+ ).toBeNull()
116
+ })
117
+
99
118
  it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => {
100
119
  expect(ctx({ kind: 'reshaping', reshape: 'endpoint' })).toBe('wall')
101
120
  expect(ctx({ kind: 'reshaping', reshape: 'curve' })).toBe('polygon')
@@ -115,6 +134,14 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
115
134
  expect(ctx({ kind: 'idle' }, 'build', 'shelf')).toBeNull()
116
135
  })
117
136
 
137
+ it('the room-preset stamp tool (not a node kind) resolves to polygon', () => {
138
+ // The host app's room stamp drives placement with `tool='room'`, which has
139
+ // no registry entry — the tool map must still give it the no-angle set so
140
+ // Shift cycling and the HUD chip work during preset placement.
141
+ expect(ctx({ kind: 'idle' }, 'build', 'room')).toBe('polygon')
142
+ expect(ctx({ kind: 'idle' }, 'select', 'room')).toBeNull()
143
+ })
144
+
118
145
  it('drafting a non-directional structural kind is angle-less (polygon, not wall)', () => {
119
146
  // Roof / stair / elevator are placed as footprints, not directional draws →
120
147
  // declared `snapDraftDirectional: false`, so their draft context drops the
@@ -1,5 +1,5 @@
1
1
  import type { SnapProfile } from '@pascal-app/core'
2
- import { GROUP_MOVE_DRAG_LABEL } from './contextual-help'
2
+ import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
3
3
 
4
4
  /**
5
5
  * Snapping mode is a single global, user-cyclable control that maps onto the
@@ -122,6 +122,13 @@ function contextForProfile(
122
122
  return null
123
123
  }
124
124
 
125
+ // Tools that are not registered node kinds (no `snapProfile` to look up) but
126
+ // still place a whole footprint — the host app's room-preset stamp. A stamp is
127
+ // a whole-footprint translate: no direction to set → the no-angle 'polygon'
128
+ // set. Without an entry here the tool has no snap context at all, so Shift
129
+ // cycling and the HUD chip stay dead while it drives placement.
130
+ const TOOL_SNAP_CONTEXTS: Record<string, SnapContext> = { room: 'polygon' }
131
+
125
132
  /**
126
133
  * The active snapping context, derived from what the user is doing — fully
127
134
  * node-declared: the kind's `snapProfile` (looked up via the injected
@@ -145,12 +152,13 @@ export function snapContextOf(args: {
145
152
  mode: string
146
153
  tool: string | null
147
154
  profileOf: (typeOrTool: string) => SnapProfile | undefined
155
+ profileOfNode?: (nodeId: string) => SnapProfile | undefined
148
156
  // Whether drafting a kind sets a direction (angle-lock meaningful). Injected
149
157
  // like `profileOf` so `snapping-mode` need not import the registry; defaults
150
158
  // to `true` (the structural draw default) when not supplied.
151
159
  draftDirectionalOf?: (typeOrTool: string) => boolean
152
160
  }): SnapContext | null {
153
- const { scope, mode, tool, profileOf, draftDirectionalOf } = args
161
+ const { scope, mode, tool, profileOf, profileOfNode, draftDirectionalOf } = args
154
162
  // The group-move gizmo translates the whole selection — same no-angle
155
163
  // treatment as a single-node move, so Shift cycles the 'item' modes and the
156
164
  // HUD shows the item snapping chips for the drag.
@@ -158,6 +166,9 @@ export function snapContextOf(args: {
158
166
  return 'item'
159
167
  }
160
168
  switch (scope.kind) {
169
+ case 'handle-drag':
170
+ if (scope.handle === ROTATE_HANDLE_DRAG_LABEL) return null
171
+ return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null
161
172
  case 'placing':
162
173
  case 'moving':
163
174
  // A whole-node translate never sets direction → no angle.
@@ -173,7 +184,8 @@ export function snapContextOf(args: {
173
184
  : null
174
185
  default:
175
186
  return mode === 'build' && tool
176
- ? contextForProfile(profileOf(tool), draftDirectionalOf?.(tool) ?? true)
187
+ ? (TOOL_SNAP_CONTEXTS[tool] ??
188
+ contextForProfile(profileOf(tool), draftDirectionalOf?.(tool) ?? true))
177
189
  : null
178
190
  }
179
191
  }
@@ -0,0 +1,103 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ LevelNode,
6
+ StairNode,
7
+ StairSegmentNode,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
12
+ import { duplicateStairSubtree } from './stair-duplication'
13
+
14
+ const LEVEL_ID = 'level_stair-duplicate' as AnyNodeId
15
+ const STAIR_ID = 'stair_original' as AnyNodeId
16
+ const SEGMENT_ID = 'sseg_original' as AnyNodeId
17
+
18
+ function seedStraightStair() {
19
+ const level = LevelNode.parse({
20
+ id: LEVEL_ID,
21
+ type: 'level',
22
+ children: [STAIR_ID],
23
+ })
24
+ const stair = StairNode.parse({
25
+ id: STAIR_ID,
26
+ type: 'stair',
27
+ parentId: LEVEL_ID,
28
+ children: [SEGMENT_ID],
29
+ position: [2, 0, 3],
30
+ })
31
+ const segment = StairSegmentNode.parse({
32
+ id: SEGMENT_ID,
33
+ type: 'stair-segment',
34
+ parentId: STAIR_ID,
35
+ })
36
+ useScene.setState({
37
+ nodes: {
38
+ [LEVEL_ID]: level as AnyNode,
39
+ [STAIR_ID]: stair as AnyNode,
40
+ [SEGMENT_ID]: segment as AnyNode,
41
+ },
42
+ rootNodeIds: [LEVEL_ID],
43
+ collections: {},
44
+ dirtyNodes: new Set(),
45
+ } as never)
46
+ }
47
+
48
+ describe('duplicateStairSubtree', () => {
49
+ beforeEach(() => {
50
+ useInteractionScope.getState().end()
51
+ useViewer.getState().setSelection({ selectedIds: [STAIR_ID] })
52
+ useScene.temporal.getState().clear()
53
+ useScene.temporal.getState().resume()
54
+ seedStraightStair()
55
+ })
56
+
57
+ afterEach(() => {
58
+ useInteractionScope.getState().end()
59
+ useScene.temporal.getState().resume()
60
+ useViewer.getState().setSelection({ selectedIds: [] })
61
+ })
62
+
63
+ test('moves the exact fresh scene subtree and clears the original selection', () => {
64
+ const result = duplicateStairSubtree(STAIR_ID, { mode: 'move' })
65
+ const nodes = useScene.getState().nodes
66
+ const draft = nodes[result.stair.id as AnyNodeId]
67
+
68
+ expect(result.stair.id).not.toBe(STAIR_ID)
69
+ expect(draft).toBe(result.stair)
70
+ expect(getMovingNode()?.id).toBe(result.stair.id)
71
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
72
+ expect((result.stair.metadata as Record<string, unknown>)?.isNew).toBe(true)
73
+ expect(result.stair.position).toEqual([3, 0, 4])
74
+ expect(result.segmentIds).toHaveLength(1)
75
+ expect(result.segmentIds[0]).not.toBe(SEGMENT_ID)
76
+ expect(nodes[result.segmentIds[0] as AnyNodeId]?.parentId).toBe(result.stair.id)
77
+ expect(nodes[STAIR_ID]).toBeDefined()
78
+ expect(nodes[SEGMENT_ID]).toBeDefined()
79
+ expect(useScene.temporal.getState().isTracking).toBe(false)
80
+ })
81
+
82
+ test('keeps childless curved stairs on the same real draft path', () => {
83
+ const curved = StairNode.parse({
84
+ ...(useScene.getState().nodes[STAIR_ID] as AnyNode),
85
+ children: [],
86
+ stairType: 'curved',
87
+ })
88
+ useScene.setState((state) => ({
89
+ nodes: {
90
+ ...state.nodes,
91
+ [LEVEL_ID]: { ...state.nodes[LEVEL_ID], children: [STAIR_ID] } as AnyNode,
92
+ [STAIR_ID]: curved as AnyNode,
93
+ },
94
+ }))
95
+
96
+ const result = duplicateStairSubtree(STAIR_ID, { mode: 'move', offset: [0, 0, 0] })
97
+
98
+ expect(result.segmentIds).toEqual([])
99
+ expect(useScene.getState().nodes[result.stair.id as AnyNodeId]).toBe(result.stair)
100
+ expect(getMovingNode()?.id).toBe(result.stair.id)
101
+ expect((result.stair.metadata as Record<string, unknown>)?.isNew).toBe(true)
102
+ })
103
+ })