@pascal-app/editor 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/package.json +10 -5
  2. package/src/components/editor/custom-camera-controls.tsx +22 -74
  3. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  4. package/src/components/editor/floorplan-panel.tsx +210 -163
  5. package/src/components/editor/grid.tsx +31 -7
  6. package/src/components/editor/group-actions.ts +5 -9
  7. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  8. package/src/components/editor/group-move-3d.ts +4 -6
  9. package/src/components/editor/group-rotate-handle.tsx +81 -35
  10. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  11. package/src/components/editor/group-transform-shared.test.ts +126 -1
  12. package/src/components/editor/group-transform-shared.ts +79 -73
  13. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  14. package/src/components/editor/handles/use-handle-drag.ts +75 -6
  15. package/src/components/editor/index.tsx +24 -9
  16. package/src/components/editor/node-action-menu.tsx +2 -0
  17. package/src/components/editor/node-arrow-handles.tsx +141 -7
  18. package/src/components/editor/registry-action-contributions.tsx +53 -0
  19. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  20. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  21. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  22. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  23. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  24. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  25. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  26. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
  27. package/src/components/systems/zone/zone-system.tsx +8 -4
  28. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  29. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  30. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  31. package/src/components/tools/item/placement-strategies.ts +55 -43
  32. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  33. package/src/components/tools/item/use-draft-node.ts +41 -2
  34. package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
  35. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  36. package/src/components/tools/registry/item-surface-move.ts +46 -24
  37. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  38. package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
  39. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  40. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  41. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  42. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  43. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  44. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  45. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  46. package/src/components/tools/tool-manager.tsx +36 -3
  47. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  48. package/src/components/ui/controls/material-picker.tsx +5 -67
  49. package/src/components/ui/controls/slider-control.tsx +76 -31
  50. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  51. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  52. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  53. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  54. package/src/components/ui/panels/parametric-inspector.tsx +3 -15
  55. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  56. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  57. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
  58. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  59. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  60. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  61. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  62. package/src/components/viewer/floorplan-preview.tsx +1 -12
  63. package/src/components/viewer-zone-system.tsx +7 -2
  64. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  65. package/src/hooks/use-auto-save.ts +21 -6
  66. package/src/hooks/use-ceiling-events.test.ts +1 -0
  67. package/src/hooks/use-ceiling-events.ts +2 -0
  68. package/src/hooks/use-grid-events.test.ts +2 -0
  69. package/src/hooks/use-grid-events.ts +16 -11
  70. package/src/hooks/use-keyboard.test.ts +7 -0
  71. package/src/hooks/use-keyboard.ts +42 -29
  72. package/src/index.tsx +63 -3
  73. package/src/lib/catalog-panel-model.ts +39 -0
  74. package/src/lib/catalog-selection.test.ts +27 -0
  75. package/src/lib/catalog-selection.ts +8 -0
  76. package/src/lib/contextual-help.test.ts +27 -1
  77. package/src/lib/contextual-help.ts +16 -9
  78. package/src/lib/floorplan/floorplan-export.test.ts +3 -1
  79. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  80. package/src/lib/fresh-planar-placement.ts +168 -36
  81. package/src/lib/glb-export.test.ts +59 -16
  82. package/src/lib/glb-export.ts +26 -13
  83. package/src/lib/grid-frame.test.ts +19 -0
  84. package/src/lib/grid-frame.ts +18 -0
  85. package/src/lib/interaction/scope.ts +9 -1
  86. package/src/lib/keyboard-pan.test.ts +62 -0
  87. package/src/lib/keyboard-pan.ts +89 -0
  88. package/src/lib/material-catalog-model.ts +72 -0
  89. package/src/lib/material-paint-panel-model.ts +76 -0
  90. package/src/lib/overlay-transform.test.ts +28 -0
  91. package/src/lib/overlay-transform.ts +12 -0
  92. package/src/lib/panel-rows.ts +75 -0
  93. package/src/lib/panel-tool-options.test.ts +34 -0
  94. package/src/lib/panel-tool-options.ts +46 -0
  95. package/src/lib/parametric-node-update.ts +37 -0
  96. package/src/lib/scene.test.ts +99 -0
  97. package/src/lib/scene.ts +21 -3
  98. package/src/lib/snapping-mode.test.ts +13 -2
  99. package/src/lib/snapping-mode.ts +11 -6
  100. package/src/lib/spatial-drag-plane.test.ts +53 -0
  101. package/src/lib/spatial-drag-plane.ts +19 -0
  102. package/src/lib/spatial-pointer-input.test.ts +72 -0
  103. package/src/lib/spatial-pointer-input.ts +62 -0
  104. package/src/lib/spatial-snap.test.ts +47 -0
  105. package/src/lib/terrain-panel-model.ts +117 -0
  106. package/src/lib/world-grid-snap.test.ts +48 -0
  107. package/src/lib/world-grid-snap.ts +9 -5
  108. package/src/store/rotation-snapping.test.ts +53 -0
  109. package/src/store/use-editor.tsx +10 -2
  110. package/src/store/use-floorplan-draft-preview.ts +10 -1
  111. package/src/store/use-interaction-scope.ts +155 -6
