@pascal-app/editor 1.0.1 → 1.0.3

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 (111) hide show
  1. package/package.json +10 -5
  2. package/src/components/editor/custom-camera-controls.tsx +22 -74
  3. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  4. package/src/components/editor/floorplan-panel.tsx +210 -163
  5. package/src/components/editor/grid.tsx +31 -7
  6. package/src/components/editor/group-actions.ts +5 -9
  7. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  8. package/src/components/editor/group-move-3d.ts +4 -6
  9. package/src/components/editor/group-rotate-handle.tsx +81 -35
  10. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  11. package/src/components/editor/group-transform-shared.test.ts +126 -1
  12. package/src/components/editor/group-transform-shared.ts +79 -73
  13. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  14. package/src/components/editor/handles/use-handle-drag.ts +75 -6
  15. package/src/components/editor/index.tsx +24 -9
  16. package/src/components/editor/node-action-menu.tsx +2 -0
  17. package/src/components/editor/node-arrow-handles.tsx +141 -7
  18. package/src/components/editor/registry-action-contributions.tsx +53 -0
  19. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  20. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  21. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  22. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  23. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  24. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  25. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  26. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
  27. package/src/components/systems/zone/zone-system.tsx +8 -4
  28. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  29. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  30. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  31. package/src/components/tools/item/placement-strategies.ts +55 -43
  32. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  33. package/src/components/tools/item/use-draft-node.ts +41 -2
  34. package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
  35. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  36. package/src/components/tools/registry/item-surface-move.ts +46 -24
  37. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  38. package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
  39. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  40. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  41. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  42. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  43. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  44. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  45. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  46. package/src/components/tools/tool-manager.tsx +36 -3
  47. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  48. package/src/components/ui/controls/material-picker.tsx +5 -67
  49. package/src/components/ui/controls/slider-control.tsx +76 -31
  50. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  51. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  52. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  53. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  54. package/src/components/ui/panels/parametric-inspector.tsx +3 -15
  55. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  56. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  57. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
  58. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  59. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  60. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  61. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  62. package/src/components/viewer/floorplan-preview.tsx +1 -12
  63. package/src/components/viewer-zone-system.tsx +7 -2
  64. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  65. package/src/hooks/use-auto-save.ts +21 -6
  66. package/src/hooks/use-ceiling-events.test.ts +1 -0
  67. package/src/hooks/use-ceiling-events.ts +2 -0
  68. package/src/hooks/use-grid-events.test.ts +2 -0
  69. package/src/hooks/use-grid-events.ts +16 -11
  70. package/src/hooks/use-keyboard.test.ts +7 -0
  71. package/src/hooks/use-keyboard.ts +42 -29
  72. package/src/index.tsx +63 -3
  73. package/src/lib/catalog-panel-model.ts +39 -0
  74. package/src/lib/catalog-selection.test.ts +27 -0
  75. package/src/lib/catalog-selection.ts +8 -0
  76. package/src/lib/contextual-help.test.ts +27 -1
  77. package/src/lib/contextual-help.ts +16 -9
  78. package/src/lib/floorplan/floorplan-export.test.ts +3 -1
  79. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  80. package/src/lib/fresh-planar-placement.ts +168 -36
  81. package/src/lib/glb-export.test.ts +59 -16
  82. package/src/lib/glb-export.ts +26 -13
  83. package/src/lib/grid-frame.test.ts +19 -0
  84. package/src/lib/grid-frame.ts +18 -0
  85. package/src/lib/interaction/scope.ts +9 -1
  86. package/src/lib/keyboard-pan.test.ts +62 -0
  87. package/src/lib/keyboard-pan.ts +89 -0
  88. package/src/lib/material-catalog-model.ts +72 -0
  89. package/src/lib/material-paint-panel-model.ts +76 -0
  90. package/src/lib/overlay-transform.test.ts +28 -0
  91. package/src/lib/overlay-transform.ts +12 -0
  92. package/src/lib/panel-rows.ts +75 -0
  93. package/src/lib/panel-tool-options.test.ts +34 -0
  94. package/src/lib/panel-tool-options.ts +46 -0
  95. package/src/lib/parametric-node-update.ts +37 -0
  96. package/src/lib/scene.test.ts +99 -0
  97. package/src/lib/scene.ts +21 -3
  98. package/src/lib/snapping-mode.test.ts +13 -2
  99. package/src/lib/snapping-mode.ts +11 -6
  100. package/src/lib/spatial-drag-plane.test.ts +53 -0
  101. package/src/lib/spatial-drag-plane.ts +19 -0
  102. package/src/lib/spatial-pointer-input.test.ts +72 -0
  103. package/src/lib/spatial-pointer-input.ts +62 -0
  104. package/src/lib/spatial-snap.test.ts +47 -0
  105. package/src/lib/terrain-panel-model.ts +117 -0
  106. package/src/lib/world-grid-snap.test.ts +48 -0
  107. package/src/lib/world-grid-snap.ts +9 -5
  108. package/src/store/rotation-snapping.test.ts +53 -0
  109. package/src/store/use-editor.tsx +10 -2
  110. package/src/store/use-floorplan-draft-preview.ts +10 -1
  111. package/src/store/use-interaction-scope.ts +155 -6
