@pascal-app/editor 1.0.0 → 1.0.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 (97) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. package/src/store/use-send-to-app.ts +21 -0
@@ -17,6 +17,7 @@ import {
17
17
  resolveFrozenFloorPlacementPatch,
18
18
  resolveLevelId,
19
19
  type ShelfEvent,
20
+ type SurfaceRejectReason,
20
21
  sceneRegistry,
21
22
  useLiveNodeOverrides,
22
23
  useLiveTransforms,
@@ -30,16 +31,13 @@ import { Html } from '@react-three/drei'
30
31
  import { useFrame, useThree } from '@react-three/fiber'
31
32
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
32
33
  import {
33
- Box3,
34
34
  Euler,
35
35
  type Group,
36
36
  type LineSegments,
37
- Matrix4,
38
37
  type Mesh,
39
38
  type Object3D,
40
39
  PlaneGeometry,
41
40
  Quaternion,
42
- Ray,
43
41
  Vector3,
44
42
  } from 'three'
45
43
  import { distance, smoothstep, uv, vec2 } from 'three/tsl'
@@ -50,17 +48,29 @@ import {
50
48
  } from '../../../lib/active-placement-surface'
51
49
  import { EDITOR_LAYER } from '../../../lib/constants'
52
50
  import { formatLinearMeasurement } from '../../../lib/measurements'
51
+ import { createMovementSfxTick } from '../../../lib/sfx/movement-tick'
53
52
  import { sfxEmitter } from '../../../lib/sfx-bus'
54
-
53
+ import {
54
+ surfaceAttachmentId,
55
+ surfaceFramePose,
56
+ updateSurfaceNode,
57
+ } from '../../../lib/surface-attachment'
55
58
  import {
56
59
  projectAlignmentGuidesWorldToActiveBuildingLocal,
57
60
  resolveAlignmentForActiveBuilding,
58
61
  } from '../../../lib/world-grid-snap'
59
62
  import useAlignmentGuides from '../../../store/use-alignment-guides'
60
- import useEditor, { isAlignmentGuideActive, isMagneticSnapActive } from '../../../store/use-editor'
61
-
63
+ import useEditor, {
64
+ isAlignmentGuideActive,
65
+ isGridSnapActive,
66
+ isMagneticSnapActive,
67
+ } from '../../../store/use-editor'
62
68
  import useFacingPose from '../../../store/use-facing-pose'
63
69
  import usePlacementPreview from '../../../store/use-placement-preview'
70
+ import {
71
+ createItemSurfaceGridDispatch,
72
+ createItemSurfacePointerArbitration,
73
+ } from '../registry/item-surface-move'
64
74
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
65
75
  import {
66
76
  createLineGeometry,
@@ -73,6 +83,8 @@ import {
73
83
  resolvePointerSupportElevation,
74
84
  resolvePointerSupportSurface,
75
85
  } from '../shared/pointer-support-cap'
86
+ import { createShelfStickiness } from '../shared/shelf-stickiness'
87
+ import { createSurfaceRejectionFeedback } from '../shared/surface-rejection'
76
88
  import { shouldCreateFloorDraft } from './draft-creation'
77
89
  import { commitFaceHostClick, resolveFaceHostPreviewCommit } from './face-host-commit'
78
90
  import {
@@ -547,10 +559,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
547
559
  }
548
560
  }
549
561
 
562
+ let lastSurfaceEvent: NodeEvent<AnyNode> | null = null
563
+ const feedback = createSurfaceRejectionFeedback()
564
+ feedback.clear()
565
+
550
566
  // ---- Helpers ----
551
567
 
552
568
  const getContext = () => ({
553
569
  asset,
570
+ onSurfaceReject: (reason: SurfaceRejectReason) => feedback.reject(reason),
554
571
  levelId: useViewer.getState().selection.levelId,
555
572
  draftItem: draftNode.current,
556
573
  gridPosition: gridPosition.current,
@@ -633,14 +650,60 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
633
650
  : null
634
651
  }
635
652
 
653
+ const surfacePointer = createItemSurfacePointerArbitration()
636
654
  const revalidate = (): boolean => {
637
- const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
655
+ const fits = checkCanPlace(getContext(), validators)
656
+ if (
657
+ !fits &&
658
+ lastSurfaceEvent &&
659
+ (feedback.reason === 'footprint-outside-surface' ||
660
+ feedback.reason === 'footprint-exceeds-host' ||
661
+ feedback.reason === 'no-surface') &&
662
+ (placementState.current.surface === 'item-surface' ||
663
+ placementState.current.surface === 'shelf-surface')
664
+ ) {
665
+ const hostId =
666
+ placementState.current.surface === 'shelf-surface'
667
+ ? placementState.current.shelfId
668
+ : placementState.current.surfaceItemId
669
+ const mesh = hostId ? sceneRegistry.nodes.get(hostId) : undefined
670
+ if (mesh) {
671
+ const position = mesh.localToWorld(gridPosition.current.clone()).toArray()
672
+ feedback.clear()
673
+ detachItemSurfaceToFloor({ ...lastSurfaceEvent, position } as ItemEvent)
674
+ return revalidate()
675
+ }
676
+ }
677
+ const placeable = !feedback.reason && (altFreeRef.current || fits)
638
678
  const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
639
679
  edgeMaterial.color.setHex(color)
640
680
  basePlaneMaterial.color.setHex(color)
641
681
  return placeable
642
682
  }
643
683
 
684
+ const surfaceContext = (event: NodeEvent<AnyNode>) => {
685
+ lastSurfaceEvent = event
686
+ return {
687
+ ...getContext(),
688
+ onSurfaceReject: (reason: SurfaceRejectReason) => {
689
+ if (
690
+ (reason === 'footprint-outside-surface' ||
691
+ reason === 'footprint-exceeds-host' ||
692
+ reason === 'no-surface') &&
693
+ (placementState.current.surface === 'item-surface' ||
694
+ placementState.current.surface === 'shelf-surface')
695
+ ) {
696
+ feedback.clear()
697
+ detachItemSurfaceToFloor(event as ItemEvent)
698
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
699
+ return
700
+ }
701
+ feedback.reject(reason, event.nativeEvent.nativeEvent ?? event.nativeEvent)
702
+ revalidate()
703
+ },
704
+ }
705
+ }
706
+
644
707
  // Tool visuals are rendered inside the building-local ToolManager group, so all cursor
645
708
  // positions must be in building-local space. Wall/ceiling/item-surface strategies return
646
709
  // world-space cursor positions (from their event.position); convert them here.
@@ -671,7 +734,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
671
734
  return [localRotation.x, localRotation.y, localRotation.z]
672
735
  }
673
736
 
674
- const applyTransition = (result: TransitionResult) => {
737
+ const { tick: tickMovementSfx } = createMovementSfxTick()
738
+ const tickSurfaceMovementSfx = (position: [number, number, number]) => {
739
+ const point = worldToBuildingLocal(...position)
740
+ tickMovementSfx({
741
+ coords: [point.x, point.z],
742
+ gridSnapActive: isGridSnapActive(),
743
+ gridStep: useEditor.getState().gridSnapStep,
744
+ })
745
+ }
746
+
747
+ const applyTransition = (result: TransitionResult, event?: NodeEvent<AnyNode>) => {
748
+ if (event) {
749
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
750
+ tickSurfaceMovementSfx(result.cursorPosition)
751
+ }
752
+ feedback.clear()
675
753
  // Alignment guides are floor-only; clear them when the cursor moves
676
754
  // onto a wall / ceiling / item surface (only those paths call this).
677
755
  useAlignmentGuides.getState().clear()
@@ -734,7 +812,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
734
812
  if (draft) {
735
813
  Object.assign(draft, result.nodeUpdate)
736
814
  // One-time setup: put node in the right parent so it renders correctly
737
- useScene.getState().updateNode(draft.id, result.nodeUpdate)
815
+ if (result.surfaceId !== undefined)
816
+ draftNode.updateSurface(result.nodeUpdate, result.surfaceId)
817
+ else updateSurfaceNode(draft.id, result.nodeUpdate)
738
818
  disableDraftRaycastNow()
739
819
  }
740
820
 
@@ -753,6 +833,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
753
833
 
754
834
  // ---- Init draft ----
755
835
  configRef.current.initDraft(gridPosition.current)
836
+ if (draftNode.current && surfaceAttachmentId(draftNode.current))
837
+ gridPosition.current.set(...draftNode.current.position)
756
838
  const preserveDragOffset = configRef.current.preserveDragOffset === true
757
839
  // The host the item was grabbed from + its pre-drag host-local position.
758
840
  // Each surface's grab anchor preserves the grab offset only on THAT host,
@@ -939,6 +1021,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
939
1021
  setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
940
1022
  }
941
1023
  const onReleaseCommit = () => {
1024
+ gridDispatch.flush()
942
1025
  if (!releaseCommit) return
943
1026
  const commit = releaseCommit
944
1027
  releaseCommit = null
@@ -948,53 +1031,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
948
1031
 
949
1032
  // ---- Floor Handlers ----
950
1033
 
951
- let previousGridPos: [number, number, number] | null = null
952
-
953
- // Scratch objects reused by the stickiness test (runs per grid:move).
954
- const stickyRay = new Ray()
955
- const stickyBox = new Box3()
956
- const stickyMat = new Matrix4()
957
- const stickyCamPos = new Vector3()
958
-
959
- // True while the cursor ray still points at the active shelf's volume.
960
- // Used to keep an item hosted on a shelf "sticky": from an angled camera
961
- // the cursor ray slips off the shelf's thin boards / through its gaps and
962
- // lands on the floor *behind* the shelf, which would otherwise thrash the
963
- // placement between the shelf row and the floor on every micro-move. We
964
- // reconstruct the world ray (camera → grid hit point) and test it against
965
- // the shelf's bounding box — so a ray that passes *through* the shelf but
966
- // lands behind it still counts as "on the shelf". Only a ray that misses
967
- // the shelf box entirely means the user genuinely moved off it. A simple
968
- // footprint test on the floor hit point can't distinguish those.
969
- const cursorRayIntersectsActiveShelf = (gridWorldPoint: [number, number, number]): boolean => {
970
- const shelfId = placementState.current.shelfId
971
- if (!shelfId) return false
972
- const shelfMesh = sceneRegistry.nodes.get(shelfId as AnyNodeId)
973
- const shelfNode = useScene.getState().nodes[shelfId as AnyNodeId] as
974
- | { width?: number; depth?: number; height?: number }
975
- | undefined
976
- if (!(shelfMesh && shelfNode?.width && shelfNode?.depth && shelfNode?.height)) return false
977
-
978
- cameraRef.current.getWorldPosition(stickyCamPos)
979
- stickyRay.origin.copy(stickyCamPos)
980
- stickyRay.direction
981
- .set(
982
- gridWorldPoint[0] - stickyCamPos.x,
983
- gridWorldPoint[1] - stickyCamPos.y,
984
- gridWorldPoint[2] - stickyCamPos.z,
985
- )
986
- .normalize()
987
-
988
- // Into shelf-local space, then test the shelf's local AABB (origin at the
989
- // base: y ∈ [0, height]) with a small margin.
990
- stickyRay.applyMatrix4(stickyMat.copy(shelfMesh.matrixWorld).invert())
991
- const m = 0.08
992
- stickyBox.min.set(-shelfNode.width / 2 - m, -m, -shelfNode.depth / 2 - m)
993
- stickyBox.max.set(shelfNode.width / 2 + m, shelfNode.height + m, shelfNode.depth / 2 + m)
994
- return stickyRay.intersectsBox(stickyBox)
995
- }
1034
+ const cursorRayIntersectsShelf = createShelfStickiness()
996
1035
 
997
1036
  const onGridMove = (event: GridEvent) => {
1037
+ if (surfacePointer.blocksGrid(event.nativeEvent.nativeEvent ?? event.nativeEvent)) return
998
1038
  releaseCommit = () => onGridClick(event)
999
1039
  // Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
1000
1040
  if (
@@ -1027,13 +1067,34 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1027
1067
  // off a board / through a gap and hit the floor behind). Detach to the
1028
1068
  // floor only once the ray misses the shelf entirely — without this the
1029
1069
  // item oscillates between the shelf row and the floor on every micro-move.
1070
+ const counterId =
1071
+ placementState.current.surface === 'item-surface'
1072
+ ? placementState.current.surfaceItemId
1073
+ : null
1074
+ if (counterId && useScene.getState().nodes[counterId as AnyNodeId]?.type === 'cabinet') {
1075
+ if (
1076
+ surfacePointer.blocksGrid(event.nativeEvent.nativeEvent ?? event.nativeEvent) ||
1077
+ cursorRayIntersectsShelf(counterId, cameraRef.current, event.position)
1078
+ )
1079
+ return
1080
+ detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent)
1081
+ }
1030
1082
  if (placementState.current.surface === 'shelf-surface') {
1031
- if (cursorRayIntersectsActiveShelf(event.position)) return
1083
+ if (
1084
+ cursorRayIntersectsShelf(
1085
+ placementState.current.shelfId,
1086
+ cameraRef.current,
1087
+ event.position,
1088
+ )
1089
+ )
1090
+ return
1032
1091
  // Land at the pointed surface's plan point — the raw grid hit is
1033
1092
  // still skewed by the plane riding at the shelf-surface height.
1034
1093
  detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent)
1035
1094
  }
1036
1095
 
1096
+ if (placementState.current.surface === 'floor')
1097
+ feedback.grid(event.nativeEvent.nativeEvent ?? event.nativeEvent)
1037
1098
  const floorEvent = applyFloorGrabOffset(surfaceEvent)
1038
1099
 
1039
1100
  lastRawPos.current.set(
@@ -1120,15 +1181,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1120
1181
  frozenSupportSlabIdRef.current = frozenPatch.supportSlabId
1121
1182
  }
1122
1183
 
1123
- // Play snap sound when grid position changes
1124
- if (
1125
- previousGridPos &&
1126
- (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
1127
- ) {
1128
- sfxEmitter.emit('sfx:grid-snap')
1129
- }
1130
-
1131
- previousGridPos = [...gridPos]
1184
+ tickMovementSfx({
1185
+ coords: [gridPos[0], gridPos[2]],
1186
+ gridSnapActive: isGridSnapActive(),
1187
+ gridStep: useEditor.getState().gridSnapStep,
1188
+ })
1132
1189
  gridPosition.current.set(...gridPos)
1133
1190
  const cursorPosition = getFloorVisualPosition(gridPos)
1134
1191
  if (!draft && asset.attachTo) {
@@ -1161,6 +1218,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1161
1218
  }
1162
1219
 
1163
1220
  const onGridClick = (event: GridEvent) => {
1221
+ gridDispatch.flush()
1222
+ if (feedback.reason) return
1164
1223
  // Drop alignment guides on click — the move commits (guides done) or
1165
1224
  // placement re-arms (the next move republishes them).
1166
1225
  useAlignmentGuides.getState().clear()
@@ -1222,7 +1281,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1222
1281
  ensureDraft(result)
1223
1282
  } else if (result.nodeUpdate.parentId) {
1224
1283
  // Existing draft (move mode): reparent to new wall
1225
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1284
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
1226
1285
  if (result.stateUpdate.wallId) {
1227
1286
  useScene.getState().dirtyNodes.add(result.stateUpdate.wallId as AnyNodeId)
1228
1287
  }
@@ -1249,7 +1308,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1249
1308
  event.stopPropagation()
1250
1309
  applyTransition(enterResult)
1251
1310
  if (draftNode.current && enterResult.nodeUpdate.parentId) {
1252
- useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
1311
+ updateSurfaceNode(draftNode.current.id, enterResult.nodeUpdate)
1253
1312
  if (enterResult.stateUpdate.wallId) {
1254
1313
  useScene.getState().dirtyNodes.add(enterResult.stateUpdate.wallId as AnyNodeId)
1255
1314
  }
@@ -1446,7 +1505,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1446
1505
  ensureDraft(result)
1447
1506
  } else if (result.nodeUpdate.parentId) {
1448
1507
  // Existing draft (move mode): reparent to the segment
1449
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1508
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
1450
1509
  }
1451
1510
  return true
1452
1511
  }
@@ -1575,7 +1634,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1575
1634
  if (!draftNode.current) {
1576
1635
  ensureDraft(result)
1577
1636
  } else if (result.nodeUpdate.parentId) {
1578
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1637
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
1579
1638
  disableDraftRaycastNow()
1580
1639
  }
1581
1640
  if (draftNode.current) useLiveTransforms.getState().clear(draftNode.current.id)
@@ -1679,8 +1738,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1679
1738
 
1680
1739
  // ---- Item Surface Handlers ----
1681
1740
 
1682
- const detachItemSurfaceToFloor = (event: ItemEvent) => {
1741
+ const detachItemSurfaceToFloor = (event: NodeEvent<AnyNode>) => {
1683
1742
  hostSurfaceDragAnchor = null
1743
+ surfacePointer.clear()
1684
1744
  // Landing back on the floor: refresh the pointer surface cap from
1685
1745
  // this event's world hit so the first floor position already targets
1686
1746
  // the aimed-at surface (not a deck above it).
@@ -1753,7 +1813,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1753
1813
  // parent-must-be-a-level guard and the item + grid stopped following
1754
1814
  // slab elevations for the rest of the drag).
1755
1815
  if (levelId) draft.parentId = levelId
1756
- useScene.getState().updateNode(draft.id, {
1816
+ updateSurfaceNode(draft.id, {
1757
1817
  parentId: useViewer.getState().selection.levelId as string,
1758
1818
  position: floorPos,
1759
1819
  rotation,
@@ -1763,29 +1823,42 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1763
1823
  revalidate()
1764
1824
  }
1765
1825
 
1766
- const onItemEnter = (event: ItemEvent) => {
1826
+ const onItemEnter = (event: NodeEvent<AnyNode>) => {
1827
+ if (event.node.type === 'cabinet' && placementState.current.surfaceItemId === event.node.id) {
1828
+ onItemMove(event)
1829
+ return
1830
+ }
1831
+ if (event.node.type === 'cabinet') {
1832
+ lastRawPos.current.set(...event.position)
1833
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
1834
+ }
1767
1835
  if (event.node.id === draftNode.current?.id) return
1768
1836
  has3DPointerDrivenMoveRef.current = true
1769
- const result = itemSurfaceStrategy.enter(getContext(), event)
1837
+ const result = itemSurfaceStrategy.enter(surfaceContext(event), event)
1770
1838
  if (!result) return
1839
+ feedback.clear()
1771
1840
 
1772
1841
  event.stopPropagation()
1773
- applyTransition(result)
1842
+ applyTransition(result, event)
1774
1843
 
1775
1844
  if (!draftNode.current) {
1776
1845
  ensureDraft(result)
1777
1846
  } else if (result.nodeUpdate.parentId) {
1778
1847
  // Existing draft (move mode): reparent to surface item
1779
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1848
+ draftNode.updateSurface(result.nodeUpdate, result.surfaceId ?? null)
1780
1849
  }
1781
1850
  }
1782
1851
 
1783
- const onItemMove = (event: ItemEvent) => {
1852
+ const onItemMove = (event: NodeEvent<AnyNode>) => {
1784
1853
  if (event.node.id === draftNode.current?.id) return
1785
1854
  releaseCommit = () => onItemClick(event)
1786
1855
  has3DPointerDrivenMoveRef.current = true
1787
1856
  if (!cursorGroupRef.current) return
1788
- const ctx = getContext()
1857
+ const ctx = surfaceContext(event)
1858
+ if (event.node.type === 'cabinet') {
1859
+ lastRawPos.current.set(...event.position)
1860
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
1861
+ }
1789
1862
 
1790
1863
  if (ctx.state.surface !== 'item-surface') {
1791
1864
  // Try entering surface mode
@@ -1793,9 +1866,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1793
1866
  if (!enterResult) return
1794
1867
 
1795
1868
  event.stopPropagation()
1796
- applyTransition(enterResult)
1869
+ applyTransition(enterResult, event)
1797
1870
  if (draftNode.current && enterResult.nodeUpdate.parentId) {
1798
- useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
1871
+ draftNode.updateSurface(enterResult.nodeUpdate, enterResult.surfaceId ?? null)
1799
1872
  }
1800
1873
  return
1801
1874
  }
@@ -1808,18 +1881,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1808
1881
 
1809
1882
  event.stopPropagation()
1810
1883
  if (enterResult) {
1811
- applyTransition(enterResult)
1884
+ applyTransition(enterResult, event)
1812
1885
  if (draftNode.current && enterResult.nodeUpdate.parentId) {
1813
- useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
1886
+ draftNode.updateSurface(enterResult.nodeUpdate, enterResult.surfaceId ?? null)
1814
1887
  }
1815
- } else {
1888
+ } else if (!feedback.reason) {
1816
1889
  detachItemSurfaceToFloor(event)
1817
1890
  }
1818
1891
  return
1819
1892
  }
1820
1893
 
1821
1894
  if (!draftNode.current) {
1822
- const enterResult = itemSurfaceStrategy.enter(getContext(), event)
1895
+ const enterResult = itemSurfaceStrategy.enter(surfaceContext(event), event)
1823
1896
  if (!enterResult) return
1824
1897
  event.stopPropagation()
1825
1898
  ensureDraft(enterResult)
@@ -1836,11 +1909,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1836
1909
  itemMoveEvent.position[1],
1837
1910
  itemMoveEvent.position[2],
1838
1911
  )
1839
- const result = itemSurfaceStrategy.move(ctx, itemMoveEvent)
1840
- if (!result) return
1912
+ const result = itemSurfaceStrategy.move(ctx, itemMoveEvent, event)
1913
+ if (!result) {
1914
+ revalidate()
1915
+ return
1916
+ }
1917
+ feedback.clear()
1841
1918
 
1842
1919
  event.stopPropagation()
1843
1920
 
1921
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
1922
+ tickSurfaceMovementSfx(result.cursorPosition)
1844
1923
  gridPosition.current.set(...result.gridPosition)
1845
1924
  const ic = worldToBuildingLocal(...result.cursorPosition)
1846
1925
  cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
@@ -1849,8 +1928,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1849
1928
  const draft = draftNode.current
1850
1929
  if (draft) {
1851
1930
  draft.position = result.gridPosition
1931
+ if (event.node.type === 'procedural-item')
1932
+ draftNode.updateSurface(result.nodeUpdate ?? {}, result.surfaceId ?? null)
1852
1933
  const mesh = sceneRegistry.nodes.get(draft.id)
1853
- if (mesh) mesh.position.set(...result.gridPosition)
1934
+ if (mesh)
1935
+ mesh.position.set(
1936
+ ...surfaceFramePose(draft.parentId, surfaceAttachmentId(draft), draft, true).position,
1937
+ )
1854
1938
 
1855
1939
  // Publish live transform for 2D floorplan
1856
1940
  useLiveTransforms.getState().set(draft.id, {
@@ -1862,7 +1946,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1862
1946
  revalidate()
1863
1947
  }
1864
1948
 
1865
- const onItemLeave = (event: ItemEvent) => {
1949
+ const onItemLeave = (event: NodeEvent<AnyNode>) => {
1950
+ if (event.node.type === 'cabinet') return
1866
1951
  if (event.node.id === draftNode.current?.id) return
1867
1952
  if (placementState.current.surface !== 'item-surface') return
1868
1953
 
@@ -1876,7 +1961,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1876
1961
  detachItemSurfaceToFloor(event)
1877
1962
  }
1878
1963
 
1879
- const onItemClick = (event: ItemEvent) => {
1964
+ const onItemClick = (event: NodeEvent<AnyNode>) => {
1965
+ gridDispatch.flush()
1966
+ if (feedback.reason) return
1880
1967
  // Click on the draft item itself. R3F dispatches click events to
1881
1968
  // the closest intersected mesh only — when the draft is hovering
1882
1969
  // on a host (shelf / table / etc.) the draft's mesh is *above*
@@ -1885,7 +1972,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1885
1972
  // self-click as a commit on the active shelf so the user doesn't
1886
1973
  // have to aim around the cursor preview to drop the item.
1887
1974
  if (event.node.id === draftNode.current?.id) {
1888
- const ctx = getContext()
1975
+ const ctx = surfaceContext(event)
1889
1976
  if (ctx.state.surface === 'block-face') {
1890
1977
  const result = resolveFaceHostPreviewCommit(ctx)
1891
1978
  if (result) {
@@ -1920,7 +2007,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1920
2007
  // the host's own click event is blocked by the cursor preview.
1921
2008
  if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
1922
2009
  const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
1923
- if (hostNode && hostNode.type === 'item') {
2010
+ if (
2011
+ hostNode &&
2012
+ (hostNode.type === 'item' ||
2013
+ hostNode.type === 'cabinet' ||
2014
+ hostNode.type === 'procedural-item')
2015
+ ) {
1924
2016
  const synthetic = { ...event, node: hostNode } as ItemEvent
1925
2017
  const result = itemSurfaceStrategy.click(ctx, synthetic)
1926
2018
  if (result) {
@@ -1955,7 +2047,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1955
2047
  finishCommittedPlacement(committedId, wasAdopted, () => {
1956
2048
  const nodes = useScene.getState().nodes
1957
2049
  const enterResult = ceilingStrategy.enter(
1958
- getContext(),
2050
+ surfaceContext(event),
1959
2051
  synthetic,
1960
2052
  resolveLevelId,
1961
2053
  nodes,
@@ -1973,7 +2065,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1973
2065
  return
1974
2066
  }
1975
2067
 
1976
- const result = itemSurfaceStrategy.click(getContext(), event)
2068
+ const result = itemSurfaceStrategy.click(surfaceContext(event), event)
1977
2069
  if (!result) return
1978
2070
 
1979
2071
  event.stopPropagation()
@@ -1981,7 +2073,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1981
2073
 
1982
2074
  finishCommittedPlacement(committedId, wasAdopted, () => {
1983
2075
  // Try to set up next draft on the same surface
1984
- const enterResult = itemSurfaceStrategy.enter(getContext(), event)
2076
+ const enterResult = itemSurfaceStrategy.enter(surfaceContext(event), event)
1985
2077
  if (enterResult) {
1986
2078
  applyTransition(enterResult)
1987
2079
  } else {
@@ -2005,7 +2097,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2005
2097
  ensureDraft(result)
2006
2098
  } else if (result.nodeUpdate.parentId) {
2007
2099
  // Existing draft (move mode): reparent to new ceiling
2008
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
2100
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
2009
2101
  if (result.stateUpdate.ceilingId) {
2010
2102
  useScene.getState().dirtyNodes.add(result.stateUpdate.ceilingId as AnyNodeId)
2011
2103
  }
@@ -2155,16 +2247,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2155
2247
 
2156
2248
  const onShelfEnter = (event: ShelfEvent) => {
2157
2249
  has3DPointerDrivenMoveRef.current = true
2158
- const result = shelfSurfaceStrategy.enter(getContext(), event)
2250
+ const result = shelfSurfaceStrategy.enter(surfaceContext(event), event)
2159
2251
  if (!result) return
2160
2252
 
2161
2253
  event.stopPropagation()
2162
- applyTransition(result)
2254
+ applyTransition(result, event)
2163
2255
 
2164
2256
  if (!draftNode.current) {
2165
2257
  ensureDraft(result)
2166
2258
  } else if (result.nodeUpdate.parentId) {
2167
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
2259
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
2168
2260
  }
2169
2261
  }
2170
2262
 
@@ -2174,7 +2266,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2174
2266
  // A shelf event can fire before the cursor group mounts or after
2175
2267
  // teardown, leaving the ref null; bail before dereferencing it below.
2176
2268
  if (!cursorGroupRef.current) return
2177
- const ctx = getContext()
2269
+ const ctx = surfaceContext(event)
2178
2270
  if (ctx.state.surface !== 'shelf-surface') {
2179
2271
  // Cursor entered via a move event without an enter — try
2180
2272
  // transitioning in so the user doesn't need to mouse out + back
@@ -2182,11 +2274,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2182
2274
  const enterResult = shelfSurfaceStrategy.enter(ctx, event)
2183
2275
  if (!enterResult) return
2184
2276
  event.stopPropagation()
2185
- applyTransition(enterResult)
2277
+ applyTransition(enterResult, event)
2186
2278
  if (!draftNode.current) {
2187
2279
  ensureDraft(enterResult)
2188
2280
  } else if (enterResult.nodeUpdate.parentId) {
2189
- useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
2281
+ updateSurfaceNode(draftNode.current.id, enterResult.nodeUpdate)
2190
2282
  }
2191
2283
  return
2192
2284
  }
@@ -2198,8 +2290,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2198
2290
  const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent)
2199
2291
  if (!result) return
2200
2292
 
2293
+ feedback.clear()
2201
2294
  event.stopPropagation()
2202
2295
 
2296
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
2297
+ tickSurfaceMovementSfx(result.cursorPosition)
2203
2298
  gridPosition.current.set(...result.gridPosition)
2204
2299
  const ic = worldToBuildingLocal(...result.cursorPosition)
2205
2300
  cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
@@ -2209,7 +2304,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2209
2304
  if (draft) {
2210
2305
  draft.position = result.gridPosition
2211
2306
  const mesh = sceneRegistry.nodes.get(draft.id)
2212
- if (mesh) mesh.position.set(...result.gridPosition)
2307
+ if (mesh)
2308
+ mesh.position.set(
2309
+ ...surfaceFramePose(draft.parentId, surfaceAttachmentId(draft), draft, true).position,
2310
+ )
2213
2311
  useLiveTransforms.getState().set(draft.id, {
2214
2312
  position: result.cursorPosition,
2215
2313
  rotation: result.cursorRotationY,
@@ -2233,14 +2331,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2233
2331
  }
2234
2332
 
2235
2333
  const onShelfClick = (event: ShelfEvent) => {
2236
- const result = shelfSurfaceStrategy.click(getContext(), event)
2334
+ gridDispatch.flush()
2335
+ if (feedback.reason) return
2336
+ const result = shelfSurfaceStrategy.click(surfaceContext(event), event)
2237
2337
  if (!result) return
2238
2338
 
2239
2339
  event.stopPropagation()
2240
2340
  const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
2241
2341
 
2242
2342
  finishCommittedPlacement(committedId, wasAdopted, () => {
2243
- const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
2343
+ const enterResult = shelfSurfaceStrategy.enter(surfaceContext(event), event)
2244
2344
  if (enterResult) {
2245
2345
  applyTransition(enterResult)
2246
2346
  } else {
@@ -2362,7 +2462,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2362
2462
  // Keep both preview renderers on the rotated draft. The item renderer
2363
2463
  // consumes live node overrides, while the floor-plan renderer consumes
2364
2464
  // the live transform and placement-preview snapshot.
2365
- useLiveNodeOverrides.getState().set(draft.id, { rotation: draft.rotation })
2465
+ const storedSurfacePose = surfaceFramePose(
2466
+ draft.parentId,
2467
+ surfaceAttachmentId(draft),
2468
+ draft,
2469
+ true,
2470
+ )
2471
+ if (surfaceAttachmentId(draft)) {
2472
+ draftNode.updateSurface(
2473
+ { position: draft.position, rotation: draft.rotation },
2474
+ surfaceAttachmentId(draft),
2475
+ )
2476
+ mesh?.position.set(...storedSurfacePose.position)
2477
+ mesh?.rotation.set(...storedSurfacePose.rotation)
2478
+ }
2479
+ useLiveNodeOverrides.getState().set(draft.id, { rotation: storedSurfacePose.rotation })
2366
2480
  const currentLive = useLiveTransforms.getState().get(draft.id)
2367
2481
  const livePosition: [number, number, number] =
2368
2482
  surface === 'floor'
@@ -2395,7 +2509,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2395
2509
  )
2396
2510
  }
2397
2511
 
2398
- revalidate()
2512
+ if (feedback.reason && lastSurfaceEvent) {
2513
+ if (lastSurfaceEvent.node.type === 'shelf') onShelfMove(lastSurfaceEvent as ShelfEvent)
2514
+ else onItemMove(lastSurfaceEvent as NodeEvent<AnyNode>)
2515
+ } else {
2516
+ feedback.clear()
2517
+ revalidate()
2518
+ }
2399
2519
  }
2400
2520
  }
2401
2521
 
@@ -2489,7 +2609,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2489
2609
 
2490
2610
  // ---- Subscribe ----
2491
2611
 
2492
- emitter.on('grid:move', onGridMove)
2612
+ const gridDispatch = createItemSurfaceGridDispatch(onGridMove)
2613
+ emitter.on('grid:move', gridDispatch.schedule)
2493
2614
  emitter.on('grid:click', onGridClick)
2494
2615
  emitter.on('item:enter', onItemEnter)
2495
2616
  emitter.on('item:move', onItemMove)
@@ -2511,6 +2632,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2511
2632
  emitter.on('ceiling:move', onCeilingMove)
2512
2633
  emitter.on('ceiling:click', onCeilingClick)
2513
2634
  emitter.on('ceiling:leave', onCeilingLeave)
2635
+ emitter.on('cabinet:enter', onItemEnter)
2636
+ emitter.on('procedural-item:enter' as never, onItemEnter)
2637
+ emitter.on('cabinet:move', onItemMove)
2638
+ emitter.on('procedural-item:move' as never, onItemMove)
2639
+ emitter.on('cabinet:click', onItemClick)
2640
+ emitter.on('procedural-item:click' as never, onItemClick)
2641
+ emitter.on('cabinet:leave', onItemLeave)
2642
+ emitter.on('procedural-item:leave' as never, onItemLeave)
2514
2643
  emitter.on('shelf:enter', onShelfEnter)
2515
2644
  emitter.on('shelf:move', onShelfMove)
2516
2645
  emitter.on('shelf:click', onShelfClick)
@@ -2534,6 +2663,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2534
2663
 
2535
2664
  return () => {
2536
2665
  tearingDown = true
2666
+ feedback.clear()
2537
2667
  if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
2538
2668
  unsubDraftWatch()
2539
2669
  useAlignmentGuides.getState().clear()
@@ -2544,7 +2674,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2544
2674
  }
2545
2675
  draftNode.destroy()
2546
2676
  useScene.temporal.getState().resume()
2547
- emitter.off('grid:move', onGridMove)
2677
+ gridDispatch.cancel()
2678
+ emitter.off('grid:move', gridDispatch.schedule)
2548
2679
  emitter.off('grid:click', onGridClick)
2549
2680
  emitter.off('item:enter', onItemEnter)
2550
2681
  emitter.off('item:move', onItemMove)
@@ -2566,6 +2697,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2566
2697
  emitter.off('ceiling:move', onCeilingMove)
2567
2698
  emitter.off('ceiling:click', onCeilingClick)
2568
2699
  emitter.off('ceiling:leave', onCeilingLeave)
2700
+ emitter.off('cabinet:enter', onItemEnter)
2701
+ emitter.off('procedural-item:enter' as never, onItemEnter)
2702
+ emitter.off('cabinet:move', onItemMove)
2703
+ emitter.off('procedural-item:move' as never, onItemMove)
2704
+ emitter.off('cabinet:click', onItemClick)
2705
+ emitter.off('procedural-item:click' as never, onItemClick)
2706
+ emitter.off('cabinet:leave', onItemLeave)
2707
+ emitter.off('procedural-item:leave' as never, onItemLeave)
2569
2708
  emitter.off('shelf:enter', onShelfEnter)
2570
2709
  emitter.off('shelf:move', onShelfMove)
2571
2710
  emitter.off('shelf:click', onShelfClick)
@@ -2623,6 +2762,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2623
2762
  if (
2624
2763
  draftParent?.type === 'item' ||
2625
2764
  draftParent?.type === 'shelf' ||
2765
+ draftParent?.type === 'cabinet' ||
2766
+ draftParent?.type === 'procedural-item' ||
2626
2767
  (draftParent &&
2627
2768
  nodeRegistry.get(draftParent.type)?.capabilities.faceHost?.currentFaceId(draft))
2628
2769
  )