@pascal-app/editor 0.9.2 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +265 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
package/src/lib/sfx-player.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
})
|
package/src/lib/site-boundary.ts
CHANGED
|
@@ -1 +1,28 @@
|
|
|
1
|
+
import type { Mode, Phase } from '../store/use-editor'
|
|
2
|
+
|
|
1
3
|
export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether the site's vertex/midpoint handles are grabbable right now.
|
|
7
|
+
*
|
|
8
|
+
* Shared by the 3D boundary editor (`tool-manager.tsx`) and the floorplan's SVG
|
|
9
|
+
* handles (`floorplan-panel.tsx`) because they must agree, and until this existed
|
|
10
|
+
* they did not: each derived the rule from `phase`/`mode` locally, and the sculpt
|
|
11
|
+
* clause was added to only one of them.
|
|
12
|
+
*
|
|
13
|
+
* The disagreement was reachable in exactly one configuration, which is why it was
|
|
14
|
+
* easy to miss: arming sculpt from the 2D view promotes `viewMode` to `split` *and*
|
|
15
|
+
* the phase to `site`, so the floorplan's `phase === 'site'` term went true at the
|
|
16
|
+
* same moment the 3D editor's `!sculpting` term went false — handles gone in one
|
|
17
|
+
* pane, live in the other. Grabbing one then calls `begin({kind: 'handle-drag'})`,
|
|
18
|
+
* which atomically replaces the held `sculpting` scope: the brush stays armed and
|
|
19
|
+
* still paints, while the rest of the editor believes a boundary drag is running.
|
|
20
|
+
*
|
|
21
|
+
* Sculpt is excluded rather than merely deprioritised because the flags sit *on* the
|
|
22
|
+
* ground the brush is aimed at — leaving them mounted puts a grabbable handle under
|
|
23
|
+
* every dab near the lot line, in either view.
|
|
24
|
+
*/
|
|
25
|
+
export function siteBoundaryHandlesEnabled(args: { mode: Mode; phase: Phase }): boolean {
|
|
26
|
+
if (args.mode === 'terrain-sculpt') return false
|
|
27
|
+
return args.phase === 'site' || args.mode === 'select'
|
|
28
|
+
}
|
|
@@ -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: {
|
|
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
|
package/src/lib/snapping-mode.ts
CHANGED
|
@@ -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
|
-
?
|
|
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
|
+
})
|