@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.
- 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 +236 -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
|
@@ -2,8 +2,12 @@ import {
|
|
|
2
2
|
AnyNode,
|
|
3
3
|
type AnyNodeId,
|
|
4
4
|
generateId,
|
|
5
|
+
generateSceneMaterialId,
|
|
5
6
|
type LevelNode,
|
|
7
|
+
nodeRegistry,
|
|
6
8
|
remapMeasurementReferences,
|
|
9
|
+
SceneMaterial,
|
|
10
|
+
type SceneMaterialId,
|
|
7
11
|
type StairNode,
|
|
8
12
|
useScene,
|
|
9
13
|
} from '@pascal-app/core'
|
|
@@ -11,18 +15,25 @@ import { useViewer } from '@pascal-app/viewer'
|
|
|
11
15
|
|
|
12
16
|
type ClipboardPayload = {
|
|
13
17
|
copiedAt: number
|
|
18
|
+
materials: SceneMaterial[]
|
|
14
19
|
nodes: AnyNode[]
|
|
15
20
|
rootIds: AnyNodeId[]
|
|
16
21
|
}
|
|
17
22
|
|
|
18
|
-
type PasteResult = {
|
|
23
|
+
export type PasteResult = {
|
|
24
|
+
createdMaterialIds: SceneMaterialId[]
|
|
19
25
|
pastedIds: AnyNodeId[]
|
|
20
26
|
skippedIds: AnyNodeId[]
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
const SYSTEM_CLIPBOARD_KIND = 'pascal.scene-nodes'
|
|
30
|
+
const SYSTEM_CLIPBOARD_VERSION = 1
|
|
31
|
+
|
|
23
32
|
const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
|
|
24
33
|
'wall',
|
|
25
34
|
'fence',
|
|
35
|
+
'door',
|
|
36
|
+
'window',
|
|
26
37
|
'column',
|
|
27
38
|
'item',
|
|
28
39
|
'slab',
|
|
@@ -37,6 +48,7 @@ const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
|
|
|
37
48
|
])
|
|
38
49
|
|
|
39
50
|
let clipboardPayload: ClipboardPayload | null = null
|
|
51
|
+
let pendingSystemClipboardWrite: Promise<boolean> | null = null
|
|
40
52
|
const subscribers = new Set<() => void>()
|
|
41
53
|
|
|
42
54
|
function notifySubscribers() {
|
|
@@ -97,10 +109,29 @@ function hasSelectedAncestor(
|
|
|
97
109
|
return false
|
|
98
110
|
}
|
|
99
111
|
|
|
100
|
-
function
|
|
112
|
+
function isClipboardRoot(
|
|
113
|
+
nodes: Record<AnyNodeId, AnyNode>,
|
|
114
|
+
node: AnyNode,
|
|
115
|
+
allowHostedOpening: boolean,
|
|
116
|
+
) {
|
|
101
117
|
const parentId = node.parentId as AnyNodeId | null
|
|
102
118
|
if (!parentId) return true
|
|
103
|
-
|
|
119
|
+
const parent = nodes[parentId]
|
|
120
|
+
if (parent?.type === 'level') return true
|
|
121
|
+
if (
|
|
122
|
+
allowHostedOpening &&
|
|
123
|
+
(node.type === 'door' || node.type === 'window') &&
|
|
124
|
+
(parent?.type === 'wall' || parent?.type === 'roof-segment')
|
|
125
|
+
) {
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
128
|
+
return parent?.type === 'building' && nodeRegistry.get(node.type)?.floorplanScope === 'building'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isCopyableRootType(node: AnyNode) {
|
|
132
|
+
if (COPYABLE_ROOT_TYPES.has(node.type)) return true
|
|
133
|
+
const definition = nodeRegistry.get(node.type)
|
|
134
|
+
return !!definition && definition.capabilities?.duplicable !== false
|
|
104
135
|
}
|
|
105
136
|
|
|
106
137
|
function getPromotedCabinetRunId(
|
|
@@ -160,14 +191,28 @@ function remapNodeReferences(
|
|
|
160
191
|
oldId: AnyNodeId,
|
|
161
192
|
targetLevel: LevelNode,
|
|
162
193
|
idMap: Map<AnyNodeId, AnyNodeId>,
|
|
194
|
+
materialIdMap: Map<SceneMaterialId, SceneMaterialId>,
|
|
163
195
|
rootIds: Set<AnyNodeId>,
|
|
164
196
|
nodes: Record<AnyNodeId, AnyNode>,
|
|
165
197
|
) {
|
|
166
|
-
const clone =
|
|
198
|
+
const clone = remapSceneMaterialReferences(
|
|
199
|
+
JSON.parse(JSON.stringify(node)),
|
|
200
|
+
materialIdMap,
|
|
201
|
+
) as AnyNode
|
|
167
202
|
;(clone as Record<string, unknown>).id = idMap.get(oldId)
|
|
168
203
|
|
|
169
204
|
if (rootIds.has(oldId)) {
|
|
170
|
-
|
|
205
|
+
const buildingId = targetLevel.parentId as AnyNodeId | null
|
|
206
|
+
clone.parentId =
|
|
207
|
+
nodeRegistry.get(node.type)?.floorplanScope === 'building' &&
|
|
208
|
+
buildingId &&
|
|
209
|
+
nodes[buildingId]?.type === 'building'
|
|
210
|
+
? buildingId
|
|
211
|
+
: targetLevel.id
|
|
212
|
+
if (clone.type === 'door' || clone.type === 'window') {
|
|
213
|
+
delete clone.roofSegmentId
|
|
214
|
+
delete clone.roofFace
|
|
215
|
+
}
|
|
171
216
|
} else if (clone.parentId && typeof clone.parentId === 'string') {
|
|
172
217
|
clone.parentId = idMap.get(clone.parentId as AnyNodeId) ?? clone.parentId
|
|
173
218
|
}
|
|
@@ -208,9 +253,47 @@ function remapNodeReferences(
|
|
|
208
253
|
return AnyNode.parse(clone)
|
|
209
254
|
}
|
|
210
255
|
|
|
256
|
+
function remapSceneMaterialReferences(
|
|
257
|
+
value: unknown,
|
|
258
|
+
materialIdMap: Map<SceneMaterialId, SceneMaterialId>,
|
|
259
|
+
): unknown {
|
|
260
|
+
if (typeof value === 'string' && value.startsWith('scene:')) {
|
|
261
|
+
const oldId = value.slice('scene:'.length) as SceneMaterialId
|
|
262
|
+
const nextId = materialIdMap.get(oldId)
|
|
263
|
+
return nextId ? `scene:${nextId}` : value
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(value)) {
|
|
266
|
+
return value.map((entry) => remapSceneMaterialReferences(entry, materialIdMap))
|
|
267
|
+
}
|
|
268
|
+
if (value && typeof value === 'object') {
|
|
269
|
+
return Object.fromEntries(
|
|
270
|
+
Object.entries(value).map(([key, entry]) => [
|
|
271
|
+
key,
|
|
272
|
+
remapSceneMaterialReferences(entry, materialIdMap),
|
|
273
|
+
]),
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
return value
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function collectReferencedSceneMaterialIds(value: unknown, ids: Set<SceneMaterialId>) {
|
|
280
|
+
if (typeof value === 'string' && value.startsWith('scene:')) {
|
|
281
|
+
ids.add(value.slice('scene:'.length) as SceneMaterialId)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
if (Array.isArray(value)) {
|
|
285
|
+
for (const entry of value) collectReferencedSceneMaterialIds(entry, ids)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
if (value && typeof value === 'object') {
|
|
289
|
+
for (const entry of Object.values(value)) collectReferencedSceneMaterialIds(entry, ids)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
211
293
|
function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
|
|
212
294
|
const scene = useScene.getState()
|
|
213
295
|
const selectedIdSet = new Set(ids)
|
|
296
|
+
const allowHostedOpening = ids.length === 1
|
|
214
297
|
const promotedIds = ids.map((id) => {
|
|
215
298
|
const node = scene.nodes[id]
|
|
216
299
|
return node ? (getPromotedCabinetRunId(scene.nodes, node, selectedIdSet) ?? id) : id
|
|
@@ -219,8 +302,8 @@ function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
|
|
|
219
302
|
const node = scene.nodes[id]
|
|
220
303
|
return (
|
|
221
304
|
node &&
|
|
222
|
-
|
|
223
|
-
|
|
305
|
+
isCopyableRootType(node) &&
|
|
306
|
+
isClipboardRoot(scene.nodes, node, allowHostedOpening) &&
|
|
224
307
|
!hasSelectedAncestor(scene.nodes, id, selectedIdSet)
|
|
225
308
|
)
|
|
226
309
|
})
|
|
@@ -234,16 +317,99 @@ function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
|
|
|
234
317
|
collectSubtreeIds(scene.nodes, rootId, subtreeIds)
|
|
235
318
|
}
|
|
236
319
|
|
|
320
|
+
const copiedNodes = [...subtreeIds]
|
|
321
|
+
.map((id) => scene.nodes[id])
|
|
322
|
+
.filter((node): node is AnyNode => !!node)
|
|
323
|
+
.map((node) => JSON.parse(JSON.stringify(node)) as AnyNode)
|
|
324
|
+
const materialIds = new Set<SceneMaterialId>()
|
|
325
|
+
collectReferencedSceneMaterialIds(copiedNodes, materialIds)
|
|
326
|
+
|
|
237
327
|
return {
|
|
238
328
|
copiedAt: Date.now(),
|
|
239
|
-
|
|
240
|
-
.map((id) => scene.
|
|
241
|
-
.filter((
|
|
242
|
-
.map((
|
|
329
|
+
materials: [...materialIds]
|
|
330
|
+
.map((id) => scene.materials[id])
|
|
331
|
+
.filter((material): material is SceneMaterial => !!material)
|
|
332
|
+
.map((material) => JSON.parse(JSON.stringify(material)) as SceneMaterial),
|
|
333
|
+
nodes: copiedNodes,
|
|
243
334
|
rootIds,
|
|
244
335
|
}
|
|
245
336
|
}
|
|
246
337
|
|
|
338
|
+
function serializeClipboardPayload(payload: ClipboardPayload) {
|
|
339
|
+
return JSON.stringify({
|
|
340
|
+
kind: SYSTEM_CLIPBOARD_KIND,
|
|
341
|
+
version: SYSTEM_CLIPBOARD_VERSION,
|
|
342
|
+
payload,
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function parseClipboardPayload(text: string): ClipboardPayload | null {
|
|
347
|
+
try {
|
|
348
|
+
const envelope = JSON.parse(text) as {
|
|
349
|
+
kind?: unknown
|
|
350
|
+
payload?: unknown
|
|
351
|
+
version?: unknown
|
|
352
|
+
}
|
|
353
|
+
if (
|
|
354
|
+
envelope.kind !== SYSTEM_CLIPBOARD_KIND ||
|
|
355
|
+
envelope.version !== SYSTEM_CLIPBOARD_VERSION ||
|
|
356
|
+
!envelope.payload ||
|
|
357
|
+
typeof envelope.payload !== 'object'
|
|
358
|
+
) {
|
|
359
|
+
return null
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const candidate = envelope.payload as {
|
|
363
|
+
copiedAt?: unknown
|
|
364
|
+
materials?: unknown
|
|
365
|
+
nodes?: unknown
|
|
366
|
+
rootIds?: unknown
|
|
367
|
+
}
|
|
368
|
+
if (
|
|
369
|
+
typeof candidate.copiedAt !== 'number' ||
|
|
370
|
+
!Array.isArray(candidate.nodes) ||
|
|
371
|
+
!Array.isArray(candidate.rootIds) ||
|
|
372
|
+
!candidate.rootIds.every((id) => typeof id === 'string')
|
|
373
|
+
) {
|
|
374
|
+
return null
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const nodes = candidate.nodes.map((node) => AnyNode.safeParse(node))
|
|
378
|
+
if (nodes.some((result) => !result.success)) return null
|
|
379
|
+
const materials = Array.isArray(candidate.materials)
|
|
380
|
+
? candidate.materials.map((material) => SceneMaterial.safeParse(material))
|
|
381
|
+
: []
|
|
382
|
+
if (materials.some((result) => !result.success)) return null
|
|
383
|
+
|
|
384
|
+
const parsedNodes = nodes.filter((result) => result.success).map((result) => result.data)
|
|
385
|
+
const nodeIds = new Set(parsedNodes.map((node) => node.id))
|
|
386
|
+
const rootIds = candidate.rootIds as AnyNodeId[]
|
|
387
|
+
if (rootIds.length === 0 || rootIds.some((id) => !nodeIds.has(id))) return null
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
copiedAt: candidate.copiedAt,
|
|
391
|
+
materials: materials.filter((result) => result.success).map((result) => result.data),
|
|
392
|
+
nodes: parsedNodes,
|
|
393
|
+
rootIds,
|
|
394
|
+
}
|
|
395
|
+
} catch {
|
|
396
|
+
return null
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function writeSystemClipboard(payload: ClipboardPayload) {
|
|
401
|
+
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
|
402
|
+
pendingSystemClipboardWrite = null
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
pendingSystemClipboardWrite = navigator.clipboard
|
|
406
|
+
.writeText(serializeClipboardPayload(payload))
|
|
407
|
+
.then(
|
|
408
|
+
() => true,
|
|
409
|
+
() => false,
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
247
413
|
export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
|
|
248
414
|
const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
|
|
249
415
|
const payload = buildClipboardPayload(ids)
|
|
@@ -251,7 +417,33 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
|
|
|
251
417
|
|
|
252
418
|
clipboardPayload = payload
|
|
253
419
|
notifySubscribers()
|
|
420
|
+
writeSystemClipboard(payload)
|
|
421
|
+
|
|
422
|
+
return true
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function readEditorClipboardFromSystem() {
|
|
426
|
+
const pendingWrite = pendingSystemClipboardWrite
|
|
427
|
+
pendingSystemClipboardWrite = null
|
|
428
|
+
if (pendingWrite && !(await pendingWrite)) {
|
|
429
|
+
return hasEditorClipboard()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) {
|
|
433
|
+
return hasEditorClipboard()
|
|
434
|
+
}
|
|
254
435
|
|
|
436
|
+
let text: string
|
|
437
|
+
try {
|
|
438
|
+
text = await navigator.clipboard.readText()
|
|
439
|
+
} catch {
|
|
440
|
+
return hasEditorClipboard()
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const payload = parseClipboardPayload(text)
|
|
444
|
+
if (!payload) return false
|
|
445
|
+
clipboardPayload = payload
|
|
446
|
+
notifySubscribers()
|
|
255
447
|
return true
|
|
256
448
|
}
|
|
257
449
|
|
|
@@ -275,6 +467,13 @@ export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteRes
|
|
|
275
467
|
return applyClipboardPayloadToLevel(clipboardPayload, targetLevelId)
|
|
276
468
|
}
|
|
277
469
|
|
|
470
|
+
export async function pasteSystemEditorClipboardToLevel(
|
|
471
|
+
targetLevelId?: AnyNodeId,
|
|
472
|
+
): Promise<PasteResult | null> {
|
|
473
|
+
if (!(await readEditorClipboardFromSystem())) return null
|
|
474
|
+
return pasteEditorClipboardToLevel(targetLevelId)
|
|
475
|
+
}
|
|
476
|
+
|
|
278
477
|
function applyClipboardPayloadToLevel(
|
|
279
478
|
payload: ClipboardPayload,
|
|
280
479
|
targetLevelId?: AnyNodeId,
|
|
@@ -284,10 +483,23 @@ function applyClipboardPayloadToLevel(
|
|
|
284
483
|
|
|
285
484
|
const scene = useScene.getState()
|
|
286
485
|
const idMap = new Map<AnyNodeId, AnyNodeId>()
|
|
486
|
+
const materialIdMap = new Map<SceneMaterialId, SceneMaterialId>()
|
|
487
|
+
const materialsToCreate: SceneMaterial[] = []
|
|
287
488
|
|
|
288
489
|
for (const node of payload.nodes) {
|
|
289
490
|
idMap.set(node.id as AnyNodeId, generateId(extractIdPrefix(node.id)) as AnyNodeId)
|
|
290
491
|
}
|
|
492
|
+
for (const material of payload.materials) {
|
|
493
|
+
const oldId = material.id as SceneMaterialId
|
|
494
|
+
const existing = scene.materials[oldId]
|
|
495
|
+
if (existing && JSON.stringify(existing) === JSON.stringify(material)) {
|
|
496
|
+
materialIdMap.set(oldId, oldId)
|
|
497
|
+
continue
|
|
498
|
+
}
|
|
499
|
+
const nextId = existing ? generateSceneMaterialId() : oldId
|
|
500
|
+
materialIdMap.set(oldId, nextId)
|
|
501
|
+
materialsToCreate.push({ ...material, id: nextId })
|
|
502
|
+
}
|
|
291
503
|
|
|
292
504
|
const rootIdSet = new Set(payload.rootIds)
|
|
293
505
|
const pastedNodes: AnyNode[] = []
|
|
@@ -296,7 +508,15 @@ function applyClipboardPayloadToLevel(
|
|
|
296
508
|
for (const node of payload.nodes) {
|
|
297
509
|
try {
|
|
298
510
|
pastedNodes.push(
|
|
299
|
-
remapNodeReferences(
|
|
511
|
+
remapNodeReferences(
|
|
512
|
+
node,
|
|
513
|
+
node.id as AnyNodeId,
|
|
514
|
+
targetLevel,
|
|
515
|
+
idMap,
|
|
516
|
+
materialIdMap,
|
|
517
|
+
rootIdSet,
|
|
518
|
+
scene.nodes,
|
|
519
|
+
),
|
|
300
520
|
)
|
|
301
521
|
} catch (error) {
|
|
302
522
|
console.error('Failed to paste copied node', node.id, error)
|
|
@@ -305,9 +525,12 @@ function applyClipboardPayloadToLevel(
|
|
|
305
525
|
}
|
|
306
526
|
|
|
307
527
|
if (pastedNodes.length === 0) {
|
|
308
|
-
return { pastedIds: [], skippedIds }
|
|
528
|
+
return { createdMaterialIds: [], pastedIds: [], skippedIds }
|
|
309
529
|
}
|
|
310
530
|
|
|
531
|
+
for (const material of materialsToCreate) {
|
|
532
|
+
scene.addSceneMaterial(material)
|
|
533
|
+
}
|
|
311
534
|
scene.createNodes(
|
|
312
535
|
pastedNodes.map((node) => ({
|
|
313
536
|
node,
|
|
@@ -326,6 +549,7 @@ function applyClipboardPayloadToLevel(
|
|
|
326
549
|
})
|
|
327
550
|
|
|
328
551
|
return {
|
|
552
|
+
createdMaterialIds: materialsToCreate.map((material) => material.id as SceneMaterialId),
|
|
329
553
|
pastedIds: pastedRootIds,
|
|
330
554
|
skippedIds,
|
|
331
555
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { type AnyNode, nodeRegistry, registerNode } from '@pascal-app/core'
|
|
2
|
+
import { type AnyNode, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
import {
|
|
5
|
+
emitCanvasNodeSelection,
|
|
5
6
|
resolveCanvasSelectionNode,
|
|
6
7
|
resolveNodeSelectionTarget,
|
|
7
8
|
resolveSelectedIdsForNodeClick,
|
|
@@ -47,6 +48,20 @@ describe('resolveSelectedIdsForNodeClick', () => {
|
|
|
47
48
|
})
|
|
48
49
|
})
|
|
49
50
|
|
|
51
|
+
describe('emitCanvasNodeSelection', () => {
|
|
52
|
+
test('publishes the accepted canvas node once', () => {
|
|
53
|
+
const node = { id: 'wall_1', type: 'wall' } as unknown as AnyNode
|
|
54
|
+
const received: AnyNode[] = []
|
|
55
|
+
const onSelection = (selectedNode: AnyNode) => received.push(selectedNode)
|
|
56
|
+
emitter.on('selection:canvas-node-click', onSelection)
|
|
57
|
+
|
|
58
|
+
emitCanvasNodeSelection(node)
|
|
59
|
+
|
|
60
|
+
emitter.off('selection:canvas-node-click', onSelection)
|
|
61
|
+
expect(received).toEqual([node])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
50
65
|
describe('selectionModifiersFromEvent', () => {
|
|
51
66
|
test('falls back to tracked modifier state when the click event omits keys', () => {
|
|
52
67
|
expect(selectionModifiersFromEvent({}, { meta: false, ctrl: true, shift: false })).toEqual({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNode,
|
|
3
|
+
emitter,
|
|
3
4
|
type ItemNode,
|
|
4
5
|
nodeRegistry,
|
|
5
6
|
resolveSelectionProxyId,
|
|
@@ -16,6 +17,10 @@ export type NodeSelectionTarget = {
|
|
|
16
17
|
structureLayer?: 'zones' | 'elements'
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
export function emitCanvasNodeSelection(node: AnyNode): void {
|
|
21
|
+
emitter.emit('selection:canvas-node-click', node)
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
function shouldBypassSelectionProxy(node: AnyNode, target: AnyNode): boolean {
|
|
20
25
|
if (node.id === target.id) return false
|
|
21
26
|
// Kind-declared bypass (`def.selectionProxy.bypassDirectPick`): the kind
|
package/src/lib/sfx-bus.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import type { TerrainVerb } from '@pascal-app/core'
|
|
1
2
|
import mitt from 'mitt'
|
|
2
|
-
import
|
|
3
|
+
import useAudio from '../store/use-audio'
|
|
4
|
+
import {
|
|
5
|
+
disposeSFX,
|
|
6
|
+
type LoopSFXName,
|
|
7
|
+
playSFX,
|
|
8
|
+
startLoopSFX,
|
|
9
|
+
stopLoopSFX,
|
|
10
|
+
updateSFXVolumes,
|
|
11
|
+
} from './sfx-player'
|
|
3
12
|
|
|
4
13
|
/**
|
|
5
14
|
* SFX-specific events that tools can trigger
|
|
@@ -18,8 +27,14 @@ type SFXEvents = {
|
|
|
18
27
|
'sfx:menu-hover': undefined
|
|
19
28
|
'sfx:menu-click': undefined
|
|
20
29
|
'sfx:paint-apply': undefined
|
|
30
|
+
'sfx:terrain-sculpt-start': TerrainVerb
|
|
31
|
+
'sfx:terrain-sculpt-stop': undefined
|
|
21
32
|
}
|
|
22
33
|
|
|
34
|
+
type TriggerSFXEvent = {
|
|
35
|
+
[Event in keyof SFXEvents]: SFXEvents[Event] extends undefined ? Event : never
|
|
36
|
+
}[keyof SFXEvents]
|
|
37
|
+
|
|
23
38
|
/**
|
|
24
39
|
* Dedicated event emitter for SFX
|
|
25
40
|
* Tools should use this to trigger sound effects
|
|
@@ -41,6 +56,15 @@ const handleSnapshotCapture = () => playSFX('snapshotCapture')
|
|
|
41
56
|
const handleMenuHover = () => playSFX('menuHover')
|
|
42
57
|
const handleMenuClick = () => playSFX('menuClick')
|
|
43
58
|
const handlePaintApply = () => playSFX('paintApply')
|
|
59
|
+
const TERRAIN_LOOP_BY_VERB = {
|
|
60
|
+
raise: 'terrainRaise',
|
|
61
|
+
lower: 'terrainLower',
|
|
62
|
+
flatten: 'terrainFlatten',
|
|
63
|
+
smooth: 'terrainSmooth',
|
|
64
|
+
} as const satisfies Record<TerrainVerb, LoopSFXName>
|
|
65
|
+
const handleTerrainSculptStart = (verb: TerrainVerb) => startLoopSFX(TERRAIN_LOOP_BY_VERB[verb])
|
|
66
|
+
const handleTerrainSculptStop = () => stopLoopSFX()
|
|
67
|
+
let unsubscribeAudio: (() => void) | null = null
|
|
44
68
|
|
|
45
69
|
/**
|
|
46
70
|
* Initialize SFX Bus - connects SFX events to actual sound playback.
|
|
@@ -62,6 +86,9 @@ export function initSFXBus() {
|
|
|
62
86
|
sfxEmitter.on('sfx:menu-hover', handleMenuHover)
|
|
63
87
|
sfxEmitter.on('sfx:menu-click', handleMenuClick)
|
|
64
88
|
sfxEmitter.on('sfx:paint-apply', handlePaintApply)
|
|
89
|
+
sfxEmitter.on('sfx:terrain-sculpt-start', handleTerrainSculptStart)
|
|
90
|
+
sfxEmitter.on('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
|
|
91
|
+
unsubscribeAudio = useAudio.subscribe(updateSFXVolumes)
|
|
65
92
|
}
|
|
66
93
|
|
|
67
94
|
export function disposeSFXBus() {
|
|
@@ -79,6 +106,10 @@ export function disposeSFXBus() {
|
|
|
79
106
|
sfxEmitter.off('sfx:menu-hover', handleMenuHover)
|
|
80
107
|
sfxEmitter.off('sfx:menu-click', handleMenuClick)
|
|
81
108
|
sfxEmitter.off('sfx:paint-apply', handlePaintApply)
|
|
109
|
+
sfxEmitter.off('sfx:terrain-sculpt-start', handleTerrainSculptStart)
|
|
110
|
+
sfxEmitter.off('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
|
|
111
|
+
unsubscribeAudio?.()
|
|
112
|
+
unsubscribeAudio = null
|
|
82
113
|
sfxBusInitialized = false
|
|
83
114
|
}
|
|
84
115
|
disposeSFX()
|
|
@@ -89,7 +120,7 @@ export function disposeSFXBus() {
|
|
|
89
120
|
* @example
|
|
90
121
|
* triggerSFX('sfx:item-place')
|
|
91
122
|
*/
|
|
92
|
-
export function triggerSFX(event:
|
|
123
|
+
export function triggerSFX(event: TriggerSFXEvent) {
|
|
93
124
|
sfxEmitter.emit(event)
|
|
94
125
|
}
|
|
95
126
|
|
|
@@ -3,15 +3,26 @@ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
|
3
3
|
type FakeContext = { id: string }
|
|
4
4
|
|
|
5
5
|
let activeContext: FakeContext = { id: 'first' }
|
|
6
|
+
let initialState: 'loaded' | 'loading' = 'loaded'
|
|
7
|
+
let muted = false
|
|
6
8
|
let throwOnPlay = false
|
|
7
9
|
const instances: FakeHowl[] = []
|
|
8
10
|
|
|
9
11
|
class FakeHowl {
|
|
10
|
-
|
|
12
|
+
loadCallbacks: Array<() => void> = []
|
|
13
|
+
loop = false
|
|
14
|
+
src: string | undefined
|
|
15
|
+
stateValue: 'loaded' | 'loading' | 'unloaded' = initialState
|
|
11
16
|
unloadCount = 0
|
|
12
17
|
playCount = 0
|
|
18
|
+
stopCount = 0
|
|
19
|
+
fadeCalls: Array<[number, number, number, number | undefined]> = []
|
|
20
|
+
stereoCalls: Array<[number, number | undefined]> = []
|
|
21
|
+
volumeCalls: Array<[number, number | undefined]> = []
|
|
13
22
|
|
|
14
|
-
constructor(
|
|
23
|
+
constructor(options: { loop?: boolean; src?: string[] }) {
|
|
24
|
+
this.loop = options.loop ?? false
|
|
25
|
+
this.src = options.src?.[0]
|
|
15
26
|
instances.push(this)
|
|
16
27
|
}
|
|
17
28
|
|
|
@@ -21,7 +32,41 @@ class FakeHowl {
|
|
|
21
32
|
return 1
|
|
22
33
|
}
|
|
23
34
|
|
|
24
|
-
volume() {
|
|
35
|
+
volume(value: number, id?: number) {
|
|
36
|
+
this.volumeCalls.push([value, id])
|
|
37
|
+
return this
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fade(from: number, to: number, duration: number, id?: number) {
|
|
41
|
+
this.fadeCalls.push([from, to, duration, id])
|
|
42
|
+
return this
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
once(event: string, callback: () => void) {
|
|
46
|
+
if (event === 'load') {
|
|
47
|
+
this.loadCallbacks.push(callback)
|
|
48
|
+
return this
|
|
49
|
+
}
|
|
50
|
+
callback()
|
|
51
|
+
return this
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
finishLoading() {
|
|
55
|
+
this.stateValue = 'loaded'
|
|
56
|
+
for (const callback of this.loadCallbacks.splice(0)) callback()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
playing() {
|
|
60
|
+
return this.playCount > this.stopCount
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
stop() {
|
|
64
|
+
this.stopCount++
|
|
65
|
+
return this
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
stereo(value: number, id?: number) {
|
|
69
|
+
this.stereoCalls.push([value, id])
|
|
25
70
|
return this
|
|
26
71
|
}
|
|
27
72
|
|
|
@@ -49,16 +94,20 @@ const fakeHowler = {
|
|
|
49
94
|
mock.module('howler', () => ({ Howl: FakeHowl, Howler: fakeHowler }))
|
|
50
95
|
mock.module('../store/use-audio', () => ({
|
|
51
96
|
default: {
|
|
52
|
-
getState: () => ({ masterVolume: 100, muted
|
|
97
|
+
getState: () => ({ masterVolume: 100, muted, sfxVolume: 100 }),
|
|
98
|
+
subscribe: () => () => {},
|
|
53
99
|
},
|
|
54
100
|
}))
|
|
55
101
|
|
|
56
|
-
const { disposeSFX, playSFX, preloadSFX } =
|
|
57
|
-
|
|
102
|
+
const { disposeSFX, playSFX, preloadSFX, startLoopSFX, stopLoopSFX, updateSFXVolumes } =
|
|
103
|
+
await import('./sfx-player')
|
|
104
|
+
const { disposeSFXBus, initSFXBus, sfxEmitter, triggerSFX } = await import('./sfx-bus')
|
|
58
105
|
|
|
59
106
|
beforeEach(() => {
|
|
60
107
|
disposeSFXBus()
|
|
61
108
|
activeContext = { id: 'first' }
|
|
109
|
+
initialState = 'loaded'
|
|
110
|
+
muted = false
|
|
62
111
|
throwOnPlay = false
|
|
63
112
|
instances.length = 0
|
|
64
113
|
})
|
|
@@ -104,6 +153,16 @@ describe('SFX audio context lifecycle', () => {
|
|
|
104
153
|
expect(instances.length).toBe(initialCount)
|
|
105
154
|
})
|
|
106
155
|
|
|
156
|
+
test('does not queue spatial mutations while a sound is still loading', () => {
|
|
157
|
+
initialState = 'loading'
|
|
158
|
+
|
|
159
|
+
playSFX('itemDelete', { source: 'remote', stereo: 0.65, volumeMultiplier: 0.25 })
|
|
160
|
+
|
|
161
|
+
expect(instances.every((sound) => sound.playCount === 0)).toBe(true)
|
|
162
|
+
expect(instances.every((sound) => sound.stereoCalls.length === 0)).toBe(true)
|
|
163
|
+
expect(instances.every((sound) => sound.volumeCalls.length === 0)).toBe(true)
|
|
164
|
+
})
|
|
165
|
+
|
|
107
166
|
test('disposes idempotently and recreates sounds after remount', () => {
|
|
108
167
|
preloadSFX()
|
|
109
168
|
const initialCount = instances.length
|
|
@@ -121,4 +180,87 @@ describe('SFX audio context lifecycle', () => {
|
|
|
121
180
|
|
|
122
181
|
expect(() => triggerSFX('sfx:item-delete')).not.toThrow()
|
|
123
182
|
})
|
|
183
|
+
|
|
184
|
+
test('applies bounded gain and stereo positioning to a remote cue', () => {
|
|
185
|
+
playSFX('itemDelete', { source: 'remote', stereo: 0.65, volumeMultiplier: 0.25 })
|
|
186
|
+
|
|
187
|
+
const played = instances.find((sound) => sound.playCount === 1)
|
|
188
|
+
expect(played?.volumeCalls[0]?.[0]).toBeGreaterThanOrEqual(0.225)
|
|
189
|
+
expect(played?.volumeCalls[0]?.[0]).toBeLessThanOrEqual(0.25)
|
|
190
|
+
expect(played?.stereoCalls).toEqual([[0.65, 1]])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('keeps local feedback audible after the same remote cue', () => {
|
|
194
|
+
playSFX('itemDelete', { source: 'remote', volumeMultiplier: 0.25 })
|
|
195
|
+
playSFX('itemDelete')
|
|
196
|
+
|
|
197
|
+
expect(instances.reduce((total, sound) => total + sound.playCount, 0)).toBe(2)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('keeps one faded terrain loop active and stops it on release', () => {
|
|
201
|
+
startLoopSFX('terrainRaise')
|
|
202
|
+
const raise = instances.find((sound) => sound.loop && sound.playCount === 1)
|
|
203
|
+
|
|
204
|
+
expect(raise?.volumeCalls[0]).toEqual([0, 1])
|
|
205
|
+
expect(raise?.fadeCalls).toEqual([[0, 0.2, 90, 1]])
|
|
206
|
+
|
|
207
|
+
startLoopSFX('terrainRaise')
|
|
208
|
+
expect(raise?.playCount).toBe(1)
|
|
209
|
+
|
|
210
|
+
stopLoopSFX()
|
|
211
|
+
expect(raise?.stopCount).toBe(1)
|
|
212
|
+
expect(raise?.fadeCalls.at(-1)).toEqual([0.2, 0, 90, 1])
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('maps each terrain verb to a distinct loop through the SFX bus', () => {
|
|
216
|
+
initSFXBus()
|
|
217
|
+
|
|
218
|
+
for (const verb of ['raise', 'lower', 'flatten', 'smooth'] as const) {
|
|
219
|
+
sfxEmitter.emit('sfx:terrain-sculpt-start', verb)
|
|
220
|
+
sfxEmitter.emit('sfx:terrain-sculpt-stop')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
expect(
|
|
224
|
+
instances.filter((sound) => sound.loop && sound.playCount === 1).map((sound) => sound.src),
|
|
225
|
+
).toEqual([
|
|
226
|
+
'/audios/sfx/terrain_raise.mp3',
|
|
227
|
+
'/audios/sfx/terrain_lower.mp3',
|
|
228
|
+
'/audios/sfx/terrain_flatten.mp3',
|
|
229
|
+
'/audios/sfx/terrain_smooth.mp3',
|
|
230
|
+
])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('fades an active terrain loop out when sound is muted', () => {
|
|
234
|
+
startLoopSFX('terrainLower')
|
|
235
|
+
const lower = instances.find((sound) => sound.loop && sound.playCount === 1)
|
|
236
|
+
|
|
237
|
+
muted = true
|
|
238
|
+
updateSFXVolumes()
|
|
239
|
+
|
|
240
|
+
expect(lower?.fadeCalls.at(-1)).toEqual([0.2, 0, 90, 1])
|
|
241
|
+
expect(lower?.stopCount).toBe(1)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('starts a requested terrain loop when its asset finishes loading', () => {
|
|
245
|
+
initialState = 'loading'
|
|
246
|
+
startLoopSFX('terrainRaise')
|
|
247
|
+
const raise = instances.find((sound) => sound.src === '/audios/sfx/terrain_raise.mp3')
|
|
248
|
+
|
|
249
|
+
expect(raise?.playCount).toBe(0)
|
|
250
|
+
|
|
251
|
+
raise?.finishLoading()
|
|
252
|
+
|
|
253
|
+
expect(raise?.playCount).toBe(1)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('does not start a terrain loop after the sculpt press was released', () => {
|
|
257
|
+
initialState = 'loading'
|
|
258
|
+
startLoopSFX('terrainLower')
|
|
259
|
+
const lower = instances.find((sound) => sound.src === '/audios/sfx/terrain_lower.mp3')
|
|
260
|
+
|
|
261
|
+
stopLoopSFX()
|
|
262
|
+
lower?.finishLoading()
|
|
263
|
+
|
|
264
|
+
expect(lower?.playCount).toBe(0)
|
|
265
|
+
})
|
|
124
266
|
})
|