@pascal-app/editor 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. package/src/store/use-send-to-app.ts +21 -0
@@ -8,6 +8,7 @@ import {
8
8
  type AnyNodeId,
9
9
  analyzePortConnectivity,
10
10
  bboxCornerAnchors,
11
+ cascadeDirty,
11
12
  collectAlignmentAnchors,
12
13
  createSceneApi,
13
14
  emitter,
@@ -18,6 +19,7 @@ import {
18
19
  getFloorPlacedFootprints,
19
20
  type MovableConfig,
20
21
  movingFootprintAnchors,
22
+ NON_PHYSICAL_HOST_KINDS,
21
23
  type NodeEvent,
22
24
  nodeRegistry,
23
25
  type ParentFrameSnapMatch,
@@ -27,6 +29,7 @@ import {
27
29
  resolveFacingIndicator,
28
30
  resolveFrozenFloorPlacementPatch,
29
31
  resolveSupportSlabPatch,
32
+ type SurfaceRejectReason,
30
33
  sceneRegistry,
31
34
  spatialGridManager,
32
35
  useLiveNodeOverrides,
@@ -36,7 +39,7 @@ import {
36
39
  import { useViewer } from '@pascal-app/viewer'
37
40
  import { useFrame, useThree } from '@react-three/fiber'
38
41
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
39
- import type { Group } from 'three'
42
+ import { type Group, Vector3 } from 'three'
40
43
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
41
44
  import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
42
45
  import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
@@ -45,10 +48,14 @@ import {
45
48
  resolvePrioritizedPlanarCursorPosition,
46
49
  } from '../../../lib/planar-cursor-placement'
47
50
  import { resolveAttachmentPreviewRotation } from '../../../lib/rigid-plan-svg-transform'
48
- import { movementSfxStepKey } from '../../../lib/sfx/movement-tick'
51
+ import { createMovementSfxTick } from '../../../lib/sfx/movement-tick'
49
52
  import { sfxEmitter } from '../../../lib/sfx-bus'
50
53
  import { resolveSnapFlags } from '../../../lib/snapping-mode'
51
-
54
+ import {
55
+ surfaceAttachmentId,
56
+ surfaceAttachmentUpdates,
57
+ updateSurfaceNode,
58
+ } from '../../../lib/surface-attachment'
52
59
  import useAlignmentGuides from '../../../store/use-alignment-guides'
53
60
  import useEditor, {
54
61
  getActiveSnappingMode,
@@ -56,7 +63,6 @@ import useEditor, {
56
63
  isGridSnapActive,
57
64
  isMagneticSnapActive,
58
65
  } from '../../../store/use-editor'
59
-
60
66
  import useFacingPose from '../../../store/use-facing-pose'
61
67
  import { swallowNextClick } from '../../editor/node-arrow-handles'
62
68
  import { CursorSphere } from '../shared/cursor-sphere'
@@ -68,6 +74,7 @@ import {
68
74
  type PointerSupportSurface,
69
75
  resolvePointerSupportSurface,
70
76
  } from '../shared/pointer-support-cap'
77
+ import { createItemSurfaceGridDispatch, createRegistryItemSurfaceMove } from './item-surface-move'
71
78
 
72
79
  /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
73
80
  * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
@@ -197,18 +204,6 @@ const ALIGNMENT_THRESHOLD_M = 0.08
197
204
  /**
198
205
  * Generic move tool for any registry-backed kind.
199
206
  *
200
- * Imperative-only motion during drag:
201
- * - On every `grid:move` we mutate `sceneRegistry.nodes.get(id).position`
202
- * directly. The node's store data is unchanged → the renderer doesn't
203
- * re-render → R3F doesn't reapply `position={node.position}` → the
204
- * imperative mutation sticks. Movement is smooth, framerate-locked,
205
- * and React-free.
206
- *
207
- * Store update happens only on commit (single undoable action).
208
- *
209
- * Cancel imperatively snaps the mesh back to its original position and
210
- * resumes history without ever having touched the store mid-drag.
211
- *
212
207
  * **Commit triggers**: the tool listens for `grid:click` and the generic
213
208
  * `node:click` event. A click on the grid plane fires `grid:click`; a click
214
209
  * on the moved node itself (or any other 3D geometry the ray happens to land
@@ -224,8 +219,11 @@ const ALIGNMENT_THRESHOLD_M = 0.08
224
219
  type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
225
220
 
226
221
  export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
222
+ const itemSurfaceMove = useMemo(() => createRegistryItemSurfaceMove(node), [node])
223
+ const suppressRaycastsRef = useRef<(() => void) | null>(null)
227
224
  const previewGroupRef = useRef<Group>(null)
228
225
  useFrame(() => {
226
+ suppressRaycastsRef.current?.()
229
227
  if (!previewGroupRef.current) return
230
228
  const nodes = useScene.getState().nodes
231
229
  const parentId = nodes[node.id]?.parentId ?? node.parentId
@@ -282,7 +280,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
282
280
  return 0
283
281
  }, [node])
284
282
  const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition)
285
- const previousSnapRef = useRef<string | null>(null)
286
283
  /**
287
284
  * The latest snapped cursor position from `grid:move`. We commit at
288
285
  * THIS position regardless of which event variant fires the click —
@@ -362,20 +359,24 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
362
359
  [collides, parentFrameCollides, resolvedFootprint],
363
360
  )
364
361
  const [valid, setValid] = useState(true)
362
+ const [surfaceRejection, setSurfaceRejection] = useState<SurfaceRejectReason | null>(null)
365
363
  const previewRotationY = useCallback(
366
364
  (rotationY = rotationRef.current) =>
367
365
  parentFrame && frameParent
368
366
  ? parentFrame.parentRotationY(frameParent, useScene.getState().nodes) + rotationY
369
- : rotationY,
370
- [parentFrame, frameParent],
367
+ : itemSurfaceMove?.hosted
368
+ ? itemSurfaceMove.planPose(lastCursorRef.current, rotationY).rotationY
369
+ : rotationY,
370
+ [parentFrame, frameParent, itemSurfaceMove],
371
371
  )
372
372
  const visualPositionFor = useCallback(
373
373
  (position: [number, number, number], rotationY = rotationRef.current) => {
374
374
  if (parentFrame && frameParent) {
375
375
  return parentFrame.localToPlan(frameParent, position, useScene.getState().nodes)
376
376
  }
377
+ if (itemSurfaceMove?.hosted) return itemSurfaceMove.planPose(position, rotationY).position
377
378
  return getFloorStackPreviewPosition({
378
- node,
379
+ node: useScene.getState().nodes[node.id] ?? node,
379
380
  position,
380
381
  rotation: (() => {
381
382
  const r = (node as { rotation?: unknown }).rotation
@@ -386,7 +387,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
386
387
  maxElevation: supportCapRef.current,
387
388
  })
388
389
  },
389
- [parentFrame, frameParent, node],
390
+ [parentFrame, frameParent, node, itemSurfaceMove],
390
391
  )
391
392
  const canonicalPositionFromPlan = useCallback(
392
393
  (planX: number, localY: number, planZ: number): [number, number, number] =>
@@ -432,7 +433,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
432
433
 
433
434
  useEffect(() => {
434
435
  useScene.temporal.getState().pause()
435
- previousSnapRef.current = null
436
+ const movementSfx = createMovementSfxTick()
436
437
  dragAnchorRef.current = null
437
438
  hasMovedRef.current = false
438
439
  rotationRef.current = originalRotationY
@@ -451,14 +452,22 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
451
452
  // would keep the previous clone's rotation/position until the next R/T.
452
453
  setCursorRotationY(previewRotationY(originalRotationY))
453
454
  lastCursorRef.current = originalPosition
455
+ let floorY = originalPosition[1]
456
+ let centeredAfterHost = false
454
457
  let committed = false
455
458
  const isNew = isFreshPlacement
456
459
 
457
460
  const baseRotation = (node as { rotation?: unknown }).rotation
458
- const toCommitRotation = (y: number): number | [number, number, number] =>
459
- Array.isArray(baseRotation)
460
- ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
461
- : y
461
+ const toCommitRotation = (y: number): number | [number, number, number] => {
462
+ const live = useScene.getState().nodes[node.id]
463
+ const rotation =
464
+ live && surfaceAttachmentId(live) && 'rotation' in live ? live.rotation : baseRotation
465
+ return Array.isArray(rotation) ? [rotation[0] ?? 0, y, rotation[2] ?? 0] : y
466
+ }
467
+
468
+ const currentLevelId = () =>
469
+ useViewer.getState().selection.levelId ??
470
+ (itemSurfaceMove ? findLevelAncestorId(node.id, useScene.getState().nodes) : node.parentId)
462
471
 
463
472
  const getVisualPosition = visualPositionFor
464
473
  const applyMeshPose = (position: [number, number, number], rotationY = rotationRef.current) => {
@@ -469,7 +478,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
469
478
  }
470
479
  const markMovedNodeDirty = () => {
471
480
  if (useScene.getState().nodes[node.id]) {
472
- useScene.getState().markDirty(node.id as AnyNodeId)
481
+ for (const id of cascadeDirty(node.id as AnyNodeId, { scene: createSceneApi(useScene) }))
482
+ useScene.getState().markDirty(id)
473
483
  }
474
484
  }
475
485
 
@@ -563,10 +573,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
563
573
  // override so the user can drop on top of an existing item on purpose. Only
564
574
  // shelves show the box, so this no-ops for every other movable kind.
565
575
  const recomputeValidity = () => {
576
+ const rejection = itemSurfaceMove?.rejection ?? null
577
+ setSurfaceRejection(rejection)
578
+ if (rejection) {
579
+ validRef.current = false
580
+ setValid(false)
581
+ return
582
+ }
566
583
  if (!boxDimensions && !movableValidityConfig) return
567
- if (altRef.current) {
568
- validRef.current = true
569
- setValid(true)
584
+ if (altRef.current || itemSurfaceMove?.hosted) {
585
+ const valid = !itemSurfaceMove?.hosted || itemSurfaceMove.valid
586
+ validRef.current = valid
587
+ setValid(valid)
570
588
  return
571
589
  }
572
590
  if (parentFrameCollides && frameParent && parentFrame?.isValidPosition) {
@@ -584,7 +602,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
584
602
  setValid(validPosition)
585
603
  return
586
604
  }
587
- const levelId = useViewer.getState().selection.levelId ?? node.parentId
605
+ const levelId = currentLevelId()
588
606
  if (!levelId) {
589
607
  validRef.current = true
590
608
  setValid(true)
@@ -594,7 +612,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
594
612
  const liveRotation = previewRotationY(rotationRef.current)
595
613
  const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
596
614
  const effectiveNode = {
597
- ...(node as Record<string, unknown>),
615
+ ...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
598
616
  position: livePosition,
599
617
  rotation: Array.isArray((node as { rotation?: unknown }).rotation)
600
618
  ? [
@@ -651,10 +669,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
651
669
  // passes through the moved mesh and continues to the grid plane,
652
670
  // so `grid:move` keeps firing and the cursor tracks correctly.
653
671
  // We restore the original raycast on cleanup.
654
- const mesh = sceneRegistry.nodes.get(node.id)
655
672
  const restoreRaycasts: Array<() => void> = []
656
- if (mesh) {
657
- mesh.traverse((child) => {
673
+ const suppressed = new WeakSet<object>()
674
+ const suppressRaycasts = () => {
675
+ sceneRegistry.nodes.get(node.id)?.traverse((child) => {
676
+ if (suppressed.has(child)) return
677
+ suppressed.add(child)
658
678
  const original = child.raycast
659
679
  child.raycast = () => {}
660
680
  restoreRaycasts.push(() => {
@@ -662,6 +682,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
662
682
  })
663
683
  })
664
684
  }
685
+ // Reparenting remounts the renderer, so new meshes must also let the cursor ray through.
686
+ suppressRaycastsRef.current = suppressRaycasts
687
+ suppressRaycasts()
665
688
 
666
689
  // Static alignment candidates — anchors of every OTHER alignable object
667
690
  // (items, walls, fences, slabs, ceilings, columns) ON THE SAME LEVEL,
@@ -673,7 +696,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
673
696
  const alignmentCandidates = collectAlignmentAnchors(
674
697
  useScene.getState().nodes,
675
698
  node.id,
676
- useViewer.getState().selection.levelId ?? node.parentId,
699
+ currentLevelId(),
677
700
  )
678
701
 
679
702
  // Connectivity snapshot (existing port-bearing nodes only — fresh
@@ -686,7 +709,76 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
686
709
  if (snapshot.connections.length > 0) connectivityRef.current = snapshot
687
710
  }
688
711
 
712
+ const applySurfacePose = (pose: { position: [number, number, number]; rotationY: number }) => {
713
+ lastCursorRef.current = pose.position
714
+ rotationRef.current = pose.rotationY
715
+ freeRotationRef.current = pose.rotationY
716
+ attachmentRotationRef.current = null
717
+ centeredAfterHost = true
718
+ dragAnchorRef.current = null
719
+ hasMovedRef.current = true
720
+ revealFreshPlacement()
721
+ setCursorPosition(getVisualPosition(pose.position))
722
+ setCursorRotationY(previewRotationY(pose.rotationY))
723
+ useAlignmentGuides.getState().clear()
724
+ recomputeValidity()
725
+ }
726
+ const detachSurface = (
727
+ worldPosition: [number, number, number],
728
+ leaveEvent?: NodeEvent<AnyNode>,
729
+ ) => {
730
+ const pose = leaveEvent
731
+ ? itemSurfaceMove?.leave(leaveEvent, rotationRef.current)
732
+ : itemSurfaceMove?.detach(worldPosition, rotationRef.current)
733
+ if (!pose) return
734
+ floorY = 0
735
+ const pointed = resolvePointerSupportSurface(cameraRef.current, worldPosition)
736
+ supportCapRef.current = pointed?.elevation ?? null
737
+ supportSurfaceRef.current = pointed
738
+ applySurfacePose(pose)
739
+ applyMeshPose(pose.position)
740
+ }
741
+ let lastSurfaceEvent: NodeEvent<AnyNode> | null = null
742
+ const onItemMove = (event: NodeEvent<AnyNode>) => {
743
+ if (committed || !resolvedFootprint || !itemSurfaceMove) return
744
+ lastSurfaceEvent = event
745
+ const pose = itemSurfaceMove.enter(event, resolvedFootprint, rotationRef.current)
746
+ if (pose) {
747
+ applySurfacePose(pose)
748
+ // Compare floor and host movement in the same plan frame, never host-local storage.
749
+ const point = new Vector3(...pose.worldPosition)
750
+ const levelId = currentLevelId()
751
+ if (levelId) sceneRegistry.nodes.get(levelId)?.worldToLocal(point)
752
+ movementSfx.tick({
753
+ coords: [point.x, point.z],
754
+ gridSnapActive: isGridSnapActive(),
755
+ gridStep: useEditor.getState().gridSnapStep,
756
+ })
757
+ } else {
758
+ if (
759
+ itemSurfaceMove.hosted &&
760
+ (itemSurfaceMove.rejection === 'footprint-outside-surface' ||
761
+ itemSurfaceMove.rejection === 'footprint-exceeds-host' ||
762
+ itemSurfaceMove.rejection === 'no-surface')
763
+ )
764
+ detachSurface(event.position)
765
+ recomputeValidity()
766
+ }
767
+ }
768
+ const onItemLeave = (event: NodeEvent<AnyNode>) => {
769
+ if (!itemSurfaceMove?.hosted || committed) return
770
+ if (event.node.id !== useScene.getState().nodes[node.id]?.parentId) return
771
+ event.stopPropagation()
772
+ detachSurface(event.position, event)
773
+ }
774
+
689
775
  const onGridMove = (event: GridEvent) => {
776
+ const blocked = Boolean(itemSurfaceMove?.blocksGrid(event, cameraRef.current))
777
+ if (!committed && !blocked) {
778
+ itemSurfaceMove?.clearRejectionForGrid(event)
779
+ detachSurface(event.position)
780
+ }
781
+ if (committed || blocked) return
690
782
  // The pointer decides the target surface AND the cursor plan point,
691
783
  // both resolved from the true camera ray in one place. The event's
692
784
  // own hit can't be used directly: its plane rides at the ghost's
@@ -707,7 +799,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
707
799
 
708
800
  const magnetic = isMagneticSnapActive()
709
801
  const attachmentEnabled = magnetic || isGridSnapActive()
710
- const absolute = useAbsoluteCursorPlacement || cursorAttached
802
+ const absolute = useAbsoluteCursorPlacement || cursorAttached || centeredAfterHost
711
803
  const centerOffset: [number, number, number] =
712
804
  absolute && dragBounds?.center
713
805
  ? offsetPlanPositionByLocalCenter(
@@ -734,16 +826,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
734
826
  // Kind-owned grid hooks exchange origins and apply their own footprint offsets.
735
827
  candidatePosition: canonicalPositionFromPlan(
736
828
  planX - centerOffset[0],
737
- originalPosition[1],
829
+ floorY,
738
830
  planZ - centerOffset[2],
739
831
  ),
740
832
  candidateRotation: freeRotationRef.current,
741
833
  movingIds: [node.id as AnyNodeId],
742
834
  nodes: useScene.getState().nodes as Record<string, AnyNode>,
743
- levelId:
744
- (useViewer.getState().selection.levelId as AnyNodeId | null) ??
745
- (node.parentId as AnyNodeId | undefined) ??
746
- null,
835
+ levelId: (currentLevelId() as AnyNodeId | null) ?? null,
747
836
  gridStep: useEditor.getState().gridSnapStep,
748
837
  })
749
838
  const snappedPlanPosition = getVisualPosition(
@@ -761,15 +850,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
761
850
  ? ([planX, planZ]) => {
762
851
  const snapArgs: Parameters<NonNullable<typeof groupMoveSnapPoseConfig>>[0] = {
763
852
  node,
764
- candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ),
853
+ candidatePosition: canonicalPositionFromPlan(planX, floorY, planZ),
765
854
  candidateRotation:
766
855
  absolute && dragBounds?.center ? freeRotationRef.current : rotationRef.current,
767
856
  movingIds: [node.id as AnyNodeId],
768
857
  nodes: useScene.getState().nodes as Record<string, AnyNode>,
769
- levelId:
770
- (useViewer.getState().selection.levelId as AnyNodeId | null) ??
771
- (node.parentId as AnyNodeId | undefined) ??
772
- null,
858
+ levelId: (currentLevelId() as AnyNodeId | null) ?? null,
773
859
  }
774
860
  const snappedPosition: GroupMoveSnapResult | null = groupMoveSnapPoseConfig
775
861
  ? groupMoveSnapPoseConfig(snapArgs)
@@ -845,7 +931,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
845
931
  }
846
932
  }
847
933
 
848
- let position = canonicalPositionFromPlan(x, originalPosition[1], z)
934
+ let position = canonicalPositionFromPlan(x, floorY, z)
849
935
  if (
850
936
  !attachmentSnapped &&
851
937
  (magnetic || isGridSnapActive()) &&
@@ -885,7 +971,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
885
971
  if (!parentFrame && pointed?.sourceNodeId) {
886
972
  const rotation = toCommitRotation(rotationRef.current)
887
973
  const effectiveNode = {
888
- ...(node as Record<string, unknown>),
974
+ ...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
889
975
  position,
890
976
  rotation,
891
977
  } as AnyNode
@@ -930,15 +1016,26 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
930
1016
  // Carry connected ductwork along (preview only — committed on drop).
931
1017
  previewConnectivity(position, rotationRef.current)
932
1018
 
933
- const nextSnapKey = movementSfxStepKey({
1019
+ const soundStep = {
934
1020
  coords: [x, z],
935
1021
  gridSnapActive: isGridSnapActive() && !attachmentSnapped,
936
1022
  gridStep: useEditor.getState().gridSnapStep,
937
- })
938
- const prev = previousSnapRef.current
939
- if (prev !== nextSnapKey) {
940
- sfxEmitter.emit('sfx:grid-snap')
941
- previousSnapRef.current = nextSnapKey
1023
+ }
1024
+ if (itemSurfaceMove) {
1025
+ movementSfx.schedule(
1026
+ soundStep,
1027
+ () => !itemSurfaceMove.blocksGrid(event, cameraRef.current),
1028
+ true,
1029
+ )
1030
+ } else movementSfx.tick(soundStep, true)
1031
+ }
1032
+
1033
+ const gridDispatch = createItemSurfaceGridDispatch(onGridMove)
1034
+ const receiveGridMove = (event: GridEvent) => {
1035
+ if (itemSurfaceMove?.hosted) gridDispatch.schedule(event)
1036
+ else {
1037
+ gridDispatch.cancel()
1038
+ onGridMove(event)
942
1039
  }
943
1040
  }
944
1041
 
@@ -967,6 +1064,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
967
1064
  // finds the fresh draft already deleted and takes the orphan re-create
968
1065
  // path below, minting a hidden ghost copy and replaying the SFX.
969
1066
  if (committed) return
1067
+ gridDispatch.flush()
1068
+ movementSfx.flush()
1069
+ if (itemSurfaceMove?.rejection || (itemSurfaceMove?.hosted && !itemSurfaceMove.valid)) return
970
1070
  // Ignore a commit that fires before the cursor has moved into place —
971
1071
  // it's the stray trailing click of whatever armed this move, not a
972
1072
  // deliberate drop. Prevents preset re-arm from double-placing.
@@ -982,11 +1082,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
982
1082
 
983
1083
  if (useScene.getState().nodes[node.id]) {
984
1084
  const effectiveNode = {
985
- ...(node as Record<string, unknown>),
1085
+ ...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
986
1086
  position,
987
1087
  rotation,
988
1088
  } as AnyNode
989
1089
  const data = {
1090
+ ...(itemSurfaceMove ? { parentId: effectiveNode.parentId } : {}),
990
1091
  position,
991
1092
  rotation,
992
1093
  // The pointer cap makes the persisted host reproduce the capped
@@ -1027,10 +1128,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1027
1128
  buildPreviewNode(position, rotationRef.current),
1028
1129
  ).filter((u) => useScene.getState().nodes[u.id])
1029
1130
  : []
1131
+ const surfaceId = surfaceAttachmentId(effectiveNode)
1132
+ itemSurfaceMove?.restore()
1030
1133
  useScene.temporal.getState().resume()
1031
1134
  useScene
1032
1135
  .getState()
1033
- .updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates])
1136
+ .updateNodes([
1137
+ { id: node.id as AnyNodeId, data },
1138
+ ...surfaceAttachmentUpdates(node.id, effectiveNode.parentId, surfaceId),
1139
+ ...connectivityUpdates,
1140
+ ])
1034
1141
  // Kind-owned derived-state maintenance after a parent-frame move
1035
1142
  // (cabinet run re-flow + linked corner-run re-anchor). Runs in the
1036
1143
  // resumed window so its writes are undoable alongside the move.
@@ -1144,8 +1251,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1144
1251
  )
1145
1252
  if (nextFreeRotation === null) return
1146
1253
  sfxEmitter.emit('sfx:item-rotate')
1147
- let position = lastCursorRef.current
1254
+ const hostedPose = itemSurfaceMove?.rotate(nextFreeRotation)
1255
+ let position = hostedPose?.position ?? lastCursorRef.current
1148
1256
  if (
1257
+ !hostedPose &&
1149
1258
  hasMovedRef.current &&
1150
1259
  (useAbsoluteCursorPlacement || cursorAttached) &&
1151
1260
  dragBounds?.center
@@ -1161,25 +1270,28 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1161
1270
  previewRotationY(nextFreeRotation),
1162
1271
  )
1163
1272
  position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2])
1164
- lastCursorRef.current = position
1165
1273
  }
1166
- freeRotationRef.current = nextFreeRotation
1274
+ lastCursorRef.current = position
1275
+ freeRotationRef.current = hostedPose?.rotationY ?? nextFreeRotation
1167
1276
  rotationRef.current = freeRotationRef.current
1168
1277
  setCursorRotationY(previewRotationY(rotationRef.current))
1169
1278
  const visualPosition = getVisualPosition(position)
1170
1279
  setCursorPosition(visualPosition)
1171
1280
  applyMeshPose(position)
1172
- useLiveTransforms.getState().set(node.id, {
1173
- position,
1174
- rotation: rotationRef.current,
1175
- supportElevationCap: supportCapRef.current ?? undefined,
1176
- })
1281
+ if (!itemSurfaceMove?.hosted)
1282
+ useLiveTransforms.getState().set(node.id, {
1283
+ position,
1284
+ rotation: rotationRef.current,
1285
+ supportElevationCap: supportCapRef.current ?? undefined,
1286
+ })
1177
1287
  syncParentFramePreview(position)
1178
1288
  markMovedNodeDirty()
1179
1289
  // Rotating the fitting swings its collars — connected ducts follow.
1180
1290
  previewConnectivity(position, rotationRef.current)
1181
1291
  // Rotation changes the footprint's collision span — re-check validity.
1182
- recomputeValidity()
1292
+ if (!itemSurfaceMove?.hosted && itemSurfaceMove?.rejection && lastSurfaceEvent)
1293
+ onItemMove(lastSurfaceEvent)
1294
+ else recomputeValidity()
1183
1295
  }
1184
1296
  const onKeyUp = (e: KeyboardEvent) => {
1185
1297
  if (e.key === 'Alt') {
@@ -1190,7 +1302,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1190
1302
  window.addEventListener('keydown', onKeyDown)
1191
1303
  window.addEventListener('keyup', onKeyUp)
1192
1304
 
1193
- emitter.on('grid:move', onGridMove)
1305
+ const surfaceKinds = Array.from(nodeRegistry.entries(), ([kind]) => kind).filter(
1306
+ (kind) => !NON_PHYSICAL_HOST_KINDS.includes(kind),
1307
+ )
1308
+ for (const kind of surfaceKinds) {
1309
+ emitter.on(`${kind}:enter` as never, onItemMove)
1310
+ emitter.on(`${kind}:move` as never, onItemMove)
1311
+ emitter.on(`${kind}:leave` as never, onItemLeave)
1312
+ emitter.on(`${kind}:click` as never, commitAtCursor)
1313
+ }
1314
+ emitter.on('grid:move', receiveGridMove)
1194
1315
  emitter.on('grid:click', commitAtCursor)
1195
1316
 
1196
1317
  const onPlacementDragPointerUp = (event: PointerEvent) => {
@@ -1211,12 +1332,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1211
1332
  emitter.on('node:click', commitAtCursor)
1212
1333
 
1213
1334
  const onCancel = () => {
1335
+ movementSfx.cancel()
1336
+ gridDispatch.cancel()
1214
1337
  useLiveTransforms.getState().clear(node.id)
1215
1338
  clearConnectivityOverrides()
1216
1339
  clearParentFramePreview()
1217
1340
  if (isNew) {
1341
+ updateSurfaceNode(node.id, {}, null)
1218
1342
  useScene.getState().deleteNode(node.id as AnyNodeId)
1219
1343
  } else {
1344
+ itemSurfaceMove?.restore()
1220
1345
  applyMeshPose(originalPosition, originalRotationY)
1221
1346
  markMovedNodeDirty()
1222
1347
  }
@@ -1228,9 +1353,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1228
1353
  emitter.on('tool:cancel', onCancel)
1229
1354
 
1230
1355
  return () => {
1356
+ movementSfx.cancel()
1357
+ gridDispatch.cancel()
1231
1358
  window.removeEventListener('keydown', onKeyDown)
1232
1359
  window.removeEventListener('keyup', onKeyUp)
1233
- emitter.off('grid:move', onGridMove)
1360
+ suppressRaycastsRef.current = null
1361
+ for (const kind of surfaceKinds) {
1362
+ emitter.off(`${kind}:enter` as never, onItemMove)
1363
+ emitter.off(`${kind}:move` as never, onItemMove)
1364
+ emitter.off(`${kind}:leave` as never, onItemLeave)
1365
+ emitter.off(`${kind}:click` as never, commitAtCursor)
1366
+ }
1367
+ emitter.off('grid:move', receiveGridMove)
1234
1368
  emitter.off('grid:click', commitAtCursor)
1235
1369
  window.removeEventListener('pointerup', onPlacementDragPointerUp)
1236
1370
  emitter.off('node:click', commitAtCursor)
@@ -1242,10 +1376,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1242
1376
  // unmount / commit paths uniformly.
1243
1377
  useAlignmentGuides.getState().clear()
1244
1378
  const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
1245
- if (!(committed || isNew || finalisedBy2D)) {
1379
+ if (!committed && !finalisedBy2D && (!isNew || itemSurfaceMove)) {
1246
1380
  useLiveTransforms.getState().clear(node.id)
1247
1381
  clearConnectivityOverrides()
1248
1382
  clearParentFramePreview()
1383
+ itemSurfaceMove?.restore()
1249
1384
  applyMeshPose(originalPosition, originalRotationY)
1250
1385
  markMovedNodeDirty()
1251
1386
  }
@@ -1253,6 +1388,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1253
1388
  }
1254
1389
  }, [
1255
1390
  boxDimensions,
1391
+ resolvedFootprint,
1392
+ itemSurfaceMove,
1256
1393
  dragBounds,
1257
1394
  canonicalPositionFromPlan,
1258
1395
  parentFrame,
@@ -1320,7 +1457,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1320
1457
  <DragBoundingBox
1321
1458
  center={dragBounds?.center}
1322
1459
  centerY={dragBounds?.centerY}
1323
- color={boxDimensions ? (valid ? VALID_COLOR : INVALID_COLOR) : undefined}
1460
+ color={
1461
+ surfaceRejection
1462
+ ? INVALID_COLOR
1463
+ : boxDimensions
1464
+ ? valid
1465
+ ? VALID_COLOR
1466
+ : INVALID_COLOR
1467
+ : undefined
1468
+ }
1324
1469
  nodeId={node.id}
1325
1470
  position={cursorPosition}
1326
1471
  rotationY={cursorRotationY}
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type AnyNode,
2
3
  type AnyNodeId,
3
4
  canHostOnTop,
4
5
  GROUND_SUPPORT_ID,
@@ -10,10 +11,11 @@ import {
10
11
  useScene,
11
12
  } from '@pascal-app/core'
12
13
  import { setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer'
13
- import { type Camera, Matrix3, type Object3D, Raycaster, Vector3 } from 'three'
14
+ import { type Camera, type Object3D, Raycaster, Vector3 } from 'three'
14
15
  import { resolveTerrainGroundHit } from '../../../lib/ground-surface'
15
16
  import { scopeNodeId } from '../../../lib/interaction/scope'
16
17
  import useInteractionScope from '../../../store/use-interaction-scope'
18
+ import { surfaceWorldNormalY } from './surface-hit'
17
19
 
18
20
  const originScratch = new Vector3()
19
21
  const hitScratch = new Vector3()
@@ -23,8 +25,6 @@ const worldRayOrigin = new Vector3()
23
25
  const worldRayDirection = new Vector3()
24
26
  const nodeTopRaycaster = new Raycaster()
25
27
  setSurfaceRaycastLayers(nodeTopRaycaster.layers)
26
- const nodeTopNormal = new Vector3()
27
- const nodeTopNormalMatrix = new Matrix3()
28
28
 
29
29
  export type PointerSupportSurface = {
30
30
  /** Level-local elevation of the pointed surface — the election cap. */
@@ -194,7 +194,7 @@ export function resolvePointerSupportSurface(
194
194
  // convention — the election owns the invariant.
195
195
  const interactingNodeId = scopeNodeId(useInteractionScope.getState().scope)
196
196
  const isEligibleCandidate = (nodeId: AnyNodeId) => {
197
- let current = nodes[nodeId]
197
+ let current: AnyNode | undefined = nodes[nodeId]
198
198
  const visited = new Set<AnyNodeId>()
199
199
  while (current && !visited.has(current.id)) {
200
200
  if (current.id === interactingNodeId) return false
@@ -247,11 +247,13 @@ export function resolvePointerSupportSurface(
247
247
  ownerObject = ownerObject.parent
248
248
  }
249
249
  if (belongsToNestedNode) continue
250
- nodeTopNormal
251
- .copy(intersection.face.normal)
252
- .applyNormalMatrix(nodeTopNormalMatrix.getNormalMatrix(intersection.object.matrixWorld))
253
- .normalize()
254
- if (nodeTopNormal.y < 0.75) continue
250
+ if (
251
+ surfaceWorldNormalY(
252
+ intersection.face.normal.toArray(),
253
+ intersection.object.matrixWorld,
254
+ ) < 0.75
255
+ )
256
+ continue
255
257
  nearest = {
256
258
  distance: intersection.distance,
257
259
  nodeId,