@pascal-app/editor 1.0.0 → 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 (177) hide show
  1. package/package.json +12 -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/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type CeilingEvent,
7
+ canHostSurfaceChild,
7
8
  collectAlignmentAnchors,
8
9
  emitter,
9
10
  findLevelAncestorId,
@@ -17,6 +18,7 @@ import {
17
18
  resolveFrozenFloorPlacementPatch,
18
19
  resolveLevelId,
19
20
  type ShelfEvent,
21
+ type SurfaceRejectReason,
20
22
  sceneRegistry,
21
23
  useLiveNodeOverrides,
22
24
  useLiveTransforms,
@@ -30,16 +32,13 @@ import { Html } from '@react-three/drei'
30
32
  import { useFrame, useThree } from '@react-three/fiber'
31
33
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
32
34
  import {
33
- Box3,
34
35
  Euler,
35
36
  type Group,
36
37
  type LineSegments,
37
- Matrix4,
38
38
  type Mesh,
39
39
  type Object3D,
40
40
  PlaneGeometry,
41
41
  Quaternion,
42
- Ray,
43
42
  Vector3,
44
43
  } from 'three'
45
44
  import { distance, smoothstep, uv, vec2 } from 'three/tsl'
@@ -50,17 +49,30 @@ import {
50
49
  } from '../../../lib/active-placement-surface'
51
50
  import { EDITOR_LAYER } from '../../../lib/constants'
52
51
  import { formatLinearMeasurement } from '../../../lib/measurements'
52
+ import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
53
+ import { createMovementSfxTick } from '../../../lib/sfx/movement-tick'
53
54
  import { sfxEmitter } from '../../../lib/sfx-bus'
54
-
55
+ import {
56
+ surfaceAttachmentId,
57
+ surfaceFramePose,
58
+ updateSurfaceNode,
59
+ } from '../../../lib/surface-attachment'
55
60
  import {
56
61
  projectAlignmentGuidesWorldToActiveBuildingLocal,
57
62
  resolveAlignmentForActiveBuilding,
58
63
  } from '../../../lib/world-grid-snap'
59
64
  import useAlignmentGuides from '../../../store/use-alignment-guides'
60
- import useEditor, { isAlignmentGuideActive, isMagneticSnapActive } from '../../../store/use-editor'
61
-
65
+ import useEditor, {
66
+ isAlignmentGuideActive,
67
+ isGridSnapActive,
68
+ isMagneticSnapActive,
69
+ } from '../../../store/use-editor'
62
70
  import useFacingPose from '../../../store/use-facing-pose'
63
71
  import usePlacementPreview from '../../../store/use-placement-preview'
72
+ import {
73
+ createItemSurfaceGridDispatch,
74
+ createItemSurfacePointerArbitration,
75
+ } from '../registry/item-surface-move'
64
76
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
65
77
  import {
66
78
  createLineGeometry,
@@ -70,9 +82,13 @@ import {
70
82
  } from '../shared/placement-box-geometry'
71
83
  import {
72
84
  type PointerSupportSurface,
73
- resolvePointerSupportElevation,
74
85
  resolvePointerSupportSurface,
75
86
  } from '../shared/pointer-support-cap'
87
+ import { createShelfStickiness } from '../shared/shelf-stickiness'
88
+ import {
89
+ createSurfaceEventOwnership,
90
+ createSurfaceRejectionFeedback,
91
+ } from '../shared/surface-rejection'
76
92
  import { shouldCreateFloorDraft } from './draft-creation'
77
93
  import { commitFaceHostClick, resolveFaceHostPreviewCommit } from './face-host-commit'
78
94
  import {
@@ -547,10 +563,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
547
563
  }
548
564
  }
549
565
 
566
+ let lastSurfaceEvent: NodeEvent<AnyNode> | null = null
567
+ const feedback = createSurfaceRejectionFeedback()
568
+ feedback.clear()
569
+
550
570
  // ---- Helpers ----
551
571
 
552
572
  const getContext = () => ({
553
573
  asset,
574
+ onSurfaceReject: (reason: SurfaceRejectReason) => feedback.reject(reason),
554
575
  levelId: useViewer.getState().selection.levelId,
555
576
  draftItem: draftNode.current,
556
577
  gridPosition: gridPosition.current,
@@ -580,6 +601,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
580
601
  wasAdopted: boolean,
581
602
  repeat: () => void,
582
603
  ) => {
604
+ if (!committedId) return
583
605
  if (configRef.current.onCommitted()) {
584
606
  repeat()
585
607
  return
@@ -609,12 +631,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
609
631
  ) => {
610
632
  const draftId = draftNode.current?.id ?? null
611
633
  const wasAdopted = draftNode.isAdopted
612
- const finalId = draftNode.commit(nodeUpdate, options)
613
- if (draftId) {
634
+ const finalId = draftNode.commit(nodeUpdate, {
635
+ ...options,
636
+ onReject: (reason) => {
637
+ feedback.reject(reason)
638
+ edgeMaterial.color.setHex(0xef_44_44)
639
+ basePlaneMaterial.color.setHex(0xef_44_44)
640
+ },
641
+ })
642
+ if (finalId && draftId) {
614
643
  useLiveTransforms.getState().clear(draftId)
615
644
  useLiveNodeOverrides.getState().clearFields(draftId, faceHostClearFields(draftNode.current))
616
645
  }
617
- return { committedId: finalId ?? draftId, wasAdopted }
646
+ return { committedId: finalId, wasAdopted }
618
647
  }
619
648
 
620
649
  const faceHostClearFields = (draft: ItemNode | null | undefined): Array<keyof ItemNode> => {
@@ -633,14 +662,74 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
633
662
  : null
634
663
  }
635
664
 
665
+ const surfacePointer = createItemSurfacePointerArbitration()
666
+ const surfaceOwnership = createSurfaceEventOwnership()
636
667
  const revalidate = (): boolean => {
637
- const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
668
+ const fits = checkCanPlace(getContext(), validators)
669
+ if (
670
+ !fits &&
671
+ lastSurfaceEvent &&
672
+ (feedback.reason === 'footprint-outside-surface' ||
673
+ feedback.reason === 'footprint-exceeds-host' ||
674
+ feedback.reason === 'no-surface') &&
675
+ (placementState.current.surface === 'item-surface' ||
676
+ placementState.current.surface === 'shelf-surface')
677
+ ) {
678
+ const hostId =
679
+ placementState.current.surface === 'shelf-surface'
680
+ ? placementState.current.shelfId
681
+ : placementState.current.surfaceItemId
682
+ const mesh = hostId ? sceneRegistry.nodes.get(hostId) : undefined
683
+ if (mesh) {
684
+ const position = mesh.localToWorld(gridPosition.current.clone()).toArray()
685
+ feedback.clear()
686
+ detachItemSurfaceToFloor({ ...lastSurfaceEvent, position } as ItemEvent)
687
+ return revalidate()
688
+ }
689
+ }
690
+ const placeable = !feedback.reason && (altFreeRef.current || fits)
638
691
  const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
639
692
  edgeMaterial.color.setHex(color)
640
693
  basePlaneMaterial.color.setHex(color)
641
694
  return placeable
642
695
  }
643
696
 
697
+ const surfaceContext = (event: NodeEvent<AnyNode>, generic = false) => {
698
+ lastSurfaceEvent = event
699
+ return {
700
+ ...getContext(),
701
+ onSurfaceReject: (reason: SurfaceRejectReason) => {
702
+ // A rejected generic acquisition leaves the paired pointer's floor route in charge.
703
+ if (
704
+ generic &&
705
+ placementState.current.surfaceItemId !== event.node.id &&
706
+ reason !== 'surface-cutout' &&
707
+ reason !== 'surface-occupied'
708
+ ) {
709
+ return
710
+ }
711
+ surfaceOwnership.claim(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
712
+ event.stopPropagation()
713
+ if (
714
+ (reason === 'footprint-outside-surface' ||
715
+ reason === 'footprint-exceeds-host' ||
716
+ reason === 'no-surface') &&
717
+ (placementState.current.surface === 'item-surface' ||
718
+ placementState.current.surface === 'shelf-surface')
719
+ ) {
720
+ feedback.clear()
721
+ detachItemSurfaceToFloor(event as ItemEvent)
722
+ // A generic host exit yields positioning to this pointer's paired floor event.
723
+ if (!generic)
724
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
725
+ return
726
+ }
727
+ feedback.reject(reason, event.nativeEvent.nativeEvent ?? event.nativeEvent)
728
+ revalidate()
729
+ },
730
+ }
731
+ }
732
+
644
733
  // Tool visuals are rendered inside the building-local ToolManager group, so all cursor
645
734
  // positions must be in building-local space. Wall/ceiling/item-surface strategies return
646
735
  // world-space cursor positions (from their event.position); convert them here.
@@ -671,7 +760,23 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
671
760
  return [localRotation.x, localRotation.y, localRotation.z]
672
761
  }
673
762
 
674
- const applyTransition = (result: TransitionResult) => {
763
+ const { tick: tickMovementSfx } = createMovementSfxTick()
764
+ const tickSurfaceMovementSfx = (position: [number, number, number]) => {
765
+ const point = worldToBuildingLocal(...position)
766
+ tickMovementSfx({
767
+ coords: [point.x, point.z],
768
+ gridSnapActive: isGridSnapActive(),
769
+ gridStep: useEditor.getState().gridSnapStep,
770
+ })
771
+ }
772
+
773
+ const applyTransition = (result: TransitionResult, event?: NodeEvent<AnyNode>) => {
774
+ if (event) {
775
+ surfaceOwnership.claim(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
776
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
777
+ tickSurfaceMovementSfx(result.cursorPosition)
778
+ }
779
+ feedback.clear()
675
780
  // Alignment guides are floor-only; clear them when the cursor moves
676
781
  // onto a wall / ceiling / item surface (only those paths call this).
677
782
  useAlignmentGuides.getState().clear()
@@ -734,7 +839,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
734
839
  if (draft) {
735
840
  Object.assign(draft, result.nodeUpdate)
736
841
  // One-time setup: put node in the right parent so it renders correctly
737
- useScene.getState().updateNode(draft.id, result.nodeUpdate)
842
+ if (result.surfaceId !== undefined)
843
+ draftNode.updateSurface(result.nodeUpdate, result.surfaceId)
844
+ else updateSurfaceNode(draft.id, result.nodeUpdate)
738
845
  disableDraftRaycastNow()
739
846
  }
740
847
 
@@ -753,6 +860,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
753
860
 
754
861
  // ---- Init draft ----
755
862
  configRef.current.initDraft(gridPosition.current)
863
+ if (draftNode.current && surfaceAttachmentId(draftNode.current))
864
+ gridPosition.current.set(...draftNode.current.position)
756
865
  const preserveDragOffset = configRef.current.preserveDragOffset === true
757
866
  // The host the item was grabbed from + its pre-drag host-local position.
758
867
  // Each surface's grab anchor preserves the grab offset only on THAT host,
@@ -939,6 +1048,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
939
1048
  setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
940
1049
  }
941
1050
  const onReleaseCommit = () => {
1051
+ gridDispatch.flush()
942
1052
  if (!releaseCommit) return
943
1053
  const commit = releaseCommit
944
1054
  releaseCommit = null
@@ -948,53 +1058,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
948
1058
 
949
1059
  // ---- Floor Handlers ----
950
1060
 
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
- }
1061
+ const cursorRayIntersectsShelf = createShelfStickiness()
996
1062
 
997
1063
  const onGridMove = (event: GridEvent) => {
1064
+ if (surfacePointer.blocksGrid(event.nativeEvent.nativeEvent ?? event.nativeEvent)) return
998
1065
  releaseCommit = () => onGridClick(event)
999
1066
  // Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
1000
1067
  if (
@@ -1014,7 +1081,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1014
1081
  // perspective-skewed along the ray whenever the plane sits on a
1015
1082
  // different storey than the pointed surface (the skew is what made
1016
1083
  // a drag over a deck-above-a-floor hop between the two surfaces).
1017
- const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
1084
+ const pointed = resolvePointerSupportSurface(cameraRef.current, event.position, {
1085
+ pointerRay: event.nativeEvent.ray,
1086
+ })
1018
1087
  pointerSupportCapRef.current = pointed?.elevation ?? null
1019
1088
  pointerSupportSurfaceRef.current = pointed
1020
1089
  const surfaceEvent: GridEvent =
@@ -1027,13 +1096,34 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1027
1096
  // off a board / through a gap and hit the floor behind). Detach to the
1028
1097
  // floor only once the ray misses the shelf entirely — without this the
1029
1098
  // item oscillates between the shelf row and the floor on every micro-move.
1099
+ const counterId =
1100
+ placementState.current.surface === 'item-surface'
1101
+ ? placementState.current.surfaceItemId
1102
+ : null
1103
+ if (counterId && useScene.getState().nodes[counterId as AnyNodeId]?.type === 'cabinet') {
1104
+ if (
1105
+ surfacePointer.blocksGrid(event.nativeEvent.nativeEvent ?? event.nativeEvent) ||
1106
+ cursorRayIntersectsShelf(counterId, cameraRef.current, event.position)
1107
+ )
1108
+ return
1109
+ detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent)
1110
+ }
1030
1111
  if (placementState.current.surface === 'shelf-surface') {
1031
- if (cursorRayIntersectsActiveShelf(event.position)) return
1112
+ if (
1113
+ cursorRayIntersectsShelf(
1114
+ placementState.current.shelfId,
1115
+ cameraRef.current,
1116
+ event.position,
1117
+ )
1118
+ )
1119
+ return
1032
1120
  // Land at the pointed surface's plan point — the raw grid hit is
1033
1121
  // still skewed by the plane riding at the shelf-surface height.
1034
1122
  detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent)
1035
1123
  }
1036
1124
 
1125
+ if (placementState.current.surface === 'floor')
1126
+ feedback.grid(event.nativeEvent.nativeEvent ?? event.nativeEvent)
1037
1127
  const floorEvent = applyFloorGrabOffset(surfaceEvent)
1038
1128
 
1039
1129
  lastRawPos.current.set(
@@ -1120,15 +1210,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1120
1210
  frozenSupportSlabIdRef.current = frozenPatch.supportSlabId
1121
1211
  }
1122
1212
 
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]
1213
+ tickMovementSfx({
1214
+ coords: [gridPos[0], gridPos[2]],
1215
+ gridSnapActive: isGridSnapActive(),
1216
+ gridStep: useEditor.getState().gridSnapStep,
1217
+ })
1132
1218
  gridPosition.current.set(...gridPos)
1133
1219
  const cursorPosition = getFloorVisualPosition(gridPos)
1134
1220
  if (!draft && asset.attachTo) {
@@ -1161,6 +1247,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1161
1247
  }
1162
1248
 
1163
1249
  const onGridClick = (event: GridEvent) => {
1250
+ gridDispatch.flush()
1251
+ if (feedback.reason) return
1164
1252
  // Drop alignment guides on click — the move commits (guides done) or
1165
1253
  // placement re-arms (the next move republishes them).
1166
1254
  useAlignmentGuides.getState().clear()
@@ -1222,7 +1310,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1222
1310
  ensureDraft(result)
1223
1311
  } else if (result.nodeUpdate.parentId) {
1224
1312
  // Existing draft (move mode): reparent to new wall
1225
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1313
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
1226
1314
  if (result.stateUpdate.wallId) {
1227
1315
  useScene.getState().dirtyNodes.add(result.stateUpdate.wallId as AnyNodeId)
1228
1316
  }
@@ -1249,7 +1337,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1249
1337
  event.stopPropagation()
1250
1338
  applyTransition(enterResult)
1251
1339
  if (draftNode.current && enterResult.nodeUpdate.parentId) {
1252
- useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
1340
+ updateSurfaceNode(draftNode.current.id, enterResult.nodeUpdate)
1253
1341
  if (enterResult.stateUpdate.wallId) {
1254
1342
  useScene.getState().dirtyNodes.add(enterResult.stateUpdate.wallId as AnyNodeId)
1255
1343
  }
@@ -1446,7 +1534,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1446
1534
  ensureDraft(result)
1447
1535
  } else if (result.nodeUpdate.parentId) {
1448
1536
  // Existing draft (move mode): reparent to the segment
1449
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1537
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
1450
1538
  }
1451
1539
  return true
1452
1540
  }
@@ -1575,7 +1663,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1575
1663
  if (!draftNode.current) {
1576
1664
  ensureDraft(result)
1577
1665
  } else if (result.nodeUpdate.parentId) {
1578
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1666
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
1579
1667
  disableDraftRaycastNow()
1580
1668
  }
1581
1669
  if (draftNode.current) useLiveTransforms.getState().clear(draftNode.current.id)
@@ -1673,22 +1761,23 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1673
1761
  if (draft) useScene.getState().updateNode(draft.id, result.nodeUpdate)
1674
1762
  } else {
1675
1763
  draftNode.destroy()
1764
+ gridPosition.current.set(...result.gridPosition)
1676
1765
  Object.assign(placementState.current, result.stateUpdate)
1677
1766
  }
1678
1767
  }
1679
1768
 
1680
1769
  // ---- Item Surface Handlers ----
1681
1770
 
1682
- const detachItemSurfaceToFloor = (event: ItemEvent) => {
1771
+ const detachItemSurfaceToFloor = (event: NodeEvent<AnyNode>) => {
1683
1772
  hostSurfaceDragAnchor = null
1773
+ surfacePointer.clear()
1684
1774
  // Landing back on the floor: refresh the pointer surface cap from
1685
1775
  // this event's world hit so the first floor position already targets
1686
1776
  // the aimed-at surface (not a deck above it).
1687
- pointerSupportCapRef.current = resolvePointerSupportElevation(cameraRef.current, [
1688
- event.position[0],
1689
- event.position[1],
1690
- event.position[2],
1691
- ])
1777
+ pointerSupportCapRef.current =
1778
+ resolvePointerSupportSurface(cameraRef.current, event.position, {
1779
+ pointerRay: event.nativeEvent.ray,
1780
+ })?.elevation ?? null
1692
1781
  // Coming back from a host: forget the floor grab too, so the item
1693
1782
  // centers under the cursor instead of restoring the pre-drag offset —
1694
1783
  // and landing on the floor is "anchoring elsewhere", so a later return
@@ -1753,7 +1842,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1753
1842
  // parent-must-be-a-level guard and the item + grid stopped following
1754
1843
  // slab elevations for the rest of the drag).
1755
1844
  if (levelId) draft.parentId = levelId
1756
- useScene.getState().updateNode(draft.id, {
1845
+ updateSurfaceNode(draft.id, {
1757
1846
  parentId: useViewer.getState().selection.levelId as string,
1758
1847
  position: floorPos,
1759
1848
  rotation,
@@ -1763,29 +1852,42 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1763
1852
  revalidate()
1764
1853
  }
1765
1854
 
1766
- const onItemEnter = (event: ItemEvent) => {
1855
+ const onItemEnter = (event: NodeEvent<AnyNode>, generic = false) => {
1856
+ if (event.node.type === 'cabinet' && placementState.current.surfaceItemId === event.node.id) {
1857
+ onItemMove(event)
1858
+ return
1859
+ }
1860
+ if (event.node.type === 'cabinet') {
1861
+ lastRawPos.current.set(...event.position)
1862
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
1863
+ }
1767
1864
  if (event.node.id === draftNode.current?.id) return
1768
1865
  has3DPointerDrivenMoveRef.current = true
1769
- const result = itemSurfaceStrategy.enter(getContext(), event)
1866
+ const result = itemSurfaceStrategy.enter(surfaceContext(event, generic), event)
1770
1867
  if (!result) return
1868
+ feedback.clear()
1771
1869
 
1772
1870
  event.stopPropagation()
1773
- applyTransition(result)
1871
+ applyTransition(result, event)
1774
1872
 
1775
1873
  if (!draftNode.current) {
1776
1874
  ensureDraft(result)
1777
1875
  } else if (result.nodeUpdate.parentId) {
1778
1876
  // Existing draft (move mode): reparent to surface item
1779
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1877
+ draftNode.updateSurface(result.nodeUpdate, result.surfaceId ?? null)
1780
1878
  }
1781
1879
  }
1782
1880
 
1783
- const onItemMove = (event: ItemEvent) => {
1881
+ const onItemMove = (event: NodeEvent<AnyNode>, generic = false) => {
1784
1882
  if (event.node.id === draftNode.current?.id) return
1785
1883
  releaseCommit = () => onItemClick(event)
1786
1884
  has3DPointerDrivenMoveRef.current = true
1787
1885
  if (!cursorGroupRef.current) return
1788
- const ctx = getContext()
1886
+ const ctx = surfaceContext(event, generic)
1887
+ if (event.node.type === 'cabinet') {
1888
+ lastRawPos.current.set(...event.position)
1889
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
1890
+ }
1789
1891
 
1790
1892
  if (ctx.state.surface !== 'item-surface') {
1791
1893
  // Try entering surface mode
@@ -1793,9 +1895,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1793
1895
  if (!enterResult) return
1794
1896
 
1795
1897
  event.stopPropagation()
1796
- applyTransition(enterResult)
1898
+ applyTransition(enterResult, event)
1797
1899
  if (draftNode.current && enterResult.nodeUpdate.parentId) {
1798
- useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
1900
+ draftNode.updateSurface(enterResult.nodeUpdate, enterResult.surfaceId ?? null)
1799
1901
  }
1800
1902
  return
1801
1903
  }
@@ -1806,20 +1908,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1806
1908
  event,
1807
1909
  )
1808
1910
 
1911
+ // An unclaimed generic mesh must let its owning ancestor receive the same hit.
1912
+ if (generic && !enterResult) return
1809
1913
  event.stopPropagation()
1810
1914
  if (enterResult) {
1811
- applyTransition(enterResult)
1915
+ applyTransition(enterResult, event)
1812
1916
  if (draftNode.current && enterResult.nodeUpdate.parentId) {
1813
- useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
1917
+ draftNode.updateSurface(enterResult.nodeUpdate, enterResult.surfaceId ?? null)
1814
1918
  }
1815
- } else {
1919
+ } else if (!feedback.reason) {
1816
1920
  detachItemSurfaceToFloor(event)
1817
1921
  }
1818
1922
  return
1819
1923
  }
1820
1924
 
1821
1925
  if (!draftNode.current) {
1822
- const enterResult = itemSurfaceStrategy.enter(getContext(), event)
1926
+ const enterResult = itemSurfaceStrategy.enter(surfaceContext(event, generic), event)
1823
1927
  if (!enterResult) return
1824
1928
  event.stopPropagation()
1825
1929
  ensureDraft(enterResult)
@@ -1836,11 +1940,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1836
1940
  itemMoveEvent.position[1],
1837
1941
  itemMoveEvent.position[2],
1838
1942
  )
1839
- const result = itemSurfaceStrategy.move(ctx, itemMoveEvent)
1840
- if (!result) return
1943
+ const result = itemSurfaceStrategy.move(ctx, itemMoveEvent, event)
1944
+ if (!result) {
1945
+ revalidate()
1946
+ return
1947
+ }
1948
+ feedback.clear()
1841
1949
 
1842
1950
  event.stopPropagation()
1843
1951
 
1952
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
1953
+ tickSurfaceMovementSfx(result.cursorPosition)
1844
1954
  gridPosition.current.set(...result.gridPosition)
1845
1955
  const ic = worldToBuildingLocal(...result.cursorPosition)
1846
1956
  cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
@@ -1849,8 +1959,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1849
1959
  const draft = draftNode.current
1850
1960
  if (draft) {
1851
1961
  draft.position = result.gridPosition
1962
+ if (event.node.type === 'procedural-item')
1963
+ draftNode.updateSurface(result.nodeUpdate ?? {}, result.surfaceId ?? null)
1852
1964
  const mesh = sceneRegistry.nodes.get(draft.id)
1853
- if (mesh) mesh.position.set(...result.gridPosition)
1965
+ if (mesh)
1966
+ mesh.position.set(
1967
+ ...surfaceFramePose(draft.parentId, surfaceAttachmentId(draft), draft, true).position,
1968
+ )
1854
1969
 
1855
1970
  // Publish live transform for 2D floorplan
1856
1971
  useLiveTransforms.getState().set(draft.id, {
@@ -1862,7 +1977,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1862
1977
  revalidate()
1863
1978
  }
1864
1979
 
1865
- const onItemLeave = (event: ItemEvent) => {
1980
+ const onItemLeave = (event: NodeEvent<AnyNode>) => {
1981
+ if (event.node.type === 'cabinet') return
1866
1982
  if (event.node.id === draftNode.current?.id) return
1867
1983
  if (placementState.current.surface !== 'item-surface') return
1868
1984
 
@@ -1876,7 +1992,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1876
1992
  detachItemSurfaceToFloor(event)
1877
1993
  }
1878
1994
 
1879
- const onItemClick = (event: ItemEvent) => {
1995
+ const onItemClick = (event: NodeEvent<AnyNode>) => {
1996
+ gridDispatch.flush()
1997
+ if (feedback.reason) return
1880
1998
  // Click on the draft item itself. R3F dispatches click events to
1881
1999
  // the closest intersected mesh only — when the draft is hovering
1882
2000
  // on a host (shelf / table / etc.) the draft's mesh is *above*
@@ -1885,7 +2003,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1885
2003
  // self-click as a commit on the active shelf so the user doesn't
1886
2004
  // have to aim around the cursor preview to drop the item.
1887
2005
  if (event.node.id === draftNode.current?.id) {
1888
- const ctx = getContext()
2006
+ const ctx = surfaceContext(event)
1889
2007
  if (ctx.state.surface === 'block-face') {
1890
2008
  const result = resolveFaceHostPreviewCommit(ctx)
1891
2009
  if (result) {
@@ -1920,7 +2038,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1920
2038
  // the host's own click event is blocked by the cursor preview.
1921
2039
  if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
1922
2040
  const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
1923
- if (hostNode && hostNode.type === 'item') {
2041
+ if (hostNode) {
1924
2042
  const synthetic = { ...event, node: hostNode } as ItemEvent
1925
2043
  const result = itemSurfaceStrategy.click(ctx, synthetic)
1926
2044
  if (result) {
@@ -1955,7 +2073,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1955
2073
  finishCommittedPlacement(committedId, wasAdopted, () => {
1956
2074
  const nodes = useScene.getState().nodes
1957
2075
  const enterResult = ceilingStrategy.enter(
1958
- getContext(),
2076
+ surfaceContext(event),
1959
2077
  synthetic,
1960
2078
  resolveLevelId,
1961
2079
  nodes,
@@ -1973,7 +2091,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1973
2091
  return
1974
2092
  }
1975
2093
 
1976
- const result = itemSurfaceStrategy.click(getContext(), event)
2094
+ const result = itemSurfaceStrategy.click(surfaceContext(event), event)
1977
2095
  if (!result) return
1978
2096
 
1979
2097
  event.stopPropagation()
@@ -1981,7 +2099,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1981
2099
 
1982
2100
  finishCommittedPlacement(committedId, wasAdopted, () => {
1983
2101
  // Try to set up next draft on the same surface
1984
- const enterResult = itemSurfaceStrategy.enter(getContext(), event)
2102
+ const enterResult = itemSurfaceStrategy.enter(surfaceContext(event), event)
1985
2103
  if (enterResult) {
1986
2104
  applyTransition(enterResult)
1987
2105
  } else {
@@ -2005,7 +2123,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2005
2123
  ensureDraft(result)
2006
2124
  } else if (result.nodeUpdate.parentId) {
2007
2125
  // Existing draft (move mode): reparent to new ceiling
2008
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
2126
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
2009
2127
  if (result.stateUpdate.ceilingId) {
2010
2128
  useScene.getState().dirtyNodes.add(result.stateUpdate.ceilingId as AnyNodeId)
2011
2129
  }
@@ -2155,16 +2273,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2155
2273
 
2156
2274
  const onShelfEnter = (event: ShelfEvent) => {
2157
2275
  has3DPointerDrivenMoveRef.current = true
2158
- const result = shelfSurfaceStrategy.enter(getContext(), event)
2276
+ const result = shelfSurfaceStrategy.enter(surfaceContext(event), event)
2159
2277
  if (!result) return
2160
2278
 
2161
2279
  event.stopPropagation()
2162
- applyTransition(result)
2280
+ applyTransition(result, event)
2163
2281
 
2164
2282
  if (!draftNode.current) {
2165
2283
  ensureDraft(result)
2166
2284
  } else if (result.nodeUpdate.parentId) {
2167
- useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
2285
+ updateSurfaceNode(draftNode.current.id, result.nodeUpdate)
2168
2286
  }
2169
2287
  }
2170
2288
 
@@ -2174,7 +2292,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2174
2292
  // A shelf event can fire before the cursor group mounts or after
2175
2293
  // teardown, leaving the ref null; bail before dereferencing it below.
2176
2294
  if (!cursorGroupRef.current) return
2177
- const ctx = getContext()
2295
+ const ctx = surfaceContext(event)
2178
2296
  if (ctx.state.surface !== 'shelf-surface') {
2179
2297
  // Cursor entered via a move event without an enter — try
2180
2298
  // transitioning in so the user doesn't need to mouse out + back
@@ -2182,11 +2300,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2182
2300
  const enterResult = shelfSurfaceStrategy.enter(ctx, event)
2183
2301
  if (!enterResult) return
2184
2302
  event.stopPropagation()
2185
- applyTransition(enterResult)
2303
+ applyTransition(enterResult, event)
2186
2304
  if (!draftNode.current) {
2187
2305
  ensureDraft(enterResult)
2188
2306
  } else if (enterResult.nodeUpdate.parentId) {
2189
- useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
2307
+ updateSurfaceNode(draftNode.current.id, enterResult.nodeUpdate)
2190
2308
  }
2191
2309
  return
2192
2310
  }
@@ -2198,8 +2316,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2198
2316
  const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent)
2199
2317
  if (!result) return
2200
2318
 
2319
+ feedback.clear()
2201
2320
  event.stopPropagation()
2202
2321
 
2322
+ surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
2323
+ tickSurfaceMovementSfx(result.cursorPosition)
2203
2324
  gridPosition.current.set(...result.gridPosition)
2204
2325
  const ic = worldToBuildingLocal(...result.cursorPosition)
2205
2326
  cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
@@ -2209,7 +2330,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2209
2330
  if (draft) {
2210
2331
  draft.position = result.gridPosition
2211
2332
  const mesh = sceneRegistry.nodes.get(draft.id)
2212
- if (mesh) mesh.position.set(...result.gridPosition)
2333
+ if (mesh)
2334
+ mesh.position.set(
2335
+ ...surfaceFramePose(draft.parentId, surfaceAttachmentId(draft), draft, true).position,
2336
+ )
2213
2337
  useLiveTransforms.getState().set(draft.id, {
2214
2338
  position: result.cursorPosition,
2215
2339
  rotation: result.cursorRotationY,
@@ -2233,14 +2357,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2233
2357
  }
2234
2358
 
2235
2359
  const onShelfClick = (event: ShelfEvent) => {
2236
- const result = shelfSurfaceStrategy.click(getContext(), event)
2360
+ gridDispatch.flush()
2361
+ if (feedback.reason) return
2362
+ const result = shelfSurfaceStrategy.click(surfaceContext(event), event)
2237
2363
  if (!result) return
2238
2364
 
2239
2365
  event.stopPropagation()
2240
2366
  const { committedId, wasAdopted } = commitDraft(result.nodeUpdate)
2241
2367
 
2242
2368
  finishCommittedPlacement(committedId, wasAdopted, () => {
2243
- const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
2369
+ const enterResult = shelfSurfaceStrategy.enter(surfaceContext(event), event)
2244
2370
  if (enterResult) {
2245
2371
  applyTransition(enterResult)
2246
2372
  } else {
@@ -2286,7 +2412,49 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2286
2412
  const currentRotation = draft.rotation
2287
2413
  // Round to the nearest 45° then step, matching the placed-item R/T.
2288
2414
  const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
2289
- draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
2415
+ const nextRotation: [number, number, number] = [
2416
+ currentRotation[0],
2417
+ newRotationY,
2418
+ currentRotation[2],
2419
+ ]
2420
+ const surface = placementState.current.surface
2421
+ const surfaceMesh =
2422
+ surface === 'item-surface' && placementState.current.surfaceItemId
2423
+ ? sceneRegistry.nodes.get(placementState.current.surfaceItemId)
2424
+ : undefined
2425
+ let surfacePosition: [number, number, number] | undefined
2426
+ if (surfaceMesh) {
2427
+ const localPos = surfaceMesh.worldToLocal(lastRawPos.current.clone())
2428
+ const [dimX, , dimZ] = getScaledDimensions(draft)
2429
+ const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
2430
+ surfacePosition = [
2431
+ snapToGrid(localPos.x, swapDims ? dimZ : dimX),
2432
+ gridPosition.current.y,
2433
+ snapToGrid(localPos.z, swapDims ? dimX : dimZ),
2434
+ ]
2435
+ }
2436
+ if (surfaceAttachmentId(draft)) {
2437
+ let occupied = false
2438
+ checkCanPlace(
2439
+ {
2440
+ ...getContext(),
2441
+ draftItem: { ...draft, rotation: nextRotation },
2442
+ gridPosition: surfacePosition
2443
+ ? new Vector3(...surfacePosition)
2444
+ : gridPosition.current,
2445
+ onSurfaceReject: (reason) => {
2446
+ occupied = reason === 'surface-occupied'
2447
+ },
2448
+ },
2449
+ validators,
2450
+ )
2451
+ if (occupied) {
2452
+ feedback.reject('surface-occupied')
2453
+ revalidate()
2454
+ return
2455
+ }
2456
+ }
2457
+ draft.rotation = nextRotation
2290
2458
 
2291
2459
  // Rotate the building-local cursor by the same delta as the host-local
2292
2460
  // draft. This preserves the host's composed yaw for items resting on a
@@ -2298,7 +2466,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2298
2466
  if (mesh) mesh.rotation.y = newRotationY
2299
2467
 
2300
2468
  // Re-snap position immediately with updated rotation (dimX/dimZ may swap at 90°)
2301
- const surface = placementState.current.surface
2302
2469
  if (surface === 'floor' || surface === 'ceiling') {
2303
2470
  const dims = getScaledDimensions(draft)
2304
2471
  const [dimX, , dimZ] = dims
@@ -2328,15 +2495,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2328
2495
  }
2329
2496
  }
2330
2497
  } else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
2331
- const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
2332
- if (surfaceMesh) {
2333
- const localPos = surfaceMesh.worldToLocal(lastRawPos.current.clone())
2334
- const dims = getScaledDimensions(draft)
2335
- const [dimX, , dimZ] = dims
2336
- const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
2337
- const x = snapToGrid(localPos.x, swapDims ? dimZ : dimX)
2338
- const z = snapToGrid(localPos.z, swapDims ? dimX : dimZ)
2339
- const y = gridPosition.current.y
2498
+ if (surfaceMesh && surfacePosition) {
2499
+ const [x, y, z] = surfacePosition
2340
2500
  gridPosition.current.set(x, y, z)
2341
2501
  draft.position = [x, y, z]
2342
2502
  const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
@@ -2362,7 +2522,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2362
2522
  // Keep both preview renderers on the rotated draft. The item renderer
2363
2523
  // consumes live node overrides, while the floor-plan renderer consumes
2364
2524
  // the live transform and placement-preview snapshot.
2365
- useLiveNodeOverrides.getState().set(draft.id, { rotation: draft.rotation })
2525
+ const storedSurfacePose = surfaceFramePose(
2526
+ draft.parentId,
2527
+ surfaceAttachmentId(draft),
2528
+ draft,
2529
+ true,
2530
+ )
2531
+ if (surfaceAttachmentId(draft)) {
2532
+ draftNode.updateSurface(
2533
+ { position: draft.position, rotation: draft.rotation },
2534
+ surfaceAttachmentId(draft),
2535
+ )
2536
+ mesh?.position.set(...storedSurfacePose.position)
2537
+ mesh?.rotation.set(...storedSurfacePose.rotation)
2538
+ }
2539
+ useLiveNodeOverrides.getState().set(draft.id, { rotation: storedSurfacePose.rotation })
2366
2540
  const currentLive = useLiveTransforms.getState().get(draft.id)
2367
2541
  const livePosition: [number, number, number] =
2368
2542
  surface === 'floor'
@@ -2395,7 +2569,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2395
2569
  )
2396
2570
  }
2397
2571
 
2398
- revalidate()
2572
+ if (feedback.reason && lastSurfaceEvent) {
2573
+ if (lastSurfaceEvent.node.type === 'shelf') onShelfMove(lastSurfaceEvent as ShelfEvent)
2574
+ else onItemMove(lastSurfaceEvent as NodeEvent<AnyNode>)
2575
+ } else {
2576
+ feedback.clear()
2577
+ revalidate()
2578
+ }
2399
2579
  }
2400
2580
  }
2401
2581
 
@@ -2474,7 +2654,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2474
2654
  const unsubDraftWatch = useScene.subscribe((state) => {
2475
2655
  if (tearingDown) return
2476
2656
  const draft = draftNode.current
2477
- if (draft === null) return
2657
+ if (draft === null || isFreshPlacementMetadata(draft.metadata)) return
2478
2658
  if (draft.id in state.nodes) return
2479
2659
 
2480
2660
  queueMicrotask(() => {
@@ -2489,32 +2669,73 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2489
2669
 
2490
2670
  // ---- Subscribe ----
2491
2671
 
2492
- emitter.on('grid:move', onGridMove)
2672
+ const specializedKinds = new Set<string>()
2673
+ const subscribeSpecialized = <E,>(key: string, handler: (event: E) => void) => {
2674
+ specializedKinds.add(key.slice(0, key.lastIndexOf(':')))
2675
+ emitter.on(key as never, handler as never)
2676
+ }
2677
+ const genericRoute = (phase: 'enter' | 'move' | 'click' | 'leave') => (event: NodeEvent) => {
2678
+ if (specializedKinds.has(event.node.type)) return
2679
+ if (
2680
+ !surfaceOwnership.allows(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
2681
+ )
2682
+ return
2683
+ if (nodeRegistry.get(event.node.type)?.capabilities.faceHost) {
2684
+ const handlers = {
2685
+ enter: onFaceHostEnter,
2686
+ move: onFaceHostMove,
2687
+ click: onFaceHostClick,
2688
+ leave: onFaceHostLeave,
2689
+ }
2690
+ handlers[phase](event)
2691
+ return
2692
+ }
2693
+ if (!canHostSurfaceChild(event.node, 'item', draftNode.current?.id)) return
2694
+ if (phase === 'leave' && placementState.current.surfaceItemId !== event.node.id) return
2695
+ if (phase === 'enter') onItemEnter(event, true)
2696
+ else if (phase === 'move') onItemMove(event, true)
2697
+ else if (phase === 'click') onItemClick(event)
2698
+ else onItemLeave(event)
2699
+ }
2700
+ const onNodeEnter = genericRoute('enter')
2701
+ const onNodeMove = genericRoute('move')
2702
+ const onNodeClick = genericRoute('click')
2703
+ const onNodeLeave = genericRoute('leave')
2704
+ const gridDispatch = createItemSurfaceGridDispatch(onGridMove)
2705
+ emitter.on('grid:move', gridDispatch.schedule)
2493
2706
  emitter.on('grid:click', onGridClick)
2494
- emitter.on('item:enter', onItemEnter)
2495
- emitter.on('item:move', onItemMove)
2496
- emitter.on('item:leave', onItemLeave)
2497
- emitter.on('item:click', onItemClick)
2498
- emitter.on('wall:enter', onWallEnter)
2499
- emitter.on('wall:move', onWallMove)
2500
- emitter.on('wall:click', onWallClick)
2501
- emitter.on('wall:leave', onWallLeave)
2502
- emitter.on('roof:enter', onRoofWallEnter)
2503
- emitter.on('roof:move', onRoofWallMove)
2504
- emitter.on('roof:click', onRoofWallClick)
2505
- emitter.on('roof:leave', onRoofWallLeave)
2506
- emitter.on('node:enter', onFaceHostEnter)
2507
- emitter.on('node:move', onFaceHostMove)
2508
- emitter.on('node:click', onFaceHostClick)
2509
- emitter.on('node:leave', onFaceHostLeave)
2510
- emitter.on('ceiling:enter', onCeilingEnter)
2511
- emitter.on('ceiling:move', onCeilingMove)
2512
- emitter.on('ceiling:click', onCeilingClick)
2513
- emitter.on('ceiling:leave', onCeilingLeave)
2514
- emitter.on('shelf:enter', onShelfEnter)
2515
- emitter.on('shelf:move', onShelfMove)
2516
- emitter.on('shelf:click', onShelfClick)
2517
- emitter.on('shelf:leave', onShelfLeave)
2707
+ subscribeSpecialized('item:enter', onItemEnter)
2708
+ subscribeSpecialized('item:move', onItemMove)
2709
+ subscribeSpecialized('item:leave', onItemLeave)
2710
+ subscribeSpecialized('item:click', onItemClick)
2711
+ subscribeSpecialized('wall:enter', onWallEnter)
2712
+ subscribeSpecialized('wall:move', onWallMove)
2713
+ subscribeSpecialized('wall:click', onWallClick)
2714
+ subscribeSpecialized('wall:leave', onWallLeave)
2715
+ subscribeSpecialized('roof:enter', onRoofWallEnter)
2716
+ subscribeSpecialized('roof:move', onRoofWallMove)
2717
+ subscribeSpecialized('roof:click', onRoofWallClick)
2718
+ subscribeSpecialized('roof:leave', onRoofWallLeave)
2719
+ emitter.on('node:enter', onNodeEnter)
2720
+ emitter.on('node:move', onNodeMove)
2721
+ emitter.on('node:click', onNodeClick)
2722
+ emitter.on('node:leave', onNodeLeave)
2723
+ subscribeSpecialized('ceiling:enter', onCeilingEnter)
2724
+ subscribeSpecialized('ceiling:move', onCeilingMove)
2725
+ subscribeSpecialized('ceiling:click', onCeilingClick)
2726
+ subscribeSpecialized('ceiling:leave', onCeilingLeave)
2727
+ subscribeSpecialized('cabinet:enter', onItemEnter)
2728
+ subscribeSpecialized('procedural-item:enter', onItemEnter)
2729
+ subscribeSpecialized('cabinet:move', onItemMove)
2730
+ subscribeSpecialized('procedural-item:move', onItemMove)
2731
+ subscribeSpecialized('cabinet:click', onItemClick)
2732
+ subscribeSpecialized('procedural-item:click', onItemClick)
2733
+ subscribeSpecialized('cabinet:leave', onItemLeave)
2734
+ subscribeSpecialized('procedural-item:leave', onItemLeave)
2735
+ subscribeSpecialized('shelf:enter', onShelfEnter)
2736
+ subscribeSpecialized('shelf:move', onShelfMove)
2737
+ subscribeSpecialized('shelf:click', onShelfClick)
2738
+ subscribeSpecialized('shelf:leave', onShelfLeave)
2518
2739
 
2519
2740
  // A floor placement commits at the tracked floor cursor (`gridPosition`),
2520
2741
  // which keeps following the floor even when the click ray lands on a wall
@@ -2534,6 +2755,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2534
2755
 
2535
2756
  return () => {
2536
2757
  tearingDown = true
2758
+ feedback.clear()
2537
2759
  if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
2538
2760
  unsubDraftWatch()
2539
2761
  useAlignmentGuides.getState().clear()
@@ -2544,7 +2766,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2544
2766
  }
2545
2767
  draftNode.destroy()
2546
2768
  useScene.temporal.getState().resume()
2547
- emitter.off('grid:move', onGridMove)
2769
+ gridDispatch.cancel()
2770
+ emitter.off('grid:move', gridDispatch.schedule)
2548
2771
  emitter.off('grid:click', onGridClick)
2549
2772
  emitter.off('item:enter', onItemEnter)
2550
2773
  emitter.off('item:move', onItemMove)
@@ -2558,14 +2781,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2558
2781
  emitter.off('roof:move', onRoofWallMove)
2559
2782
  emitter.off('roof:click', onRoofWallClick)
2560
2783
  emitter.off('roof:leave', onRoofWallLeave)
2561
- emitter.off('node:enter', onFaceHostEnter)
2562
- emitter.off('node:move', onFaceHostMove)
2563
- emitter.off('node:click', onFaceHostClick)
2564
- emitter.off('node:leave', onFaceHostLeave)
2784
+ emitter.off('node:enter', onNodeEnter)
2785
+ emitter.off('node:move', onNodeMove)
2786
+ emitter.off('node:click', onNodeClick)
2787
+ emitter.off('node:leave', onNodeLeave)
2565
2788
  emitter.off('ceiling:enter', onCeilingEnter)
2566
2789
  emitter.off('ceiling:move', onCeilingMove)
2567
2790
  emitter.off('ceiling:click', onCeilingClick)
2568
2791
  emitter.off('ceiling:leave', onCeilingLeave)
2792
+ emitter.off('cabinet:enter', onItemEnter)
2793
+ emitter.off('procedural-item:enter' as never, onItemEnter)
2794
+ emitter.off('cabinet:move', onItemMove)
2795
+ emitter.off('procedural-item:move' as never, onItemMove)
2796
+ emitter.off('cabinet:click', onItemClick)
2797
+ emitter.off('procedural-item:click' as never, onItemClick)
2798
+ emitter.off('cabinet:leave', onItemLeave)
2799
+ emitter.off('procedural-item:leave' as never, onItemLeave)
2569
2800
  emitter.off('shelf:enter', onShelfEnter)
2570
2801
  emitter.off('shelf:move', onShelfMove)
2571
2802
  emitter.off('shelf:click', onShelfClick)
@@ -2623,6 +2854,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2623
2854
  if (
2624
2855
  draftParent?.type === 'item' ||
2625
2856
  draftParent?.type === 'shelf' ||
2857
+ draftParent?.type === 'cabinet' ||
2858
+ draftParent?.type === 'procedural-item' ||
2859
+ (draftParent && canHostSurfaceChild(draftParent, draft.type, draft.id)) ||
2626
2860
  (draftParent &&
2627
2861
  nodeRegistry.get(draftParent.type)?.capabilities.faceHost?.currentFaceId(draft))
2628
2862
  )
@@ -2641,8 +2875,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2641
2875
  // instead of drawing an inline triangle. Only this coordinator publishes — a
2642
2876
  // moving existing node has no draft here, so the grid reads that case straight
2643
2877
  // off the node's mesh. Cleared when idle.
2644
- const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
2645
2878
  const surfaceWorldPointRef = useRef(new Vector3())
2879
+ const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
2646
2880
  const facingForwardRef = useRef(new Vector3(0, 0, 1))
2647
2881
  const facingQuatRef = useRef(new Quaternion())
2648
2882
  const ghostSurfaceQuatRef = useRef(new Quaternion())