@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
@@ -4,7 +4,6 @@ import type {
4
4
  CeilingEvent,
5
5
  CeilingNode,
6
6
  GridEvent,
7
- ItemNode,
8
7
  NodeEvent,
9
8
  RoofEvent,
10
9
  RoofNode,
@@ -17,6 +16,7 @@ import type {
17
16
  } from '@pascal-app/core'
18
17
  import {
19
18
  clampRectToRoofWallFace,
19
+ clearFaceHostItemFields,
20
20
  createSceneApi,
21
21
  getRoofSegmentWallFace,
22
22
  getScaledDimensions,
@@ -24,11 +24,13 @@ import {
24
24
  resolveSurfacePlacement,
25
25
  roofFacePointToSegment,
26
26
  sceneRegistry,
27
+ snapLocalXZInWorld,
27
28
  useScene,
29
+ wouldCreateHostingCycle,
28
30
  } from '@pascal-app/core'
29
31
  import { Euler, Quaternion, Vector3 } from 'three'
30
32
  import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
31
- import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
33
+ import { getActiveBuildingPose } from '../../../lib/world-grid-snap'
32
34
  import { itemEventToSurfaceHit, surfaceWorldNormalY } from '../shared/surface-hit'
33
35
  import {
34
36
  calculateItemRotation,
@@ -49,20 +51,6 @@ import type {
49
51
  } from './placement-types'
50
52
 
51
53
  const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
52
- function isDescendantOfItem(
53
- candidate: AnyNode,
54
- ancestorId: string,
55
- nodes: Record<string, AnyNode>,
56
- ): boolean {
57
- let parentId = candidate.parentId
58
- while (parentId) {
59
- if (parentId === ancestorId) return true
60
- const parent = nodes[parentId as AnyNodeId]
61
- parentId = parent?.parentId ?? null
62
- }
63
- return false
64
- }
65
-
66
54
  // ============================================================================
67
55
  // FLOOR STRATEGY
68
56
  // ============================================================================
@@ -89,11 +77,12 @@ export const floorStrategy = {
89
77
  // the world grid.
90
78
  // Snapping is governed by the active mode (snapToGrid returns raw in Off /
91
79
  // non-grid modes); Alt is force-place only and never bypasses snapping here.
92
- const [x, z] = snapWorldXZForActiveBuilding(
93
- snapToGrid(event.position[0], swapDims ? dimZ : dimX),
94
- snapToGrid(event.position[2], swapDims ? dimX : dimZ),
95
- 0,
96
- ).local
80
+ const dimensions = [swapDims ? dimZ : dimX, swapDims ? dimX : dimZ] as const
81
+ const [x, z] = snapLocalXZInWorld(
82
+ [event.localPosition[0], event.localPosition[2]],
83
+ getActiveBuildingPose(),
84
+ (value, axis) => snapToGrid(value, dimensions[axis]),
85
+ )
97
86
  const y = ctx.gridPosition.y
98
87
 
99
88
  return {
@@ -212,6 +201,12 @@ export const wallStrategy = {
212
201
  if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
213
202
  if (!isValidWallSideFace(event.normal)) return null
214
203
 
204
+ if (
205
+ ctx.draftItem &&
206
+ wouldCreateHostingCycle(ctx.draftItem.id, event.node, createSceneApi(useScene))
207
+ )
208
+ return null
209
+
215
210
  // Level guard
216
211
  const wallLevelId = resolveLevelId(event.node, nodes)
217
212
  if (ctx.levelId !== wallLevelId) return null
@@ -416,6 +411,11 @@ function resolveRoofWallTarget(
416
411
 
417
412
  const hit = resolveRoofWallHit(event.node as RoofNode, event.position, event.normal, event.object)
418
413
  if (!hit) return null
414
+ if (
415
+ ctx.draftItem &&
416
+ wouldCreateHostingCycle(ctx.draftItem.id, hit.segment, createSceneApi(useScene))
417
+ )
418
+ return null
419
419
 
420
420
  const rawDims = ctx.draftItem
421
421
  ? getScaledDimensions(ctx.draftItem)
@@ -605,6 +605,11 @@ export const roofWallStrategy = {
605
605
  // ============================================================================
606
606
 
607
607
  function resolveFaceHostTarget(ctx: PlacementContext, event: NodeEvent) {
608
+ if (
609
+ ctx.draftItem &&
610
+ wouldCreateHostingCycle(ctx.draftItem.id, event.node, createSceneApi(useScene))
611
+ )
612
+ return null
608
613
  const faceHost = nodeRegistry.get(event.node.type)?.capabilities.faceHost
609
614
  if (!faceHost) return null
610
615
  const rawDimensions = ctx.draftItem
@@ -624,18 +629,6 @@ function resolveFaceHostTarget(ctx: PlacementContext, event: NodeEvent) {
624
629
  })
625
630
  }
626
631
 
627
- function clearFaceHostItemFields(ctx: PlacementContext): Partial<ItemNode> {
628
- const host = ctx.state.blockId ? useScene.getState().nodes[ctx.state.blockId] : undefined
629
- const clearFields = host
630
- ? nodeRegistry.get(host.type)?.capabilities.faceHost?.clearItemFields
631
- : undefined
632
- const patch: Partial<ItemNode> = {}
633
- for (const field of clearFields ?? []) {
634
- ;(patch as Record<string, unknown>)[field] = undefined
635
- }
636
- return patch
637
- }
638
-
639
632
  export const faceHostStrategy = {
640
633
  enter(ctx: PlacementContext, event: NodeEvent): TransitionResult | null {
641
634
  const target = resolveFaceHostTarget(ctx, event)
@@ -695,7 +688,9 @@ export const faceHostStrategy = {
695
688
  return {
696
689
  stateUpdate: { surface: 'floor', blockId: null },
697
690
  nodeUpdate: {
698
- ...clearFaceHostItemFields(ctx),
691
+ ...clearFaceHostItemFields(
692
+ ctx.state.blockId ? useScene.getState().nodes[ctx.state.blockId] : undefined,
693
+ ),
699
694
  position: floorPosition,
700
695
  parentId: ctx.levelId,
701
696
  rotation: [0, ctx.currentCursorRotationY, 0],
@@ -725,6 +720,12 @@ export const ceilingStrategy = {
725
720
  ): TransitionResult | null {
726
721
  if (ctx.asset.attachTo !== 'ceiling') return null
727
722
 
723
+ if (
724
+ ctx.draftItem &&
725
+ wouldCreateHostingCycle(ctx.draftItem.id, event.node, createSceneApi(useScene))
726
+ )
727
+ return null
728
+
728
729
  // Level guard
729
730
  const ceilingLevelId = resolveLevelId(event.node, nodes)
730
731
  if (ctx.levelId !== ceilingLevelId) return null
@@ -860,6 +861,8 @@ function resolveCatalogItemSurfacePlacement(
860
861
  worldYaw: number,
861
862
  onReject?: PlacementContext['onSurfaceReject'],
862
863
  rawEvent = event,
864
+ childId?: string,
865
+ childRotation?: [number, number, number],
863
866
  ) {
864
867
  const mesh = sceneRegistry.nodes.get(host.id)
865
868
  if (!mesh) return null
@@ -870,7 +873,12 @@ function resolveCatalogItemSurfacePlacement(
870
873
  const placement = resolveSurfacePlacement({
871
874
  host,
872
875
  childKind: 'item',
873
- childFootprint: { size: dimensions, rotationY: worldYaw - hostYaw },
876
+ childId,
877
+ childFootprint: {
878
+ size: dimensions,
879
+ rotationY: worldYaw - hostYaw,
880
+ rotation: [childRotation?.[0] ?? 0, worldYaw - hostYaw, childRotation?.[2] ?? 0],
881
+ },
874
882
  hit: host.type === 'procedural-item' ? (itemEventToSurfaceHit(host, rawEvent) ?? hit) : hit,
875
883
  origin: host.type === 'procedural-item' ? hit.point : undefined,
876
884
  scene: createSceneApi(useScene),
@@ -890,17 +898,12 @@ function resolveCatalogItemSurfacePlacement(
890
898
  export function validCatalogCounterPose(ctx: PlacementContext): boolean {
891
899
  const hostId = ctx.state.surface === 'shelf-surface' ? ctx.state.shelfId : ctx.state.surfaceItemId
892
900
  const host = hostId ? useScene.getState().nodes[hostId as AnyNodeId] : undefined
893
- if (
894
- host?.type !== 'cabinet' &&
895
- host?.type !== 'shelf' &&
896
- host?.type !== 'item' &&
897
- host?.type !== 'procedural-item'
898
- )
899
- return true
901
+ if (!host) return true
900
902
  if (!ctx.draftItem) return false
901
903
  const placement = resolveSurfacePlacement({
902
904
  host,
903
905
  childKind: 'item',
906
+ childId: ctx.draftItem.id,
904
907
  childFootprint: {
905
908
  size: getScaledDimensions(ctx.draftItem),
906
909
  rotationY: ctx.draftItem.rotation[1],
@@ -936,7 +939,7 @@ export const itemSurfaceStrategy = {
936
939
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
937
940
  if (
938
941
  ctx.draftItem &&
939
- isDescendantOfItem(surfaceItem, ctx.draftItem.id, useScene.getState().nodes)
942
+ wouldCreateHostingCycle(ctx.draftItem.id, surfaceItem, createSceneApi(useScene))
940
943
  )
941
944
  return null
942
945
  const pose = resolveCatalogItemSurfacePlacement(
@@ -945,6 +948,9 @@ export const itemSurfaceStrategy = {
945
948
  ourDims,
946
949
  ctx.currentCursorRotationY,
947
950
  ctx.onSurfaceReject,
951
+ event,
952
+ ctx.draftItem?.id,
953
+ ctx.draftItem?.rotation,
948
954
  )
949
955
  if (!pose) return null
950
956
  const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
@@ -984,6 +990,8 @@ export const itemSurfaceStrategy = {
984
990
  ctx.currentCursorRotationY,
985
991
  ctx.onSurfaceReject,
986
992
  rawEvent,
993
+ ctx.draftItem.id,
994
+ ctx.draftItem.rotation,
987
995
  )
988
996
  if (!pose) return null
989
997
 
@@ -1030,6 +1038,7 @@ function resolveShelfSurfacePlacement(
1030
1038
  worldYaw: number,
1031
1039
  entering: boolean,
1032
1040
  onReject?: PlacementContext['onSurfaceReject'],
1041
+ childId?: string,
1033
1042
  ) {
1034
1043
  const mesh = sceneRegistry.nodes.get(host.id)
1035
1044
  if (!mesh) return null
@@ -1039,6 +1048,7 @@ function resolveShelfSurfacePlacement(
1039
1048
  const placement = resolveSurfacePlacement({
1040
1049
  host,
1041
1050
  childKind: 'item',
1051
+ childId,
1042
1052
  childFootprint: { size: dimensions, rotationY: worldYaw - hostYaw },
1043
1053
  hit: {
1044
1054
  point: local.toArray(),
@@ -1085,6 +1095,7 @@ export const shelfSurfaceStrategy = {
1085
1095
  ctx.currentCursorRotationY,
1086
1096
  true,
1087
1097
  ctx.onSurfaceReject,
1098
+ ctx.draftItem?.id,
1088
1099
  )
1089
1100
  if (!pose) return null
1090
1101
  const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
@@ -1119,6 +1130,7 @@ export const shelfSurfaceStrategy = {
1119
1130
  ctx.currentCursorRotationY,
1120
1131
  false,
1121
1132
  ctx.onSurfaceReject,
1133
+ ctx.draftItem.id,
1122
1134
  )
1123
1135
  if (!pose) return null
1124
1136
 
@@ -3,7 +3,6 @@ import * as core from '@pascal-app/core'
3
3
  import {
4
4
  ItemNode,
5
5
  nodeRegistry,
6
- registerNode,
7
6
  type ShelfEvent,
8
7
  ShelfNode,
9
8
  sceneRegistry,
@@ -12,6 +11,7 @@ import {
12
11
  import { Euler, Group, Quaternion, Vector3 } from 'three'
13
12
  import useEditor from '../../../store/use-editor'
14
13
  import useInteractionScope from '../../../store/use-interaction-scope'
14
+ import { registerHostingTestNode } from '../__fixtures__/hosting'
15
15
  import { shelfSurfaceStrategy } from './placement-strategies'
16
16
  import type { PlacementContext } from './placement-types'
17
17
 
@@ -39,7 +39,7 @@ beforeEach(() => {
39
39
  restoreRegistry = nodeRegistry._snapshot()
40
40
  nodeRegistry._reset()
41
41
  rows = [0.5, 1.5]
42
- registerNode({
42
+ registerHostingTestNode({
43
43
  kind: 'shelf',
44
44
  schemaVersion: 1,
45
45
  schema: ShelfNode,
@@ -51,7 +51,7 @@ beforeEach(() => {
51
51
  },
52
52
  },
53
53
  })
54
- registerNode({
54
+ registerHostingTestNode({
55
55
  kind: 'item',
56
56
  schemaVersion: 1,
57
57
  schema: ItemNode,
@@ -3,18 +3,24 @@ import {
3
3
  type AssetInput,
4
4
  ItemNode,
5
5
  resolveSupportSlabPatch,
6
+ type SurfaceRejectReason,
6
7
  sceneRegistry,
7
8
  useScene,
8
9
  } from '@pascal-app/core'
9
10
  import { beginPerfAction, commitPerfAction, useViewer } from '@pascal-app/viewer'
10
11
  import { useCallback, useMemo, useRef } from 'react'
11
12
  import type { Vector3 } from 'three'
13
+ import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
14
+ import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
12
15
  import {
13
16
  surfaceAttachmentId,
14
17
  surfaceAttachmentUpdates,
15
18
  surfaceFramePose,
16
19
  updateSurfaceNode,
17
20
  } from '../../../lib/surface-attachment'
21
+ import useInteractionScope, {
22
+ isInteractionSubtreeDraft,
23
+ } from '../../../store/use-interaction-scope'
18
24
  import usePlacementPreview from '../../../store/use-placement-preview'
19
25
  import { stripTransient } from './placement-math'
20
26
 
@@ -58,6 +64,7 @@ export interface DraftNodeHandle {
58
64
  options?: {
59
65
  supportElevationCap?: number | null
60
66
  preferredSupportSlabId?: string | null
67
+ onReject?: (reason: SurfaceRejectReason) => void
61
68
  pinSupport?: boolean
62
69
  },
63
70
  ) => string | null
@@ -76,6 +83,7 @@ export interface DraftNodeHandle {
76
83
  export function useDraftNode(): DraftNodeHandle {
77
84
  const draftRef = useRef<ItemNode | null>(null)
78
85
  const adoptedRef = useRef(false)
86
+ const ownsSubtreeRef = useRef(false)
79
87
  const originalStateRef = useRef<OriginalState | null>(null)
80
88
 
81
89
  const create = useCallback(
@@ -106,6 +114,7 @@ export function useDraftNode(): DraftNodeHandle {
106
114
  .set(node, useScene.getState().nodes[currentLevelId as AnyNodeId] ?? null)
107
115
  draftRef.current = node
108
116
  adoptedRef.current = false
117
+ ownsSubtreeRef.current = false
109
118
  originalStateRef.current = null
110
119
  return node
111
120
  },
@@ -113,6 +122,8 @@ export function useDraftNode(): DraftNodeHandle {
113
122
  )
114
123
 
115
124
  const adopt = useCallback((node: ItemNode): void => {
125
+ ownsSubtreeRef.current =
126
+ useInteractionScope.getState().adoptSubtree(node.id) || isInteractionSubtreeDraft(node.id)
116
127
  // Save original state so destroy() can restore it
117
128
  const meta =
118
129
  typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
@@ -155,6 +166,7 @@ export function useDraftNode(): DraftNodeHandle {
155
166
  options?: {
156
167
  supportElevationCap?: number | null
157
168
  preferredSupportSlabId?: string | null
169
+ onReject?: (reason: SurfaceRejectReason) => void
158
170
  pinSupport?: boolean
159
171
  },
160
172
  ): string | null => {
@@ -169,6 +181,29 @@ export function useDraftNode(): DraftNodeHandle {
169
181
  true,
170
182
  )
171
183
  finalUpdate = { ...finalUpdate, ...stored }
184
+ if (isFreshPlacementMetadata(originalStateRef.current?.metadata)) {
185
+ const effectiveNode = ItemNode.parse({ ...draft, ...finalUpdate })
186
+ const id = commitFreshPlacementSubtree(
187
+ draft.id,
188
+ {
189
+ ...finalUpdate,
190
+ ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
191
+ maxElevation: options?.supportElevationCap,
192
+ preferredSlabId: options?.preferredSupportSlabId,
193
+ pinSupport: options?.pinSupport,
194
+ }),
195
+ },
196
+ options?.onReject,
197
+ )
198
+ if (!id) return null
199
+ if (usePlacementPreview.getState().node?.id === draft.id) {
200
+ usePlacementPreview.getState().clear()
201
+ }
202
+ draftRef.current = null
203
+ adoptedRef.current = false
204
+ originalStateRef.current = null
205
+ return id
206
+ }
172
207
  if (adoptedRef.current) {
173
208
  // Move mode: update in place (single undoable action)
174
209
  const { parentId: newParentId, ...updateProps } = finalUpdate
@@ -311,8 +346,12 @@ export function useDraftNode(): DraftNodeHandle {
311
346
  if (!draftRef.current) return
312
347
 
313
348
  const draftId = draftRef.current.id
314
-
315
- if (adoptedRef.current && originalStateRef.current) {
349
+ if (ownsSubtreeRef.current) {
350
+ draftRef.current = null
351
+ adoptedRef.current = false
352
+ originalStateRef.current = null
353
+ return
354
+ } else if (adoptedRef.current && originalStateRef.current) {
316
355
  // Move mode: restore original state instead of deleting — but only
317
356
  // if no other system has already committed a new position for this
318
357
  // node. The 2D `FloorplanRegistryMoveOverlay` commits via