@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.
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 +265 -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
@@ -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 isLevelChildRoot(nodes: Record<AnyNodeId, AnyNode>, node: AnyNode) {
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
- return nodes[parentId]?.type === 'level'
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 = JSON.parse(JSON.stringify(node)) as AnyNode
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
- clone.parentId = targetLevel.id
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
- COPYABLE_ROOT_TYPES.has(node.type) &&
223
- isLevelChildRoot(scene.nodes, node) &&
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
- nodes: [...subtreeIds]
240
- .map((id) => scene.nodes[id])
241
- .filter((node): node is AnyNode => !!node)
242
- .map((node) => JSON.parse(JSON.stringify(node)) as AnyNode),
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(node, node.id as AnyNodeId, targetLevel, idMap, rootIdSet, scene.nodes),
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
@@ -1,5 +1,14 @@
1
+ import type { TerrainVerb } from '@pascal-app/core'
1
2
  import mitt from 'mitt'
2
- import { disposeSFX, playSFX } from './sfx-player'
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: keyof SFXEvents) {
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
- stateValue: 'loaded' | 'unloaded' = 'loaded'
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(_options: unknown) {
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: false, sfxVolume: 100 }),
97
+ getState: () => ({ masterVolume: 100, muted, sfxVolume: 100 }),
98
+ subscribe: () => () => {},
53
99
  },
54
100
  }))
55
101
 
56
- const { disposeSFX, playSFX, preloadSFX } = await import('./sfx-player')
57
- const { disposeSFXBus, initSFXBus, triggerSFX } = await import('./sfx-bus')
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
  })