@@ -37,8 +37,7 @@ import useInteractionScope, { useMovingNode } from '../../store/use-interaction-
37
37
  import {
38
38
  classifyParticipant,
39
39
  collectParticipants,
40
- computeGroupBox,
41
- expandToComponent,
40
+ computeGroupPlanBox,
42
41
  type GroupPlanBounds,
43
42
  groupPlanBounds,
44
43
  levelFrame,
@@ -128,9 +127,8 @@ export function startFloorplanGroupMove(
128
127
  const participantIds = selectedIds.filter(
129
128
  (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
130
129
  )
131
- // Move the full connected wall/fence component, mirroring the 3D gizmo.
132
- const fullIds = expandToComponent(participantIds, nodes, levelId)
133
- const { starts, links } = collectParticipants(fullIds, nodes, levelId)
130
+ // Only the selection moves; connected walls stretch through `links`, as in 3D.
131
+ const { starts, links } = collectParticipants(participantIds, nodes, levelId)
134
132
  if (starts.length === 0) return null
135
133
  const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
136
134
 
@@ -144,10 +142,9 @@ export function startFloorplanGroupMove(
144
142
  const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
145
143
 
146
144
  // The group aligns as one rigid footprint: its bbox corners + center are
147
- // the moving anchors. `computeGroupBox` is world-space (the 3D scene stays
148
- // mounted under every view mode); plan coords are level-frame, so convert.
145
+ // the moving anchors, measured in the level frame like the plan itself.
149
146
  const { inverse: frameInv } = levelFrame(levelId)
150
- const restBounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv)
147
+ const restBounds = groupPlanBounds(starts, frameInv)
151
148
  if (!restBounds) return null
152
149
  const restAnchors = bboxCornerAnchors(
153
150
  'group-move',
@@ -441,12 +438,11 @@ export function startFloorplanGroupRotate(event: {
441
438
  (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
442
439
  )
443
440
  if (participantIds.length === 0) return false
444
- const fullIds = expandToComponent(participantIds, nodes, levelId)
445
- const { starts, links } = collectParticipants(fullIds, nodes, levelId)
441
+ const { starts, links } = collectParticipants(participantIds, nodes, levelId)
446
442
  if (starts.length === 0) return false
447
443
  const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
448
444
  const { inverse: frameInv } = levelFrame(levelId)
449
- const bounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv)
445
+ const bounds = groupPlanBounds(starts, frameInv)
450
446
  if (!bounds) return false
451
447
  // Same pivot as the dashed box the handles hang off (and as the 3D rotate
452
448
  // gizmo): its centre, not the anchor points' centre.
@@ -605,8 +601,9 @@ const GROUP_BOX_ROTATE_CURSOR_STYLE = {
605
601
 
606
602
  /**
607
603
  * Dashed bounding box around the current multi-selection's transformable
608
- * participants (expanded to the welded wall/fence component) — shows what a
609
- * group drag will carry along, and IS the group's drag handle: press anywhere
604
+ * participants — what a group drag carries (connected walls outside the
605
+ * selection stretch at their shared ends to stay joined) — and IS the group's
606
+ * drag handle: press anywhere
610
607
  * inside it to slide the group, click to pick it up. Holding a selection
611
608
  * modifier (Cmd/Ctrl/Shift) lets pointer events pass through so members under
612
609
  * the box can still be toggled in and out. Rides the live drag delta so it
@@ -653,7 +650,7 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
653
650
  }, [])
654
651
 
655
652
  // `meshEpoch` re-runs the measurement once the meshes settle after a scene
656
- // change (undo/redo included) — `computeGroupBox` reads mesh world bounds,
653
+ // change (undo/redo included) — `computeGroupPlanBox` reads mesh bounds,
657
654
  // which lag the `nodes` commit by a frame or two.
658
655
  const meshEpoch = useMeshSettleEpoch(nodes)
659
656
  const box = useMemo(() => {
@@ -663,17 +660,13 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
663
660
  (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
664
661
  )
665
662
  if (participantIds.length === 0) return null
666
- const fullIds = expandToComponent(participantIds, nodes, levelId)
667
- const world = computeGroupBox(fullIds)
668
- if (!world) return null
669
- const { inverse } = levelFrame(levelId)
670
- const min = world.min.clone().applyMatrix4(inverse)
671
- const max = world.max.clone().applyMatrix4(inverse)
663
+ const plan = computeGroupPlanBox(participantIds, levelId)
664
+ if (!plan) return null
672
665
  return {
673
- x: Math.min(min.x, max.x),
674
- z: Math.min(min.z, max.z),
675
- width: Math.abs(max.x - min.x),
676
- depth: Math.abs(max.z - min.z),
666
+ x: plan.minX,
667
+ z: plan.minZ,
668
+ width: plan.maxX - plan.minX,
669
+ depth: plan.maxZ - plan.minZ,
677
670
  }
678
671
  }, [selectedIds, levelId, nodes, meshEpoch])
679
672
 
@@ -14,8 +14,19 @@ import {
14
14
  } from '../../lib/floorplan/floorplan-mode'
15
15
  import useEditor from '../../store/use-editor'
16
16
  import useFloorplanMode from '../../store/use-floorplan-mode'
17
+ import useInteractionScope, { useReshapingNode } from '../../store/use-interaction-scope'
17
18
 
18
- const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType<FloorplanToolContext>>()
19
+ type Loader = () => Promise<{ default: ComponentType<FloorplanToolContext> }>
20
+ const lazyToolCache = new WeakMap<Loader, ComponentType<FloorplanToolContext>>()
21
+
22
+ function lazyTool(loader: Loader | undefined): ComponentType<FloorplanToolContext> | null {
23
+ if (!loader) return null
24
+ const cached = lazyToolCache.get(loader)
25
+ if (cached) return cached
26
+ const component = lazy(loader)
27
+ lazyToolCache.set(loader, component)
28
+ return component
29
+ }
19
30
 
20
31
  function registeredFloorplanTool(
21
32
  tool: string | null,
@@ -24,13 +35,16 @@ function registeredFloorplanTool(
24
35
  if (!tool) return null
25
36
  const extension = getFloorplanNodeExtension(nodeRegistry.get(tool))
26
37
  if (!isFloorplanToolAvailableInMode(extension?.availableModes, mode)) return null
27
- const loader = extension?.tool
28
- if (!loader) return null
29
- const cached = lazyToolCache.get(loader)
30
- if (cached) return cached
31
- const component = lazy(loader)
32
- lazyToolCache.set(loader, component)
33
- return component
38
+ return lazyTool(extension?.tool)
39
+ }
40
+
41
+ /** The plan sibling of `def.affordanceTools[reshape]`: a kind's layer for its own reshape. */
42
+ function registeredReshapeLayer(
43
+ kind: string | null,
44
+ reshape: string | null,
45
+ ): ComponentType<FloorplanToolContext> | null {
46
+ if (!(kind && reshape)) return null
47
+ return lazyTool(getFloorplanNodeExtension(nodeRegistry.get(kind))?.reshapeLayers?.[reshape])
34
48
  }
35
49
 
36
50
  export function FloorplanRegisteredToolLayer() {
@@ -42,6 +56,10 @@ export function FloorplanRegisteredToolLayer() {
42
56
  const toolDefaults = useEditor((state) =>
43
57
  state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
44
58
  )
59
+ const reshape = useInteractionScope((state) =>
60
+ state.scope.kind === 'reshaping' ? state.scope.reshape : null,
61
+ )
62
+ const reshapingNode = useReshapingNode()
45
63
  const activeLevelId = useViewer((state) => state.selection.levelId)
46
64
  const unit = useViewer((state) => state.unit)
47
65
  const metricNotation = useViewer((state) => state.metricNotation)
@@ -55,11 +73,14 @@ export function FloorplanRegisteredToolLayer() {
55
73
  useEditor.getState().setTool(null)
56
74
  useEditor.getState().setMode('select')
57
75
  }, [])
58
- if (mode !== 'build') return null
59
- const Tool = registeredToolEnabled ? registeredFloorplanTool(tool, floorplanMode) : null
60
- return Tool ? (
76
+ const Active =
77
+ registeredReshapeLayer(reshapingNode?.type ?? null, reshape) ??
78
+ (mode === 'build' && registeredToolEnabled
79
+ ? registeredFloorplanTool(tool, floorplanMode)
80
+ : null)
81
+ return Active ? (
61
82
  <Suspense fallback={null}>
62
- <Tool
83
+ <Active
63
84
  activeLevelId={activeLevelId}
64
85
  finishTool={finishTool}
65
86
  gridSnapStep={gridSnapStep}
@@ -6,10 +6,13 @@ import {
6
6
  type AnyNodeId,
7
7
  bboxAnchors,
8
8
  bboxCornerAnchors,
9
+ cascadeDirty,
10
+ collectDescendants,
9
11
  createSceneApi,
10
12
  emitter,
11
13
  type FloorplanMoveTargetSession,
12
14
  type GroupMoveSnapResult,
15
+ getEffectiveNode,
13
16
  type MovableConfig,
14
17
  nodeRegistry,
15
18
  pauseSceneHistory,
@@ -18,8 +21,9 @@ import {
18
21
  useLiveTransforms,
19
22
  useScene,
20
23
  } from '@pascal-app/core'
24
+ import { nodeLevelFrame } from '@pascal-app/core/procedural-items'
21
25
  import { useViewer } from '@pascal-app/viewer'
22
- import { useEffect } from 'react'
26
+ import { useEffect, useMemo } from 'react'
23
27
  import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
24
28
  import { isHistoryShortcut } from '../../lib/history'
25
29
  import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
@@ -30,6 +34,7 @@ import {
30
34
  } from '../../lib/rigid-plan-svg-transform'
31
35
  import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
32
36
  import { sfxEmitter } from '../../lib/sfx-bus'
37
+ import { surfaceAttachmentId } from '../../lib/surface-attachment'
33
38
  import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
34
39
  import useAlignmentGuides from '../../store/use-alignment-guides'
35
40
  import useEditor, {
@@ -37,7 +42,11 @@ import useEditor, {
37
42
  isGridSnapActive,
38
43
  isMagneticSnapActive,
39
44
  } from '../../store/use-editor'
40
- import { useMovingNode } from '../../store/use-interaction-scope'
45
+ import useInteractionScope, {
46
+ isInteractionSubtreeDraft,
47
+ useMovingNode,
48
+ } from '../../store/use-interaction-scope'
49
+ import usePlacementPreview from '../../store/use-placement-preview'
41
50
  import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
42
51
 
43
52
  // Figma-style alignment snap threshold. Meters in world space; 8cm gives
@@ -67,7 +76,14 @@ const ALIGNMENT_THRESHOLD_M = 0.08
67
76
  * cursor → meters accounts for pan / zoom / building rotation.
68
77
  */
69
78
  export function FloorplanRegistryMoveOverlay() {
70
- const movingNode = useMovingNode()
79
+ const source = useMovingNode()
80
+ const movingNode = useMemo(
81
+ () =>
82
+ source && isInteractionSubtreeDraft(source.id)
83
+ ? (useScene.getState().nodes[source.id] ?? source)
84
+ : source,
85
+ [source],
86
+ )
71
87
  const setMovingNode = useEditor((s) => s.setMovingNode)
72
88
  const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
73
89
 
@@ -93,7 +109,15 @@ export function FloorplanRegistryMoveOverlay() {
93
109
  return [m.x, m.y]
94
110
  }
95
111
 
96
- const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => {
112
+ const isPointerOverFloorplanScene = (event: PointerEvent): boolean => {
113
+ // View controls overlap the SVG viewport; clicking one must not place the draft.
114
+ if (
115
+ (event.target as Element | null)?.closest?.(
116
+ 'button, input, select, textarea, [role="button"]',
117
+ )
118
+ )
119
+ return false
120
+ const { clientX, clientY } = event
97
121
  // The scene's `<g>` only covers painted SVG elements, so hovers over
98
122
  // empty grid background often target the parent SVG. Bounds keep the
99
123
  // cursor active anywhere inside the floor-plan viewport.
@@ -108,6 +132,8 @@ export function FloorplanRegistryMoveOverlay() {
108
132
  )
109
133
  }
110
134
 
135
+ const ownsSubtree = useInteractionScope.getState().adoptSubtree(movingNode.id)
136
+
111
137
  // ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
112
138
  if (hasMoveTarget && def?.floorplanMoveTarget) {
113
139
  const sceneNodes = useScene.getState().nodes
@@ -137,7 +163,12 @@ export function FloorplanRegistryMoveOverlay() {
137
163
  for (const id of session.affectedIds) {
138
164
  liveTransforms.clear(id)
139
165
  liveOverrides.clear(id)
140
- scene.markDirty(id)
166
+ const api = createSceneApi(useScene)
167
+ for (const dirtyId of new Set([
168
+ ...cascadeDirty(id, { scene: api }),
169
+ ...collectDescendants(id, { scene: api }),
170
+ ]))
171
+ scene.markDirty(dirtyId)
141
172
  }
142
173
  }
143
174
 
@@ -161,9 +192,29 @@ export function FloorplanRegistryMoveOverlay() {
161
192
  // the 2D pane the overlay claimed R forever and the 3D flip went dead.
162
193
  let pointerOverFloorplan = false
163
194
  const onPointerTrack = (event: PointerEvent) => {
164
- pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY)
195
+ pointerOverFloorplan = isPointerOverFloorplanScene(event)
165
196
  }
166
197
 
198
+ let rejectionIndicator: SVGCircleElement | null = null
199
+ let lastPlanPoint: [number, number] | null = null
200
+ const clearRejection = () => {
201
+ rejectionIndicator?.remove()
202
+ rejectionIndicator = null
203
+ }
204
+ const showRejection = () => {
205
+ if (!lastPlanPoint) return
206
+ clearRejection()
207
+ rejectionIndicator = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
208
+ rejectionIndicator.setAttribute('cx', String(lastPlanPoint[0]))
209
+ rejectionIndicator.setAttribute('cy', String(lastPlanPoint[1]))
210
+ rejectionIndicator.setAttribute('r', '0.15')
211
+ rejectionIndicator.setAttribute('fill', 'none')
212
+ rejectionIndicator.setAttribute('stroke', '#ef4444')
213
+ rejectionIndicator.setAttribute('stroke-width', '1.5')
214
+ rejectionIndicator.setAttribute('vector-effect', 'non-scaling-stroke')
215
+ rejectionIndicator.setAttribute('pointer-events', 'none')
216
+ scene.appendChild(rejectionIndicator)
217
+ }
167
218
  const onMove = (event: PointerEvent) => {
168
219
  // Skip 3D-canvas / other-UI cursor moves so the overlay only
169
220
  // tracks pointer events that actually correspond to a floor-plan
@@ -172,10 +223,12 @@ export function FloorplanRegistryMoveOverlay() {
172
223
  // hovers over empty grid background — without it, the cursor
173
224
  // only updated the shelf when it happened to brush over an
174
225
  // existing SVG entry, leaving the move feeling "stuck" elsewhere.
175
- if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
226
+ if (!isPointerOverFloorplanScene(event)) return
176
227
  const planPoint = toMeters(event.clientX, event.clientY)
177
228
  if (!planPoint) return
178
229
  hasMovedSinceStart = true
230
+ lastPlanPoint = planPoint
231
+ clearRejection()
179
232
  session.apply({
180
233
  planPoint,
181
234
  modifiers: {
@@ -190,7 +243,14 @@ export function FloorplanRegistryMoveOverlay() {
190
243
  // ticks as the item lands on each new snapped/free position.
191
244
  const movedId = session.affectedIds[0]
192
245
  const moved = movedId ? useScene.getState().nodes[movedId] : undefined
193
- const pos = (moved as { position?: [number, number, number] } | undefined)?.position
246
+ const effective = moved ? getEffectiveNode(moved) : undefined
247
+ const pos =
248
+ effective?.type === 'item' || effective?.type === 'procedural-item'
249
+ ? nodeLevelFrame(effective.id, {
250
+ ...useScene.getState().nodes,
251
+ [effective.id]: effective,
252
+ }).position
253
+ : (effective as { position?: [number, number, number] } | undefined)?.position
194
254
  if (pos) {
195
255
  const key = movementSfxStepKey({
196
256
  coords: [pos[0], pos[2]],
@@ -211,12 +271,19 @@ export function FloorplanRegistryMoveOverlay() {
211
271
  ?.metadata,
212
272
  )
213
273
 
214
- // Claim ownership of the drag teardown so the 3D move tool's
215
- // unmount-time cleanup skips its restore-from-snapshot — see
216
- // `movingNodeOrigin` in `use-editor.tsx`. Set here (before any
217
- // `setMovingNode(null)`) so that by the time the 3D effect's
218
- // cleanup runs the origin is observable in the store.
219
- setMovingNodeOrigin('2d')
274
+ const atomicPreview =
275
+ freshPlacement &&
276
+ (ownsSubtree ||
277
+ ('children' in movingNode && movingNode.children.length > 0) ||
278
+ surfaceAttachmentId(useScene.getState().nodes[movingNode.id] ?? movingNode) !== null)
279
+ if (atomicPreview && !commitValid) {
280
+ showRejection()
281
+ return false
282
+ }
283
+
284
+ // Keep ordinary teardown order. Validated replacements can refuse, so they
285
+ // claim teardown only after success, before clearing the moving node.
286
+ if (!atomicPreview) setMovingNodeOrigin('2d')
220
287
 
221
288
  // Sessions with a `commit` hook own their atomic write (e.g.
222
289
  // wall move emits creates + deletes + updates via the junction
@@ -224,17 +291,33 @@ export function FloorplanRegistryMoveOverlay() {
224
291
  // and Phase 2's resume — but Phase 2's write is delegated, and
225
292
  // we skip the snapshot-diff finalUpdates path.
226
293
  if (commitValid && freshPlacement) {
227
- session.commit?.()
294
+ // Subtrees finalize from the preview. Staging a session commit would publish
295
+ // an unvalidated graph, including for presets created outside our factory.
296
+ if (!atomicPreview) session.commit?.()
228
297
  const stagedNode = useScene.getState().nodes[movingNode.id]
298
+ const preview = usePlacementPreview.getState().node
299
+ const effective = stagedNode ? getEffectiveNode(stagedNode) : null
300
+ const candidate =
301
+ effective?.visible === false && preview?.id === movingNode.id ? preview : effective
229
302
  const committedId = stagedNode
230
303
  ? commitFreshPlacementSubtree(
231
304
  movingNode.id as AnyNodeId,
232
305
  {
306
+ ...(atomicPreview ? candidate : {}),
233
307
  metadata: stripPlacementMetadataFlags(stagedNode.metadata),
234
308
  visible: true,
235
309
  } as Partial<AnyNode>,
310
+ showRejection,
236
311
  )
237
312
  : null
313
+ if (!committedId && atomicPreview) return false
314
+ if (atomicPreview) {
315
+ setMovingNodeOrigin('2d')
316
+ for (const id of session.affectedIds) {
317
+ useLiveTransforms.getState().clear(id)
318
+ useLiveNodeOverrides.getState().clear(id)
319
+ }
320
+ }
238
321
  if (historyPaused) {
239
322
  resumeSceneHistory(useScene)
240
323
  historyPaused = false
@@ -247,7 +330,7 @@ export function FloorplanRegistryMoveOverlay() {
247
330
  }
248
331
 
249
332
  if (commitValid && session.commit) {
250
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
333
+ restoreSnapshots(snapshots)
251
334
  if (historyPaused) {
252
335
  resumeSceneHistory(useScene)
253
336
  historyPaused = false
@@ -298,7 +381,7 @@ export function FloorplanRegistryMoveOverlay() {
298
381
  // 1. Revert to baseline while history is still paused.
299
382
  // 2. Resume history.
300
383
  // 3. Re-apply the final state — recorded as one tracked change.
301
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
384
+ restoreSnapshots(snapshots)
302
385
  if (historyPaused) {
303
386
  resumeSceneHistory(useScene)
304
387
  historyPaused = false
@@ -312,7 +395,7 @@ export function FloorplanRegistryMoveOverlay() {
312
395
  // brings them back at the new position.
313
396
  useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
314
397
  } else {
315
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
398
+ restoreSnapshots(snapshots)
316
399
  if (historyPaused) {
317
400
  resumeSceneHistory(useScene)
318
401
  historyPaused = false
@@ -325,7 +408,7 @@ export function FloorplanRegistryMoveOverlay() {
325
408
  // Bounding-rect check (see `isPointerOverFloorplanScene`) — same
326
409
  // reason as `onMove`: commits should land for any pointer-up
327
410
  // inside the SVG viewport, including empty grid background.
328
- if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
411
+ if (!isPointerOverFloorplanScene(event)) return
329
412
  if (!hasMovedSinceStart) return
330
413
 
331
414
  // Commit using the LAST pointermove's state — no re-apply at
@@ -344,7 +427,7 @@ export function FloorplanRegistryMoveOverlay() {
344
427
  // pointermove right before pointerup, so the trade-off lands
345
428
  // on the side of WYSIWYG.
346
429
 
347
- commitFinalStateOrRevert()
430
+ if (commitFinalStateOrRevert() === false) return
348
431
  setMovingNode(null)
349
432
 
350
433
  // Swallow the click event that follows this pointer-up — the
@@ -408,7 +491,8 @@ export function FloorplanRegistryMoveOverlay() {
408
491
  setMovingNodeOrigin('2d')
409
492
  if (isFreshPlacementMetadata((movingNode as { metadata?: unknown }).metadata)) {
410
493
  emitter.emit('tool:cancel')
411
- useScene.getState().deleteNode(movingNode.id as AnyNodeId)
494
+ if (!ownsSubtree && !isInteractionSubtreeDraft(movingNode.id))
495
+ useScene.getState().deleteNode(movingNode.id)
412
496
  if (historyPaused) {
413
497
  resumeSceneHistory(useScene)
414
498
  historyPaused = false
@@ -419,7 +503,7 @@ export function FloorplanRegistryMoveOverlay() {
419
503
  return
420
504
  }
421
505
  // Revert untracked, then resume — no history entry.
422
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
506
+ restoreSnapshots(snapshots)
423
507
  if (historyPaused) {
424
508
  resumeSceneHistory(useScene)
425
509
  historyPaused = false
@@ -444,6 +528,7 @@ export function FloorplanRegistryMoveOverlay() {
444
528
  // then pre-empts it so only one handler flips.
445
529
  window.addEventListener('keydown', onKey, true)
446
530
  return () => {
531
+ clearRejection()
447
532
  window.removeEventListener('pointermove', onPointerTrack)
448
533
  window.removeEventListener('pointermove', onMove)
449
534
  window.removeEventListener('pointerup', onPointerUp)
@@ -464,11 +549,12 @@ export function FloorplanRegistryMoveOverlay() {
464
549
  // our unmount
465
550
  // are now distinguished by the origin flag — no scene-state
466
551
  // diff heuristic required.
552
+ const freshPlacement = ownsSubtree
467
553
  if (historyPaused) {
468
- if (hasMovedSinceStart) {
469
- const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
470
- if (!finalisedBy3D) {
471
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
554
+ const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
555
+ if (!finalisedBy3D && !freshPlacement) {
556
+ if (hasMovedSinceStart) {
557
+ restoreSnapshots(snapshots)
472
558
  }
473
559
  }
474
560
  resumeSceneHistory(useScene)
@@ -478,7 +564,7 @@ export function FloorplanRegistryMoveOverlay() {
478
564
  // `useLiveTransforms`; wall sessions write to
479
565
  // `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
480
566
  // tool's cleanup isn't there to clear them for us.
481
- clearLivePreviews()
567
+ if (!freshPlacement) clearLivePreviews()
482
568
  // Sessions that publish Figma-style alignment guides during `apply`
483
569
  // (item / shelf / column) leave them in the store; this cleanup runs
484
570
  // after every terminal path (commit + Esc both unmount via
@@ -589,7 +675,7 @@ export function FloorplanRegistryMoveOverlay() {
589
675
  // Same target guard as Path 1 — pointer must be over the floor
590
676
  // plan scene; otherwise we'd react to 3D-canvas moves with garbage
591
677
  // plan coords.
592
- if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
678
+ if (!isPointerOverFloorplanScene(event)) return
593
679
  const m = toMeters(event.clientX, event.clientY)
594
680
  if (!m) return
595
681
  forcePlace = event.altKey
@@ -740,7 +826,7 @@ export function FloorplanRegistryMoveOverlay() {
740
826
 
741
827
  const onPointerUp = (event: PointerEvent) => {
742
828
  if (event.button !== 0) return
743
- if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
829
+ if (!isPointerOverFloorplanScene(event)) return
744
830
 
745
831
  const snapped = lastSnapped
746
832
  if (!snapped) return
@@ -754,8 +840,14 @@ export function FloorplanRegistryMoveOverlay() {
754
840
  ]
755
841
  : snapped.rotation
756
842
  const rotationPatch = 'rotation' in movingNode ? { rotation } : {}
757
- setMovingNodeOrigin('2d')
843
+ const atomicPreview =
844
+ isFreshPlacement &&
845
+ (ownsSubtree ||
846
+ ('children' in movingNode && movingNode.children.length > 0) ||
847
+ surfaceAttachmentId(useScene.getState().nodes[movingNode.id] ?? movingNode) !== null)
848
+ if (!atomicPreview) setMovingNodeOrigin('2d')
758
849
  if (!lastPositionValid && !forcePlace) {
850
+ if (atomicPreview) setMovingNodeOrigin('2d')
759
851
  for (const relatedEntry of relatedEntries) {
760
852
  relatedEntry.removeAttribute('transform')
761
853
  }
@@ -766,6 +858,7 @@ export function FloorplanRegistryMoveOverlay() {
766
858
  }
767
859
  let selectedId = movingNode.id as AnyNodeId
768
860
  if (originalPath) {
861
+ if (atomicPreview) setMovingNodeOrigin('2d')
769
862
  // Polyline kinds: shift every point by the committed delta and
770
863
  // write `path`. Strip the fresh-placement flags on first drop.
771
864
  const dx = sx - originalPosition[0]
@@ -795,18 +888,21 @@ export function FloorplanRegistryMoveOverlay() {
795
888
  return
796
889
  }
797
890
  if (isFreshPlacement) {
798
- selectedId =
799
- commitFreshPlacementSubtree(
800
- movingNode.id as AnyNodeId,
801
- {
802
- position: [sx, oldY, sz],
803
- ...rotationPatch,
804
- metadata: stripPlacementMetadataFlags(
805
- (movingNode as { metadata?: unknown }).metadata,
806
- ),
807
- visible: true,
808
- } as Partial<AnyNode>,
809
- ) ?? selectedId
891
+ const committedId = commitFreshPlacementSubtree(
892
+ movingNode.id as AnyNodeId,
893
+ {
894
+ position: [sx, oldY, sz],
895
+ ...rotationPatch,
896
+ metadata: stripPlacementMetadataFlags((movingNode as { metadata?: unknown }).metadata),
897
+ visible: true,
898
+ } as Partial<AnyNode>,
899
+ )
900
+ if (!committedId && atomicPreview) {
901
+ boxEl.setAttribute('stroke', '#ef4444')
902
+ return
903
+ }
904
+ if (atomicPreview) setMovingNodeOrigin('2d')
905
+ selectedId = committedId ?? selectedId
810
906
  } else {
811
907
  useScene.getState().updateNode(
812
908
  movingNode.id as AnyNodeId,
@@ -836,11 +932,13 @@ export function FloorplanRegistryMoveOverlay() {
836
932
  setMovingNodeOrigin('2d')
837
933
  if (isFreshPlacement) {
838
934
  emitter.emit('tool:cancel')
839
- const temporal = useScene.temporal.getState()
840
- const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
841
- if (wasTracking) temporal.pause()
842
- useScene.getState().deleteNode(movingNode.id as AnyNodeId)
843
- if (wasTracking) temporal.resume()
935
+ if (!ownsSubtree && !isInteractionSubtreeDraft(movingNode.id)) {
936
+ const temporal = useScene.temporal.getState()
937
+ const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
938
+ if (wasTracking) temporal.pause()
939
+ useScene.getState().deleteNode(movingNode.id)
940
+ if (wasTracking) temporal.resume()
941
+ }
844
942
  }
845
943
  for (const relatedEntry of relatedEntries) {
846
944
  relatedEntry.removeAttribute('transform')
@@ -888,8 +986,15 @@ function snapshotNode(node: AnyNode): NodeSnapshot {
888
986
  return { id: node.id, data }
889
987
  }
890
988
 
891
- function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
892
- return snapshots.map((s) => ({ id: s.id, data: s.data }))
989
+ function restoreSnapshots(snapshots: NodeSnapshot[]) {
990
+ const updates = snapshots
991
+ .filter(
992
+ (s) =>
993
+ !useScene.getState().nodes[s.id] ||
994
+ !deepEqual(s.data, snapshotNode(useScene.getState().nodes[s.id]!).data),
995
+ )
996
+ .map((s) => ({ id: s.id, data: s.data }))
997
+ if (updates.length) useScene.getState().updateNodes(updates)
893
998
  }
894
999
 
895
1000
  function deepEqual(a: unknown, b: unknown): boolean {