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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +236 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -3,11 +3,13 @@ import {
3
3
  type AnyNodeId,
4
4
  bboxCornerAnchors,
5
5
  collectAlignmentAnchors,
6
+ emitter,
6
7
  pauseSceneHistory,
7
8
  pauseSpaceDetection,
8
9
  resolveAlignment,
9
10
  resumeSceneHistory,
10
11
  resumeSpaceDetection,
12
+ type SceneMaterialId,
11
13
  useLiveNodeOverrides,
12
14
  useLiveTransforms,
13
15
  useScene,
@@ -16,11 +18,18 @@ import { useViewer } from '@pascal-app/viewer'
16
18
  import { Plane, Vector2, Vector3 } from 'three'
17
19
  import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
18
20
  import { clientToPlan } from '../../lib/floorplan/plan-coords'
19
- import { duplicateNodesToLevel } from '../../lib/scene-clipboard'
20
- import { sfxEmitter } from '../../lib/sfx-bus'
21
+ import {
22
+ copySelectedNodesToEditorClipboard,
23
+ duplicateNodesToLevel,
24
+ getEditorClipboardSnapshot,
25
+ pasteSystemEditorClipboardToLevel,
26
+ } from '../../lib/scene-clipboard'
27
+ import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
21
28
  import useAlignmentGuides from '../../store/use-alignment-guides'
29
+ import useDeleteConfirmation from '../../store/use-delete-confirmation'
22
30
  import useEditor, {
23
31
  isAlignmentGuideActive,
32
+ isBrushMode,
24
33
  isGridSnapActive,
25
34
  isMagneticSnapActive,
26
35
  } from '../../store/use-editor'
@@ -79,7 +88,7 @@ export function canGroupPickUp(): boolean {
79
88
  * and drag them along with the copies.
80
89
  */
81
90
  export function startGroupPickUp(
82
- opts: { onCancel?: () => void; scopeToSelection?: boolean } = {},
91
+ opts: { onCancel?: () => void; positionAtCursor?: boolean; scopeToSelection?: boolean } = {},
83
92
  ): boolean {
84
93
  const { selectedIds, levelId } = useViewer.getState().selection
85
94
  const participantIds = groupParticipantIds()
@@ -202,16 +211,18 @@ export function startGroupPickUp(
202
211
  const applyMove = (e: PointerEvent) => {
203
212
  const plan = resolvePlanPoint(e)
204
213
  if (!plan) return
205
- // Delta-relative to where tracking starts so the group never teleports
206
- // to the cursor.
207
- if (!startPlan) {
214
+ // Ordinary moves are delta-relative so the group never teleports. A
215
+ // pasted selection instead arrives centered under the cursor.
216
+ if (!opts.positionAtCursor && !startPlan) {
208
217
  startPlan = plan
209
218
  return
210
219
  }
211
220
  const step = useEditor.getState().gridSnapStep
212
221
  const snap = isGridSnapActive() && step > 0
213
- let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0]
214
- let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
222
+ const rawDx = opts.positionAtCursor ? plan[0] - restCenter[0] : plan[0] - startPlan![0]
223
+ const rawDz = opts.positionAtCursor ? plan[1] - restCenter[1] : plan[1] - startPlan![1]
224
+ let dx = snap ? Math.round(rawDx / step) * step : rawDx
225
+ let dz = snap ? Math.round(rawDz / step) * step : rawDz
215
226
 
216
227
  if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) {
217
228
  const result = resolveAlignment({
@@ -361,6 +372,7 @@ export function startGroupPickUp(
361
372
  // Only a press over a tracked surface commits; a click on side panels or
362
373
  // the toolbar keeps the pick-up alive.
363
374
  if (!resolvePlanPoint(e)) return
375
+ if (opts.positionAtCursor && !lastDelta) applyMove(e)
364
376
  e.preventDefault()
365
377
  e.stopPropagation()
366
378
  commitPointerId = e.pointerId
@@ -380,6 +392,17 @@ export function startGroupPickUp(
380
392
 
381
393
  const onKeyDown = (e: KeyboardEvent) => {
382
394
  const key = e.key.toLowerCase()
395
+ if ((e.metaKey || e.ctrlKey) && (key === 'c' || key === 'v' || key === 'x')) {
396
+ // A clipboard chord replaces the current carry. Let the global keyboard
397
+ // arm receive the same event after this cancellation. Capture C/X first:
398
+ // pasted or duplicated carries delete their transient selection while
399
+ // cancelling, so the global arm would otherwise see nothing.
400
+ if (key === 'c' || key === 'x') {
401
+ copySelectedNodesToEditorClipboard()
402
+ }
403
+ cancel()
404
+ return
405
+ }
383
406
  if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
384
407
  e.preventDefault()
385
408
  e.stopPropagation()
@@ -437,6 +460,112 @@ export function duplicateSelectionAndPickUp(): boolean {
437
460
  return true
438
461
  }
439
462
 
463
+ function sceneReferencesMaterial(materialId: string) {
464
+ const reference = `scene:${materialId}`
465
+ const containsReference = (value: unknown): boolean => {
466
+ if (value === reference) return true
467
+ if (Array.isArray(value)) return value.some(containsReference)
468
+ if (value && typeof value === 'object') {
469
+ return Object.values(value).some(containsReference)
470
+ }
471
+ return false
472
+ }
473
+ return Object.values(useScene.getState().nodes).some(containsReference)
474
+ }
475
+
476
+ function removeUnusedPasteMaterials(materialIds: SceneMaterialId[]) {
477
+ for (const materialId of materialIds) {
478
+ if (!sceneReferencesMaterial(materialId)) {
479
+ useScene.getState().removeSceneMaterial(materialId)
480
+ }
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Paste the Pascal scene payload from the browser clipboard onto the active
486
+ * level, then carry the clones under the cursor until click-to-place. Escape
487
+ * removes the uncommitted clones and any scene materials imported with them.
488
+ */
489
+ export async function pasteSelectionAndPickUp(targetLevelId?: AnyNodeId): Promise<boolean> {
490
+ const activeScope = useInteractionScope.getState().scope
491
+ if (activeScope.kind === 'placing' || activeScope.kind === 'moving') {
492
+ emitter.emit('tool:cancel')
493
+ }
494
+ // Paint and sculpt hold their scope for the whole *mode*, so a pick-up would
495
+ // `begin({kind: 'handle-drag'})` over it — scopes are single-owner, so the brush
496
+ // would stay armed and painting while the editor believed a group move was
497
+ // running, and the mode's own release would then end someone else's scope.
498
+ // ⌘V is reachable from either mode (it reads the clipboard, not the selection,
499
+ // which is why clearing the selection on mode entry does not cover it), so drop
500
+ // the brush first: carrying clones is the newer intent, and unlike the cancels
501
+ // above there is nothing to abandon.
502
+ if (isBrushMode(useEditor.getState().mode)) useEditor.getState().setMode('select')
503
+
504
+ const result = await pasteSystemEditorClipboardToLevel(targetLevelId)
505
+ if (!result || result.pastedIds.length === 0) return false
506
+
507
+ const discardPaste = () => {
508
+ useScene.getState().deleteNodes(result.pastedIds)
509
+ removeUnusedPasteMaterials(result.createdMaterialIds)
510
+ useViewer.getState().setSelection({ selectedIds: [] })
511
+ }
512
+ if (result.pastedIds.length === 1) {
513
+ const rootId = result.pastedIds[0]!
514
+ const root = useScene.getState().nodes[rootId]
515
+ if (root?.type === 'door' || root?.type === 'window') {
516
+ const metadata =
517
+ root.metadata && typeof root.metadata === 'object' && !Array.isArray(root.metadata)
518
+ ? (root.metadata as Record<string, unknown>)
519
+ : {}
520
+ const draft = { ...root, metadata: { ...metadata, isNew: true } }
521
+ useScene.getState().updateNode(rootId, { metadata: draft.metadata })
522
+ useViewer.getState().setSelection({ selectedIds: [] })
523
+ const unsubscribe = useInteractionScope.subscribe((state, previous) => {
524
+ const previousOwnsDraft =
525
+ (previous.scope.kind === 'placing' || previous.scope.kind === 'moving') &&
526
+ previous.scope.nodeId === rootId
527
+ const currentOwnsDraft =
528
+ (state.scope.kind === 'placing' || state.scope.kind === 'moving') &&
529
+ state.scope.nodeId === rootId
530
+ if (!previousOwnsDraft || currentOwnsDraft) return
531
+ unsubscribe()
532
+ removeUnusedPasteMaterials(result.createdMaterialIds)
533
+ })
534
+ useEditor.getState().setMovingNode(draft)
535
+ sfxEmitter.emit('sfx:item-pick')
536
+ return true
537
+ }
538
+ }
539
+
540
+ const started = startGroupPickUp({
541
+ positionAtCursor: true,
542
+ scopeToSelection: true,
543
+ onCancel: discardPaste,
544
+ })
545
+ if (!started) sfxEmitter.emit('sfx:item-place')
546
+ return true
547
+ }
548
+
549
+ /**
550
+ * Cut uses the same cross-tab clipboard payload as Copy, then removes exactly
551
+ * the copied roots. Promoted subtree selections (such as all modules in one
552
+ * cabinet run) therefore remove the same root that Paste will recreate.
553
+ */
554
+ export function cutSelectionToEditorClipboard(): boolean {
555
+ if (!copySelectedNodesToEditorClipboard()) return false
556
+ const payload = getEditorClipboardSnapshot()
557
+ if (!payload || payload.rootIds.length === 0) return false
558
+
559
+ if (payload.rootIds.length === 1) {
560
+ emitDeleteSFX(useScene.getState().nodes[payload.rootIds[0]!]?.type)
561
+ } else {
562
+ sfxEmitter.emit('sfx:structure-delete')
563
+ }
564
+ useScene.getState().deleteNodes(payload.rootIds)
565
+ useViewer.getState().setSelection({ selectedIds: [] })
566
+ return true
567
+ }
568
+
440
569
  /**
441
570
  * Delete every selected node — same semantics as the keyboard Delete arm,
442
571
  * including the accidental-bulk-delete confirm.
@@ -444,14 +573,25 @@ export function duplicateSelectionAndPickUp(): boolean {
444
573
  export function deleteSelection(): boolean {
445
574
  const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
446
575
  if (selectedIds.length === 0) return false
576
+
577
+ const commitDelete = () => {
578
+ if (selectedIds.length === 1) {
579
+ emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type)
580
+ } else {
581
+ sfxEmitter.emit('sfx:structure-delete')
582
+ }
583
+ useScene.getState().deleteNodes(selectedIds)
584
+ useViewer.getState().setSelection({ selectedIds: [] })
585
+ }
586
+
447
587
  if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
448
- const confirmed = window.confirm(
449
- `Delete ${selectedIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
450
- )
451
- if (!confirmed) return false
588
+ useDeleteConfirmation.getState().requestConfirmation({
589
+ count: selectedIds.length,
590
+ onConfirm: commitDelete,
591
+ })
592
+ return true
452
593
  }
453
- sfxEmitter.emit('sfx:structure-delete')
454
- useScene.getState().deleteNodes(selectedIds)
455
- useViewer.getState().setSelection({ selectedIds: [] })
594
+
595
+ commitDelete()
456
596
  return true
457
597
  }
@@ -60,6 +60,7 @@ export function GroupFloatingActionMenu() {
60
60
  // memo keyed on selection + nodes is enough, no per-frame box traversal.
61
61
  const meshEpoch = useMeshSettleEpoch(nodes)
62
62
  const anchor = useMemo(() => {
63
+ void meshEpoch
63
64
  if (participantIds.length === 0) return null
64
65
  const fullIds = expandToComponent(participantIds, nodes, levelId)
65
66
  const box = computeGroupBox(fullIds)
@@ -69,7 +70,6 @@ export function GroupFloatingActionMenu() {
69
70
  box.max.y + MENU_Y_OFFSET,
70
71
  (box.min.z + box.max.z) / 2,
71
72
  )
72
- // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
73
73
  }, [participantIds, nodes, levelId, meshEpoch])
74
74
 
75
75
  useFrame((state) => {
@@ -132,6 +132,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
132
132
  // - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin
133
133
  // - `corner` = front-right bbox corner at mid-height → where the gizmo sits
134
134
  const rest = useMemo(() => {
135
+ void meshEpoch
135
136
  const box = computeGroupBox(ids)
136
137
  if (!box) return null
137
138
  const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
@@ -141,7 +142,6 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
141
142
  box.max.z + CORNER_OFFSET,
142
143
  )
143
144
  return { pivot, corner }
144
- // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
145
145
  }, [ids, meshEpoch])
146
146
 
147
147
  if (!rest) return null
@@ -53,6 +53,7 @@ export function GroupSelectionBox3D() {
53
53
  // Re-measure once the meshes settle after a scene change (undo included).
54
54
  const meshEpoch = useMeshSettleEpoch(nodes)
55
55
  const box = useMemo(() => {
56
+ void meshEpoch
56
57
  if (participantIds.length === 0) return null
57
58
  const fullIds = expandToComponent(participantIds, nodes, levelId)
58
59
  const world = computeGroupBox(fullIds)
@@ -69,7 +70,6 @@ export function GroupSelectionBox3D() {
69
70
  ],
70
71
  center,
71
72
  }
72
- // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
73
73
  }, [participantIds, nodes, levelId, meshEpoch])
74
74
 
75
75
  // Dashed wireframe. Built per box size (rare — selection / commit changes)
@@ -0,0 +1,96 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ runAsSingleSceneHistoryStep,
6
+ useScene,
7
+ } from '@pascal-app/core'
8
+ import { commitHandleDragPatch } from './handle-drag-history'
9
+
10
+ type RafFn = (callback: (time: number) => void) => number
11
+ ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (callback) => {
12
+ callback(0)
13
+ return 0
14
+ }
15
+ ;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
16
+
17
+ const NODE_ID = 'shelf_handle-drag-history' as AnyNodeId
18
+ const COMPANION_NODE_ID = 'shelf_handle-drag-history-companion' as AnyNodeId
19
+
20
+ function shelf(depth: number, id = NODE_ID): AnyNode {
21
+ return {
22
+ id,
23
+ type: 'shelf',
24
+ object: 'node',
25
+ parentId: null,
26
+ visible: true,
27
+ metadata: {},
28
+ children: [],
29
+ position: [0, 0, 0],
30
+ rotation: [0, 0, 0],
31
+ width: 1,
32
+ depth,
33
+ thickness: 0.04,
34
+ height: 0.9,
35
+ style: 'wall-shelf',
36
+ rows: 1,
37
+ columns: 1,
38
+ withBack: false,
39
+ withSides: true,
40
+ withBottom: false,
41
+ bracketStyle: 'minimal',
42
+ } as AnyNode
43
+ }
44
+
45
+ describe('commitHandleDragPatch', () => {
46
+ beforeEach(() => {
47
+ useScene.setState({ nodes: {}, rootNodeIds: [], dirtyNodes: new Set() } as never)
48
+ useScene.temporal.getState().clear()
49
+ useScene.temporal.getState().resume()
50
+ })
51
+
52
+ test('records the final patch as one undo step after preview history is resumed', () => {
53
+ useScene.getState().createNode(shelf(0.3))
54
+ const pastCount = useScene.temporal.getState().pastStates.length
55
+ useScene.temporal.getState().pause()
56
+
57
+ commitHandleDragPatch({
58
+ patch: { depth: 0.7 },
59
+ resumeHistory: () => useScene.temporal.getState().resume(),
60
+ runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
61
+ commit: (patch) => useScene.getState().updateNode(NODE_ID, patch),
62
+ })
63
+
64
+ expect(useScene.temporal.getState().pastStates).toHaveLength(pastCount + 1)
65
+ expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.7)
66
+
67
+ useScene.temporal.getState().undo()
68
+ expect(useScene.getState().nodes[NODE_ID]).toBeDefined()
69
+ expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.3)
70
+ })
71
+
72
+ test('records a composite multi-node commit as one undo step', () => {
73
+ useScene.getState().createNode(shelf(0.3))
74
+ useScene.getState().createNode(shelf(0.4, COMPANION_NODE_ID))
75
+ useScene.temporal.getState().clear()
76
+ useScene.temporal.getState().pause()
77
+
78
+ commitHandleDragPatch({
79
+ patch: { selectedDepth: 0.7, companionDepth: 0.2 },
80
+ resumeHistory: () => useScene.temporal.getState().resume(),
81
+ runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
82
+ commit: ({ selectedDepth, companionDepth }) => {
83
+ useScene.getState().updateNode(NODE_ID, { depth: selectedDepth })
84
+ useScene.getState().updateNode(COMPANION_NODE_ID, { depth: companionDepth })
85
+ },
86
+ })
87
+
88
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
89
+ expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.7)
90
+ expect((useScene.getState().nodes[COMPANION_NODE_ID] as { depth: number }).depth).toBe(0.2)
91
+
92
+ useScene.temporal.getState().undo()
93
+ expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.3)
94
+ expect((useScene.getState().nodes[COMPANION_NODE_ID] as { depth: number }).depth).toBe(0.4)
95
+ })
96
+ })
@@ -0,0 +1,14 @@
1
+ export function commitHandleDragPatch<T>({
2
+ commit,
3
+ patch,
4
+ resumeHistory,
5
+ runAsSingleHistoryStep,
6
+ }: {
7
+ commit: (patch: T) => void
8
+ patch: T
9
+ resumeHistory: () => void
10
+ runAsSingleHistoryStep: (run: () => void) => void
11
+ }) {
12
+ resumeHistory()
13
+ runAsSingleHistoryStep(() => commit(patch))
14
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, mock, test } from 'bun:test'
2
+ import type { AnyNodeId } from '@pascal-app/core'
3
+ import { replacePreviewOverrideIds } from './preview-overrides'
4
+
5
+ const FIRST_ID = 'cabinet_first' as AnyNodeId
6
+ const SECOND_ID = 'cabinet_second' as AnyNodeId
7
+ const THIRD_ID = 'cabinet_third' as AnyNodeId
8
+
9
+ describe('replacePreviewOverrideIds', () => {
10
+ test('clears companion overrides that leave the active preview', () => {
11
+ const clear = mock(() => {})
12
+
13
+ const nextIds = replacePreviewOverrideIds(
14
+ new Set([FIRST_ID, SECOND_ID]),
15
+ [
16
+ [SECOND_ID, { width: 0.7 }],
17
+ [THIRD_ID, { width: 0.5 }],
18
+ ],
19
+ clear,
20
+ )
21
+
22
+ expect(clear).toHaveBeenCalledTimes(1)
23
+ expect(clear).toHaveBeenCalledWith(FIRST_ID)
24
+ expect(nextIds).toEqual(new Set([SECOND_ID, THIRD_ID]))
25
+ })
26
+ })
@@ -0,0 +1,13 @@
1
+ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
2
+
3
+ export function replacePreviewOverrideIds(
4
+ activeIds: ReadonlySet<AnyNodeId>,
5
+ entries: ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>,
6
+ clear: (id: AnyNodeId) => void,
7
+ ): Set<AnyNodeId> {
8
+ const nextIds = new Set(entries.map(([id]) => id))
9
+ for (const id of activeIds) {
10
+ if (!nextIds.has(id)) clear(id)
11
+ }
12
+ return nextIds
13
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it, mock } from 'bun:test'
2
+ import { resolveResizeSnapValue } from './resize-snap'
3
+
4
+ describe('resolveResizeSnapValue', () => {
5
+ it('applies only magnetic snapping in lines mode', () => {
6
+ const magneticSnap = mock(() => 0.6)
7
+
8
+ expect(
9
+ resolveResizeSnapValue({
10
+ rawValue: 0.59,
11
+ gridSnapEnabled: true,
12
+ gridSnapActive: false,
13
+ gridSnapStep: 0.1,
14
+ magneticSnapActive: true,
15
+ magneticSnap,
16
+ }),
17
+ ).toBe(0.6)
18
+ expect(magneticSnap).toHaveBeenCalledWith(0.59)
19
+ })
20
+
21
+ it('applies only grid snapping in grid mode', () => {
22
+ const magneticSnap = mock(() => 0.6)
23
+
24
+ expect(
25
+ resolveResizeSnapValue({
26
+ rawValue: 0.56,
27
+ gridSnapEnabled: true,
28
+ gridSnapActive: true,
29
+ gridSnapStep: 0.1,
30
+ magneticSnapActive: false,
31
+ magneticSnap,
32
+ }),
33
+ ).toBeCloseTo(0.6)
34
+ expect(magneticSnap).not.toHaveBeenCalled()
35
+ })
36
+
37
+ it('keeps the raw value in off mode', () => {
38
+ const magneticSnap = mock(() => 0.6)
39
+
40
+ expect(
41
+ resolveResizeSnapValue({
42
+ rawValue: 0.56,
43
+ gridSnapEnabled: true,
44
+ gridSnapActive: false,
45
+ gridSnapStep: 0.1,
46
+ magneticSnapActive: false,
47
+ magneticSnap,
48
+ }),
49
+ ).toBe(0.56)
50
+ expect(magneticSnap).not.toHaveBeenCalled()
51
+ })
52
+ })
@@ -0,0 +1,23 @@
1
+ import { snapScalar } from '@pascal-app/core'
2
+
3
+ export function resolveResizeSnapValue({
4
+ rawValue,
5
+ gridSnapEnabled,
6
+ gridSnapActive,
7
+ gridSnapStep,
8
+ magneticSnapActive,
9
+ magneticSnap,
10
+ }: {
11
+ rawValue: number
12
+ gridSnapEnabled: boolean
13
+ gridSnapActive: boolean
14
+ gridSnapStep: number
15
+ magneticSnapActive: boolean
16
+ magneticSnap?: (value: number) => number
17
+ }): number {
18
+ const gridValue =
19
+ gridSnapEnabled && gridSnapActive && gridSnapStep > 0
20
+ ? snapScalar(rawValue, gridSnapStep)
21
+ : rawValue
22
+ return magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue
23
+ }
@@ -5,6 +5,7 @@ import {
5
5
  type AnyNodeId,
6
6
  type Cursor,
7
7
  createSceneApi,
8
+ runAsSingleSceneHistoryStep,
8
9
  useLiveNodeOverrides,
9
10
  useScene,
10
11
  } from '@pascal-app/core'
@@ -15,6 +16,7 @@ import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3
15
16
  import { isHistoryShortcut } from '../../../lib/history'
16
17
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
18
  import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
19
+ import { commitHandleDragPatch } from './handle-drag-history'
18
20
 
19
21
  export type HandleDragControls = {
20
22
  onStart: (index: number, snapshot: AnyNode) => void
@@ -174,6 +176,13 @@ export function useHandleDrag(args: UseHandleDragArgs) {
174
176
  session.onBegin?.()
175
177
 
176
178
  let lastPatch: Partial<AnyNode> | null = null
179
+ let historyPaused = true
180
+
181
+ const resumeHistory = () => {
182
+ if (!historyPaused) return
183
+ historyPaused = false
184
+ useScene.temporal.getState().resume()
185
+ }
177
186
 
178
187
  const onMove = (moveEvent: PointerEvent) => {
179
188
  const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane })
@@ -193,7 +202,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
193
202
  if (document.body.style.cursor === cursor) {
194
203
  document.body.style.cursor = ''
195
204
  }
196
- useScene.temporal.getState().resume()
205
+ resumeHistory()
197
206
  useViewer.getState().setInputDragging(false)
198
207
  setIsDragging(false)
199
208
  session.onEnd?.()
@@ -212,11 +221,12 @@ export function useHandleDrag(args: UseHandleDragArgs) {
212
221
  swallowNextClick()
213
222
  sfxEmitter.emit('sfx:item-place')
214
223
  if (lastPatch) {
215
- if (session.commit) {
216
- session.commit(lastPatch)
217
- } else {
218
- sceneApi.update(overrideId, lastPatch)
219
- }
224
+ commitHandleDragPatch({
225
+ patch: lastPatch,
226
+ resumeHistory,
227
+ runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
228
+ commit: session.commit ?? ((patch) => sceneApi.update(overrideId, patch)),
229
+ })
220
230
  }
221
231
  clearOverride()
222
232
  cleanup()
@@ -237,7 +247,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
237
247
  onCancel()
238
248
  }
239
249
 
240
- dragCleanupRef.current = cleanup
250
+ dragCleanupRef.current = onCancel
241
251
  window.addEventListener('pointermove', onMove)
242
252
  window.addEventListener('pointerup', onUp)
243
253
  window.addEventListener('pointercancel', onCancel)