@@ -4,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,
@@ -48,6 +49,7 @@ import {
48
49
  } from '../../../lib/active-placement-surface'
49
50
  import { EDITOR_LAYER } from '../../../lib/constants'
50
51
  import { formatLinearMeasurement } from '../../../lib/measurements'
52
+ import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
51
53
  import { createMovementSfxTick } from '../../../lib/sfx/movement-tick'
52
54
  import { sfxEmitter } from '../../../lib/sfx-bus'
53
55
  import {
@@ -80,11 +82,13 @@ import {
80
82
  } from '../shared/placement-box-geometry'
81
83
  import {
82
84
  type PointerSupportSurface,
83
- resolvePointerSupportElevation,
84
85
  resolvePointerSupportSurface,
85
86
  } from '../shared/pointer-support-cap'
86
87
  import { createShelfStickiness } from '../shared/shelf-stickiness'
87
- import { createSurfaceRejectionFeedback } from '../shared/surface-rejection'
88
+ import {
89
+ createSurfaceEventOwnership,
90
+ createSurfaceRejectionFeedback,
91
+ } from '../shared/surface-rejection'
88
92
  import { shouldCreateFloorDraft } from './draft-creation'
89
93
  import { commitFaceHostClick, resolveFaceHostPreviewCommit } from './face-host-commit'
90
94
  import {
@@ -597,6 +601,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
597
601
  wasAdopted: boolean,
598
602
  repeat: () => void,
599
603
  ) => {
604
+ if (!committedId) return
600
605
  if (configRef.current.onCommitted()) {
601
606
  repeat()
602
607
  return
@@ -626,12 +631,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
626
631
  ) => {
627
632
  const draftId = draftNode.current?.id ?? null
628
633
  const wasAdopted = draftNode.isAdopted
629
- const finalId = draftNode.commit(nodeUpdate, options)
630
- 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) {
631
643
  useLiveTransforms.getState().clear(draftId)
632
644
  useLiveNodeOverrides.getState().clearFields(draftId, faceHostClearFields(draftNode.current))
633
645
  }
634
- return { committedId: finalId ?? draftId, wasAdopted }
646
+ return { committedId: finalId, wasAdopted }
635
647
  }
636
648
 
637
649
  const faceHostClearFields = (draft: ItemNode | null | undefined): Array<keyof ItemNode> => {
@@ -651,6 +663,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
651
663
  }
652
664
 
653
665
  const surfacePointer = createItemSurfacePointerArbitration()
666
+ const surfaceOwnership = createSurfaceEventOwnership()
654
667
  const revalidate = (): boolean => {
655
668
  const fits = checkCanPlace(getContext(), validators)
656
669
  if (
@@ -681,11 +694,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
681
694
  return placeable
682
695
  }
683
696
 
684
- const surfaceContext = (event: NodeEvent<AnyNode>) => {
697
+ const surfaceContext = (event: NodeEvent<AnyNode>, generic = false) => {
685
698
  lastSurfaceEvent = event
686
699
  return {
687
700
  ...getContext(),
688
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()
689
713
  if (
690
714
  (reason === 'footprint-outside-surface' ||
691
715
  reason === 'footprint-exceeds-host' ||
@@ -695,7 +719,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
695
719
  ) {
696
720
  feedback.clear()
697
721
  detachItemSurfaceToFloor(event as ItemEvent)
698
- surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
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)
699
725
  return
700
726
  }
701
727
  feedback.reject(reason, event.nativeEvent.nativeEvent ?? event.nativeEvent)
@@ -746,6 +772,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
746
772
 
747
773
  const applyTransition = (result: TransitionResult, event?: NodeEvent<AnyNode>) => {
748
774
  if (event) {
775
+ surfaceOwnership.claim(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
749
776
  surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
750
777
  tickSurfaceMovementSfx(result.cursorPosition)
751
778
  }
@@ -1054,7 +1081,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1054
1081
  // perspective-skewed along the ray whenever the plane sits on a
1055
1082
  // different storey than the pointed surface (the skew is what made
1056
1083
  // a drag over a deck-above-a-floor hop between the two surfaces).
1057
- const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
1084
+ const pointed = resolvePointerSupportSurface(cameraRef.current, event.position, {
1085
+ pointerRay: event.nativeEvent.ray,
1086
+ })
1058
1087
  pointerSupportCapRef.current = pointed?.elevation ?? null
1059
1088
  pointerSupportSurfaceRef.current = pointed
1060
1089
  const surfaceEvent: GridEvent =
@@ -1732,6 +1761,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1732
1761
  if (draft) useScene.getState().updateNode(draft.id, result.nodeUpdate)
1733
1762
  } else {
1734
1763
  draftNode.destroy()
1764
+ gridPosition.current.set(...result.gridPosition)
1735
1765
  Object.assign(placementState.current, result.stateUpdate)
1736
1766
  }
1737
1767
  }
@@ -1744,11 +1774,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1744
1774
  // Landing back on the floor: refresh the pointer surface cap from
1745
1775
  // this event's world hit so the first floor position already targets
1746
1776
  // the aimed-at surface (not a deck above it).
1747
- pointerSupportCapRef.current = resolvePointerSupportElevation(cameraRef.current, [
1748
- event.position[0],
1749
- event.position[1],
1750
- event.position[2],
1751
- ])
1777
+ pointerSupportCapRef.current =
1778
+ resolvePointerSupportSurface(cameraRef.current, event.position, {
1779
+ pointerRay: event.nativeEvent.ray,
1780
+ })?.elevation ?? null
1752
1781
  // Coming back from a host: forget the floor grab too, so the item
1753
1782
  // centers under the cursor instead of restoring the pre-drag offset —
1754
1783
  // and landing on the floor is "anchoring elsewhere", so a later return
@@ -1823,7 +1852,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1823
1852
  revalidate()
1824
1853
  }
1825
1854
 
1826
- const onItemEnter = (event: NodeEvent<AnyNode>) => {
1855
+ const onItemEnter = (event: NodeEvent<AnyNode>, generic = false) => {
1827
1856
  if (event.node.type === 'cabinet' && placementState.current.surfaceItemId === event.node.id) {
1828
1857
  onItemMove(event)
1829
1858
  return
@@ -1834,7 +1863,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1834
1863
  }
1835
1864
  if (event.node.id === draftNode.current?.id) return
1836
1865
  has3DPointerDrivenMoveRef.current = true
1837
- const result = itemSurfaceStrategy.enter(surfaceContext(event), event)
1866
+ const result = itemSurfaceStrategy.enter(surfaceContext(event, generic), event)
1838
1867
  if (!result) return
1839
1868
  feedback.clear()
1840
1869
 
@@ -1849,12 +1878,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1849
1878
  }
1850
1879
  }
1851
1880
 
1852
- const onItemMove = (event: NodeEvent<AnyNode>) => {
1881
+ const onItemMove = (event: NodeEvent<AnyNode>, generic = false) => {
1853
1882
  if (event.node.id === draftNode.current?.id) return
1854
1883
  releaseCommit = () => onItemClick(event)
1855
1884
  has3DPointerDrivenMoveRef.current = true
1856
1885
  if (!cursorGroupRef.current) return
1857
- const ctx = surfaceContext(event)
1886
+ const ctx = surfaceContext(event, generic)
1858
1887
  if (event.node.type === 'cabinet') {
1859
1888
  lastRawPos.current.set(...event.position)
1860
1889
  surfacePointer.hit(event.node.id, event.nativeEvent.nativeEvent ?? event.nativeEvent)
@@ -1879,6 +1908,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1879
1908
  event,
1880
1909
  )
1881
1910
 
1911
+ // An unclaimed generic mesh must let its owning ancestor receive the same hit.
1912
+ if (generic && !enterResult) return
1882
1913
  event.stopPropagation()
1883
1914
  if (enterResult) {
1884
1915
  applyTransition(enterResult, event)
@@ -1892,7 +1923,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1892
1923
  }
1893
1924
 
1894
1925
  if (!draftNode.current) {
1895
- const enterResult = itemSurfaceStrategy.enter(surfaceContext(event), event)
1926
+ const enterResult = itemSurfaceStrategy.enter(surfaceContext(event, generic), event)
1896
1927
  if (!enterResult) return
1897
1928
  event.stopPropagation()
1898
1929
  ensureDraft(enterResult)
@@ -2007,12 +2038,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2007
2038
  // the host's own click event is blocked by the cursor preview.
2008
2039
  if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
2009
2040
  const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
2010
- if (
2011
- hostNode &&
2012
- (hostNode.type === 'item' ||
2013
- hostNode.type === 'cabinet' ||
2014
- hostNode.type === 'procedural-item')
2015
- ) {
2041
+ if (hostNode) {
2016
2042
  const synthetic = { ...event, node: hostNode } as ItemEvent
2017
2043
  const result = itemSurfaceStrategy.click(ctx, synthetic)
2018
2044
  if (result) {
@@ -2386,7 +2412,49 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2386
2412
  const currentRotation = draft.rotation
2387
2413
  // Round to the nearest 45° then step, matching the placed-item R/T.
2388
2414
  const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
2389
- 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
2390
2458
 
2391
2459
  // Rotate the building-local cursor by the same delta as the host-local
2392
2460
  // draft. This preserves the host's composed yaw for items resting on a
@@ -2398,7 +2466,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2398
2466
  if (mesh) mesh.rotation.y = newRotationY
2399
2467
 
2400
2468
  // Re-snap position immediately with updated rotation (dimX/dimZ may swap at 90°)
2401
- const surface = placementState.current.surface
2402
2469
  if (surface === 'floor' || surface === 'ceiling') {
2403
2470
  const dims = getScaledDimensions(draft)
2404
2471
  const [dimX, , dimZ] = dims
@@ -2428,15 +2495,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2428
2495
  }
2429
2496
  }
2430
2497
  } else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
2431
- const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
2432
- if (surfaceMesh) {
2433
- const localPos = surfaceMesh.worldToLocal(lastRawPos.current.clone())
2434
- const dims = getScaledDimensions(draft)
2435
- const [dimX, , dimZ] = dims
2436
- const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
2437
- const x = snapToGrid(localPos.x, swapDims ? dimZ : dimX)
2438
- const z = snapToGrid(localPos.z, swapDims ? dimX : dimZ)
2439
- const y = gridPosition.current.y
2498
+ if (surfaceMesh && surfacePosition) {
2499
+ const [x, y, z] = surfacePosition
2440
2500
  gridPosition.current.set(x, y, z)
2441
2501
  draft.position = [x, y, z]
2442
2502
  const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
@@ -2594,7 +2654,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2594
2654
  const unsubDraftWatch = useScene.subscribe((state) => {
2595
2655
  if (tearingDown) return
2596
2656
  const draft = draftNode.current
2597
- if (draft === null) return
2657
+ if (draft === null || isFreshPlacementMetadata(draft.metadata)) return
2598
2658
  if (draft.id in state.nodes) return
2599
2659
 
2600
2660
  queueMicrotask(() => {
@@ -2609,41 +2669,73 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2609
2669
 
2610
2670
  // ---- Subscribe ----
2611
2671
 
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')
2612
2704
  const gridDispatch = createItemSurfaceGridDispatch(onGridMove)
2613
2705
  emitter.on('grid:move', gridDispatch.schedule)
2614
2706
  emitter.on('grid:click', onGridClick)
2615
- emitter.on('item:enter', onItemEnter)
2616
- emitter.on('item:move', onItemMove)
2617
- emitter.on('item:leave', onItemLeave)
2618
- emitter.on('item:click', onItemClick)
2619
- emitter.on('wall:enter', onWallEnter)
2620
- emitter.on('wall:move', onWallMove)
2621
- emitter.on('wall:click', onWallClick)
2622
- emitter.on('wall:leave', onWallLeave)
2623
- emitter.on('roof:enter', onRoofWallEnter)
2624
- emitter.on('roof:move', onRoofWallMove)
2625
- emitter.on('roof:click', onRoofWallClick)
2626
- emitter.on('roof:leave', onRoofWallLeave)
2627
- emitter.on('node:enter', onFaceHostEnter)
2628
- emitter.on('node:move', onFaceHostMove)
2629
- emitter.on('node:click', onFaceHostClick)
2630
- emitter.on('node:leave', onFaceHostLeave)
2631
- emitter.on('ceiling:enter', onCeilingEnter)
2632
- emitter.on('ceiling:move', onCeilingMove)
2633
- emitter.on('ceiling:click', onCeilingClick)
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)
2643
- emitter.on('shelf:enter', onShelfEnter)
2644
- emitter.on('shelf:move', onShelfMove)
2645
- emitter.on('shelf:click', onShelfClick)
2646
- 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)
2647
2739
 
2648
2740
  // A floor placement commits at the tracked floor cursor (`gridPosition`),
2649
2741
  // which keeps following the floor even when the click ray lands on a wall
@@ -2689,10 +2781,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2689
2781
  emitter.off('roof:move', onRoofWallMove)
2690
2782
  emitter.off('roof:click', onRoofWallClick)
2691
2783
  emitter.off('roof:leave', onRoofWallLeave)
2692
- emitter.off('node:enter', onFaceHostEnter)
2693
- emitter.off('node:move', onFaceHostMove)
2694
- emitter.off('node:click', onFaceHostClick)
2695
- 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)
2696
2788
  emitter.off('ceiling:enter', onCeilingEnter)
2697
2789
  emitter.off('ceiling:move', onCeilingMove)
2698
2790
  emitter.off('ceiling:click', onCeilingClick)
@@ -2764,6 +2856,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2764
2856
  draftParent?.type === 'shelf' ||
2765
2857
  draftParent?.type === 'cabinet' ||
2766
2858
  draftParent?.type === 'procedural-item' ||
2859
+ (draftParent && canHostSurfaceChild(draftParent, draft.type, draft.id)) ||
2767
2860
  (draftParent &&
2768
2861
  nodeRegistry.get(draftParent.type)?.capabilities.faceHost?.currentFaceId(draft))
2769
2862
  )
@@ -2782,8 +2875,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
2782
2875
  // instead of drawing an inline triangle. Only this coordinator publishes — a
2783
2876
  // moving existing node has no draft here, so the grid reads that case straight
2784
2877
  // off the node's mesh. Cleared when idle.
2785
- const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
2786
2878
  const surfaceWorldPointRef = useRef(new Vector3())
2879
+ const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
2787
2880
  const facingForwardRef = useRef(new Vector3(0, 0, 1))
2788
2881
  const facingQuatRef = useRef(new Quaternion())
2789
2882
  const ghostSurfaceQuatRef = useRef(new Quaternion())
@@ -9,7 +9,6 @@ import {
9
9
  ItemNode,
10
10
  LevelNode,
11
11
  nodeRegistry,
12
- registerNode,
13
12
  resolveSupportSlabPatch,
14
13
  sceneRegistry,
15
14
  spatialGridManager,
@@ -23,6 +22,7 @@ import {
23
22
  } from '@pascal-app/core/procedural-items'
24
23
  import { Group, Vector3 } from 'three'
25
24
  import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
25
+ import { registerHostingTestNode } from '../__fixtures__/hosting'
26
26
  import {
27
27
  createItemSurfaceGridDispatch,
28
28
  createItemSurfacePointerArbitration,
@@ -73,7 +73,15 @@ beforeEach(() => {
73
73
  oldNodes = useScene.getState().nodes
74
74
  oldRoots = useScene.getState().rootNodeIds
75
75
  nodeRegistry._reset()
76
- registerNode({
76
+ registerHostingTestNode({
77
+ kind: 'item',
78
+ schemaVersion: 1,
79
+ schema: ItemNode,
80
+ category: 'furnish',
81
+ defaults: () => ({}),
82
+ capabilities: {},
83
+ })
84
+ registerHostingTestNode({
77
85
  kind: 'procedural-item',
78
86
  schemaVersion: 1,
79
87
  schema: ProceduralItemNode,
@@ -11,6 +11,7 @@ import {
11
11
  sceneRegistry,
12
12
  useLiveTransforms,
13
13
  useScene,
14
+ wouldCreateHostingCycle,
14
15
  } from '@pascal-app/core'
15
16
  import { boxCorners } from '@pascal-app/core/procedural-items'
16
17
  import { type Camera, Euler, Quaternion, Vector3 } from 'three'
@@ -23,7 +24,10 @@ import {
23
24
  import { snapToGrid, snapToHalf } from '../item/placement-math'
24
25
  import { createShelfStickiness } from '../shared/shelf-stickiness'
25
26
  import { itemEventToSurfaceHit } from '../shared/surface-hit'
26
- import { createSurfaceRejectionFeedback } from '../shared/surface-rejection'
27
+ import {
28
+ createSurfaceEventOwnership,
29
+ createSurfaceRejectionFeedback,
30
+ } from '../shared/surface-rejection'
27
31
 
28
32
  export function createItemSurfacePointerArbitration() {
29
33
  let hostId: string | null = null
@@ -114,6 +118,7 @@ export function createRegistryItemSurfaceMove(node: AnyNode) {
114
118
  const feedback = createSurfaceRejectionFeedback()
115
119
  const scene = createSceneApi(useScene)
116
120
  const pointer = createItemSurfacePointerArbitration()
121
+ const ownership = createSurfaceEventOwnership()
117
122
  const originalParent = original.parentId
118
123
  ? useScene.getState().nodes[original.parentId as AnyNodeId]
119
124
  : null
@@ -165,6 +170,14 @@ export function createRegistryItemSurfaceMove(node: AnyNode) {
165
170
  surfaceId,
166
171
  )
167
172
  useLiveTransforms.getState().clear(node.id)
173
+ // A floor move and host return can share a React batch with unchanged final props.
174
+ // Restore the local mesh pose too, so an imperative floor preview cannot survive it.
175
+ const mesh = sceneRegistry.nodes.get(node.id)
176
+ if (mesh) {
177
+ mesh.position.set(...position)
178
+ const angles = fullRotation ?? rotation(yaw)
179
+ mesh.rotation.set(...(typeof angles === 'number' ? ([0, angles, 0] as const) : angles))
180
+ }
168
181
  }
169
182
  const session = {
170
183
  get rejection() {
@@ -211,17 +224,14 @@ export function createRegistryItemSurfaceMove(node: AnyNode) {
211
224
  }
212
225
  },
213
226
  enter(event: NodeEvent<AnyNode>, dimensions: [number, number, number], yaw: number) {
227
+ if (!ownership.allows(event.node.id, pointerEventOf(event))) return null
214
228
  pointer.clear()
215
229
  feedback.clear()
216
230
  const live = liveNode()
217
231
  if (floorPlaced.applies && !floorPlaced.applies(live)) return null
218
232
  const host = useScene.getState().nodes[event.node.id]
219
233
  if (!host || NON_PHYSICAL_HOST_KINDS.includes(host.type)) return null
220
- let ancestor: AnyNode | undefined = host
221
- while (ancestor) {
222
- if (ancestor.id === node.id) return null
223
- ancestor = ancestor.parentId ? scene.get(ancestor.parentId as AnyNodeId) : undefined
224
- }
234
+ if (wouldCreateHostingCycle(node.id, host, scene)) return null
225
235
  const mesh = sceneRegistry.nodes.get(host.id)
226
236
  if (!mesh) return null
227
237
  const raw = mesh.worldToLocal(new Vector3(...event.position)).toArray()
@@ -262,6 +272,7 @@ export function createRegistryItemSurfaceMove(node: AnyNode) {
262
272
  const placement = resolveSurfacePlacement({
263
273
  host,
264
274
  childKind: node.type,
275
+ childId: node.id,
265
276
  childFootprint: {
266
277
  size: dimensions,
267
278
  rotationY: localYaw,
@@ -278,7 +289,23 @@ export function createRegistryItemSurfaceMove(node: AnyNode) {
278
289
  scene,
279
290
  snapScalar: host.type !== 'item' ? undefined : snapToGrid,
280
291
  checkFootprint: true,
281
- onReject: (reason) => feedback.reject(reason, pointerEventOf(event)),
292
+ onReject: (reason) => {
293
+ if (
294
+ live.parentId !== host.id &&
295
+ !counterHit &&
296
+ host.type !== 'item' &&
297
+ host.type !== 'shelf' &&
298
+ reason !== 'surface-cutout' &&
299
+ reason !== 'surface-occupied'
300
+ )
301
+ return
302
+ if (reason === 'surface-cutout' || reason === 'surface-occupied') {
303
+ ownership.claim(host.id, pointerEventOf(event))
304
+ pointer.hit(host.id, pointerEventOf(event))
305
+ event.stopPropagation()
306
+ }
307
+ feedback.reject(reason, pointerEventOf(event))
308
+ },
282
309
  })
283
310
  if (!placement) return null
284
311
  valid = true
@@ -291,6 +318,7 @@ export function createRegistryItemSurfaceMove(node: AnyNode) {
291
318
  worldPosition: mesh.localToWorld(new Vector3(...placement.position)).toArray(),
292
319
  }
293
320
  grab = corrected.grab
321
+ ownership.claim(host.id, pointerEventOf(event))
294
322
  pointer.hit(host.id, pointerEventOf(event))
295
323
  event.stopPropagation()
296
324
  write(
@@ -344,12 +372,7 @@ export function createRegistryItemSurfaceMove(node: AnyNode) {
344
372
  false,
345
373
  )
346
374
  if (host?.type === 'procedural-item') position.splice(0, 3, ...local.position)
347
- if (
348
- host?.type === 'cabinet' ||
349
- host?.type === 'shelf' ||
350
- host?.type === 'item' ||
351
- host?.type === 'procedural-item'
352
- ) {
375
+ if (host) {
353
376
  const footprint = floorPlaced.footprint?.(live, { nodes: scene.nodes() })
354
377
  const bounds:
355
378
  | { size: [number, number, number]; center?: [number, number, number] }
@@ -376,6 +399,7 @@ export function createRegistryItemSurfaceMove(node: AnyNode) {
376
399
  const placement = resolveSurfacePlacement({
377
400
  host,
378
401
  childKind: live.type,
402
+ childId: live.id,
379
403
  childFootprint: {
380
404
  size: bounds.size,
381
405
  rotationY: Array.isArray(localRotation) ? localRotation[1] : localRotation,
@@ -394,7 +418,14 @@ export function createRegistryItemSurfaceMove(node: AnyNode) {
394
418
  onReject: (reason) => feedback.reject(reason),
395
419
  })
396
420
  valid = !!placement
397
- if (placement && host.type === 'procedural-item') {
421
+ if (!placement) {
422
+ // Null means free rotation to the caller, so return the unchanged stored pose.
423
+ return {
424
+ position: [...live.position] as [number, number, number],
425
+ rotationY: Array.isArray(live.rotation) ? live.rotation[1] : live.rotation,
426
+ }
427
+ }
428
+ if (host.type === 'procedural-item') {
398
429
  const pose =
399
430
  placement.childFrame === 'surface-local'
400
431
  ? placement.surfaceLocal!
@@ -405,16 +436,7 @@ export function createRegistryItemSurfaceMove(node: AnyNode) {
405
436
  rotationY: pose.rotationY,
406
437
  }
407
438
  }
408
- if (placement) position[1] = placement.position[1]
409
- else if (
410
- feedback.reason === 'footprint-outside-surface' ||
411
- feedback.reason === 'footprint-exceeds-host' ||
412
- feedback.reason === 'no-surface'
413
- ) {
414
- const mesh = sceneRegistry.nodes.get(host.id)
415
- if (mesh)
416
- return session.detach(mesh.localToWorld(new Vector3(...position)).toArray(), yaw)
417
- }
439
+ position[1] = placement.position[1]
418
440
  }
419
441
  write(live.parentId, position, yaw)
420
442
  return { position, rotationY: yaw }
@@ -7,7 +7,6 @@ import {
7
7
  ItemNode,
8
8
  LevelNode,
9
9
  nodeRegistry,
10
- registerNode,
11
10
  type SurfacePlacement,
12
11
  sceneRegistry,
13
12
  useLiveTransforms,
@@ -17,6 +16,7 @@ import { Group, Vector3 } from 'three'
17
16
  import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
18
17
  import useEditor from '../../../store/use-editor'
19
18
  import useInteractionScope from '../../../store/use-interaction-scope'
19
+ import { registerHostingTestNode } from '../__fixtures__/hosting'
20
20
  import { createRegistryItemSurfaceMove } from './item-surface-move'
21
21
 
22
22
  const level = LevelNode.parse({ id: 'level_resolver' })
@@ -69,7 +69,7 @@ beforeEach(() => {
69
69
  savedRoots = useScene.getState().rootNodeIds
70
70
  restoreRegistry = nodeRegistry._snapshot()
71
71
  nodeRegistry._reset()
72
- registerNode({
72
+ registerHostingTestNode({
73
73
  kind: 'item',
74
74
  schemaVersion: 1,
75
75
  schema: ItemNode,