@pascal-app/editor 1.0.0-beta.5 → 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 (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -8,14 +8,18 @@ import {
8
8
  type AnyNodeId,
9
9
  analyzePortConnectivity,
10
10
  bboxCornerAnchors,
11
+ cascadeDirty,
11
12
  collectAlignmentAnchors,
12
13
  createSceneApi,
13
- type EventSuffix,
14
14
  emitter,
15
+ findLevelAncestorId,
15
16
  footprintAABBFrom,
16
17
  type GridEvent,
18
+ type GroupMoveSnapResult,
17
19
  getFloorPlacedFootprints,
20
+ type MovableConfig,
18
21
  movingFootprintAnchors,
22
+ NON_PHYSICAL_HOST_KINDS,
19
23
  type NodeEvent,
20
24
  nodeRegistry,
21
25
  type ParentFrameSnapMatch,
@@ -23,7 +27,9 @@ import {
23
27
  resolveAlignment,
24
28
  resolveConnectivityUpdates,
25
29
  resolveFacingIndicator,
30
+ resolveFrozenFloorPlacementPatch,
26
31
  resolveSupportSlabPatch,
32
+ type SurfaceRejectReason,
27
33
  sceneRegistry,
28
34
  spatialGridManager,
29
35
  useLiveNodeOverrides,
@@ -31,16 +37,25 @@ import {
31
37
  useScene,
32
38
  } from '@pascal-app/core'
33
39
  import { useViewer } from '@pascal-app/viewer'
34
- import { useThree } from '@react-three/fiber'
40
+ import { useFrame, useThree } from '@react-three/fiber'
35
41
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
42
+ import { type Group, Vector3 } from 'three'
36
43
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
37
44
  import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
38
45
  import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
39
- import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
40
- import { movementSfxStepKey } from '../../../lib/sfx/movement-tick'
46
+ import {
47
+ offsetPlanPositionByLocalCenter,
48
+ resolvePrioritizedPlanarCursorPosition,
49
+ } from '../../../lib/planar-cursor-placement'
50
+ import { resolveAttachmentPreviewRotation } from '../../../lib/rigid-plan-svg-transform'
51
+ import { createMovementSfxTick } from '../../../lib/sfx/movement-tick'
41
52
  import { sfxEmitter } from '../../../lib/sfx-bus'
42
53
  import { resolveSnapFlags } from '../../../lib/snapping-mode'
43
-
54
+ import {
55
+ surfaceAttachmentId,
56
+ surfaceAttachmentUpdates,
57
+ updateSurfaceNode,
58
+ } from '../../../lib/surface-attachment'
44
59
  import useAlignmentGuides from '../../../store/use-alignment-guides'
45
60
  import useEditor, {
46
61
  getActiveSnappingMode,
@@ -48,7 +63,6 @@ import useEditor, {
48
63
  isGridSnapActive,
49
64
  isMagneticSnapActive,
50
65
  } from '../../../store/use-editor'
51
-
52
66
  import useFacingPose from '../../../store/use-facing-pose'
53
67
  import { swallowNextClick } from '../../editor/node-arrow-handles'
54
68
  import { CursorSphere } from '../shared/cursor-sphere'
@@ -56,7 +70,11 @@ import { DragBoundingBox } from '../shared/drag-bounding-box'
56
70
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
57
71
  import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
58
72
  import { PlacementBox } from '../shared/placement-box'
59
- import { resolvePointerSupportSurface } from '../shared/pointer-support-cap'
73
+ import {
74
+ type PointerSupportSurface,
75
+ resolvePointerSupportSurface,
76
+ } from '../shared/pointer-support-cap'
77
+ import { createItemSurfaceGridDispatch, createRegistryItemSurfaceMove } from './item-surface-move'
60
78
 
61
79
  /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
62
80
  * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
@@ -69,6 +87,15 @@ const snapToGridStep = (value: number) => {
69
87
  /** 45° steps, matching the GLB item placement rotation. */
70
88
  const ROTATION_STEP = Math.PI / 4
71
89
 
90
+ export function resolveMoveRotationStep(
91
+ freeRotation: number,
92
+ delta: number,
93
+ attachmentRotation: number | null,
94
+ ): number | null {
95
+ if (attachmentRotation !== null) return null
96
+ return freeRotation + delta
97
+ }
98
+
72
99
  /** Default magnetic radius (meters, XZ) for `movable.portSnap`. */
73
100
  const PORT_SNAP_RADIUS_M = 0.5
74
101
  const VALID_COLOR = 0x22_c5_5e
@@ -80,20 +107,6 @@ type DragBoundsOverride = {
80
107
  centerY?: number
81
108
  }
82
109
 
83
- function offsetPlanPositionByLocalCenter(
84
- position: [number, number, number],
85
- center: [number, number, number],
86
- rotationY: number,
87
- ): [number, number, number] {
88
- const cos = Math.cos(rotationY)
89
- const sin = Math.sin(rotationY)
90
- return [
91
- position[0] + center[0] * cos + center[2] * sin,
92
- position[1] + center[1],
93
- position[2] - center[0] * sin + center[2] * cos,
94
- ]
95
- }
96
-
97
110
  /**
98
111
  * Alignment anchors for the moving node. When the kind declares
99
112
  * `capabilities.dragBounds` with an off-origin `center` (a composite cabinet
@@ -191,24 +204,10 @@ const ALIGNMENT_THRESHOLD_M = 0.08
191
204
  /**
192
205
  * Generic move tool for any registry-backed kind.
193
206
  *
194
- * Imperative-only motion during drag:
195
- * - On every `grid:move` we mutate `sceneRegistry.nodes.get(id).position`
196
- * directly. The node's store data is unchanged → the renderer doesn't
197
- * re-render → R3F doesn't reapply `position={node.position}` → the
198
- * imperative mutation sticks. Movement is smooth, framerate-locked,
199
- * and React-free.
200
- *
201
- * Store update happens only on commit (single undoable action).
202
- *
203
- * Cancel imperatively snaps the mesh back to its original position and
204
- * resumes history without ever having touched the store mid-drag.
205
- *
206
- * **Commit triggers**: the tool listens for `grid:click` *and* the
207
- * common node click events (shelf / item / slab / ceiling / wall /
208
- * fence / column / roof / stair). A click on the grid plane fires
209
- * `grid:click`; a click on the moved node itself (or any other 3D
210
- * geometry the ray happens to land on) fires the corresponding node
211
- * click event. Without the node-click listeners, clicking on the
207
+ * **Commit triggers**: the tool listens for `grid:click` and the generic
208
+ * `node:click` event. A click on the grid plane fires `grid:click`; a click
209
+ * on the moved node itself (or any other 3D geometry the ray happens to land
210
+ * on) fires `node:click`. Without the node-click listener, clicking on the
212
211
  * cursor's own mesh during a move would silently drop the commit —
213
212
  * the user perceives "click did nothing" because the click hit the
214
213
  * vertical face of e.g. a shelf instead of the grid plane below it.
@@ -219,21 +218,24 @@ const ALIGNMENT_THRESHOLD_M = 0.08
219
218
  */
220
219
  type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
221
220
 
222
- const CLICK_TRIGGER_KINDS = [
223
- 'shelf',
224
- 'item',
225
- 'slab',
226
- 'ceiling',
227
- 'wall',
228
- 'fence',
229
- 'column',
230
- 'roof',
231
- 'roof-segment',
232
- 'stair',
233
- 'stair-segment',
234
- ] as const
235
-
236
221
  export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
222
+ const itemSurfaceMove = useMemo(() => createRegistryItemSurfaceMove(node), [node])
223
+ const suppressRaycastsRef = useRef<(() => void) | null>(null)
224
+ const previewGroupRef = useRef<Group>(null)
225
+ useFrame(() => {
226
+ suppressRaycastsRef.current?.()
227
+ if (!previewGroupRef.current) return
228
+ const nodes = useScene.getState().nodes
229
+ const parentId = nodes[node.id]?.parentId ?? node.parentId
230
+ const parent = parentId ? nodes[parentId as AnyNodeId] : undefined
231
+ // Building-parented kinds already preview in the tool group's frame.
232
+ const levelId =
233
+ (parentId ? findLevelAncestorId(parentId as AnyNodeId, nodes) : null) ??
234
+ (parent?.type === 'building' ? null : useViewer.getState().selection.levelId)
235
+ previewGroupRef.current.position.y = levelId
236
+ ? (sceneRegistry.nodes.get(levelId)?.position.y ?? 0)
237
+ : 0
238
+ })
237
239
  // Live camera ref — the pointer-surface cap reconstructs the cursor world
238
240
  // ray (camera → grid hit) to find which walking surface is aimed at.
239
241
  const camera = useThree((s) => s.camera)
@@ -243,6 +245,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
243
245
  // refreshed per grid move. Caps the floor-support election so a deck
244
246
  // hanging above the aimed-at floor never lifts the dragged node.
245
247
  const supportCapRef = useRef<number | null>(null)
248
+ const supportSurfaceRef = useRef<PointerSupportSurface | null>(null)
246
249
  // Kinds whose `position` lives in a host parent's local frame declare
247
250
  // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the
248
251
  // plan-frame cursor through the capability's hooks and previews via
@@ -277,7 +280,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
277
280
  return 0
278
281
  }, [node])
279
282
  const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition)
280
- const previousSnapRef = useRef<string | null>(null)
281
283
  /**
282
284
  * The latest snapped cursor position from `grid:move`. We commit at
283
285
  * THIS position regardless of which event variant fires the click —
@@ -302,12 +304,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
302
304
  // and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
303
305
  // and committed to the scene on drop.
304
306
  const rotationRef = useRef(originalRotationY)
307
+ const freeRotationRef = useRef(originalRotationY)
308
+ const attachmentRotationRef = useRef<number | null>(null)
305
309
  // Snapshot of which ducts / fittings are mated to this node's ports at
306
310
  // drag-start (duct fittings only). Drives the "connected ductwork follows"
307
311
  // behaviour: connected nodes preview through `useLiveNodeOverrides` during
308
312
  // the drag and commit alongside the moved node on drop. Null for kinds with
309
313
  // no ports, so every other movable kind is unaffected.
310
314
  const connectivityRef = useRef<PortConnectivity | null>(null)
315
+ // Node ids touched by a parent-frame kind's derived live preview, such as
316
+ // linked cabinet corner runs. This is separate from port connectivity so
317
+ // each preview channel can be cleared independently.
318
+ const parentFramePreviewIdsRef = useRef<AnyNodeId[]>([])
311
319
  // Node ids this drag has pushed live overrides onto — cleared on
312
320
  // commit / cancel / unmount so a follow-on drag starts clean.
313
321
  const overriddenIdsRef = useRef<AnyNodeId[]>([])
@@ -317,8 +325,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
317
325
  // refuse an invalid drop unless Alt forces it. The gate + footprint both come
318
326
  // from the kind's declarative `floorPlaced` capability, so opting a new kind
319
327
  // in is just `collides: true` — no change here.
320
- // Parent-frame kinds skip the world-frame floor-collision box — their
321
- // position isn't in the level frame the spatial grid indexes.
328
+ // Parent-frame kinds skip the world-frame floor-collision check — their
329
+ // position isn't in the level frame the spatial grid indexes. They may
330
+ // still provide a parent-frame collision check and use the same bounds box.
322
331
  const collides =
323
332
  !frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
324
333
  // Snapshot the scene once at drag-start — bounds depend on `node` (locked
@@ -333,6 +342,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
333
342
  | undefined) ?? null,
334
343
  [node],
335
344
  )
345
+ const parentFrameCollides = Boolean(
346
+ frameParent && parentFrame?.isValidPosition && dragBounds?.size,
347
+ )
336
348
  // Collision extents: the declared drag bounds (composite kinds — a cabinet
337
349
  // run spans its modules) win over the single-node footprint.
338
350
  const resolvedFootprint = useMemo(
@@ -343,24 +355,28 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
343
355
  [dragBounds, node],
344
356
  )
345
357
  const boxDimensions = useMemo(
346
- () => (collides ? resolvedFootprint : null),
347
- [collides, resolvedFootprint],
358
+ () => (collides || parentFrameCollides ? resolvedFootprint : null),
359
+ [collides, parentFrameCollides, resolvedFootprint],
348
360
  )
349
361
  const [valid, setValid] = useState(true)
362
+ const [surfaceRejection, setSurfaceRejection] = useState<SurfaceRejectReason | null>(null)
350
363
  const previewRotationY = useCallback(
351
364
  (rotationY = rotationRef.current) =>
352
365
  parentFrame && frameParent
353
366
  ? parentFrame.parentRotationY(frameParent, useScene.getState().nodes) + rotationY
354
- : rotationY,
355
- [parentFrame, frameParent],
367
+ : itemSurfaceMove?.hosted
368
+ ? itemSurfaceMove.planPose(lastCursorRef.current, rotationY).rotationY
369
+ : rotationY,
370
+ [parentFrame, frameParent, itemSurfaceMove],
356
371
  )
357
372
  const visualPositionFor = useCallback(
358
373
  (position: [number, number, number], rotationY = rotationRef.current) => {
359
374
  if (parentFrame && frameParent) {
360
375
  return parentFrame.localToPlan(frameParent, position, useScene.getState().nodes)
361
376
  }
377
+ if (itemSurfaceMove?.hosted) return itemSurfaceMove.planPose(position, rotationY).position
362
378
  return getFloorStackPreviewPosition({
363
- node,
379
+ node: useScene.getState().nodes[node.id] ?? node,
364
380
  position,
365
381
  rotation: (() => {
366
382
  const r = (node as { rotation?: unknown }).rotation
@@ -371,7 +387,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
371
387
  maxElevation: supportCapRef.current,
372
388
  })
373
389
  },
374
- [parentFrame, frameParent, node],
390
+ [parentFrame, frameParent, node, itemSurfaceMove],
375
391
  )
376
392
  const canonicalPositionFromPlan = useCallback(
377
393
  (planX: number, localY: number, planZ: number): [number, number, number] =>
@@ -400,6 +416,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
400
416
  // to settle a dragged run flush against a wall without forking the move tool.
401
417
  const groupMoveSnapConfig =
402
418
  nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null
419
+ const groupMoveSnapPoseConfig =
420
+ nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnapPose ?? null
421
+ const movableValidityConfig =
422
+ (nodeRegistry.get(node.type)?.capabilities?.movable as MovableConfig | undefined) ?? null
423
+ const gridSnapPositionConfig =
424
+ nodeRegistry.get(node.type)?.capabilities?.movable?.gridSnapPosition ?? null
403
425
  // Mirrors of `valid` / Alt for the event handlers inside the effect, which
404
426
  // can't read React state without stale closures.
405
427
  const validRef = useRef(true)
@@ -411,15 +433,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
411
433
 
412
434
  useEffect(() => {
413
435
  useScene.temporal.getState().pause()
414
- previousSnapRef.current = null
436
+ const movementSfx = createMovementSfxTick()
415
437
  dragAnchorRef.current = null
416
438
  hasMovedRef.current = false
417
439
  rotationRef.current = originalRotationY
440
+ freeRotationRef.current = originalRotationY
441
+ attachmentRotationRef.current = null
418
442
  altRef.current = false
419
443
  validRef.current = true
420
444
  // No pointer surface known yet — uncapped election (the node keeps its
421
445
  // persisted host / committed elevation until the first grid move).
422
446
  supportCapRef.current = null
447
+ supportSurfaceRef.current = null
423
448
  // Re-sync the box transform to the (possibly new) node. `node` changes
424
449
  // without this component remounting whenever a positioned preset re-arms a
425
450
  // fresh clone after a drop, or the user picks a different catalog tile —
@@ -427,14 +452,22 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
427
452
  // would keep the previous clone's rotation/position until the next R/T.
428
453
  setCursorRotationY(previewRotationY(originalRotationY))
429
454
  lastCursorRef.current = originalPosition
455
+ let floorY = originalPosition[1]
456
+ let centeredAfterHost = false
430
457
  let committed = false
431
458
  const isNew = isFreshPlacement
432
459
 
433
460
  const baseRotation = (node as { rotation?: unknown }).rotation
434
- const toCommitRotation = (y: number): number | [number, number, number] =>
435
- Array.isArray(baseRotation)
436
- ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
437
- : 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)
438
471
 
439
472
  const getVisualPosition = visualPositionFor
440
473
  const applyMeshPose = (position: [number, number, number], rotationY = rotationRef.current) => {
@@ -445,7 +478,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
445
478
  }
446
479
  const markMovedNodeDirty = () => {
447
480
  if (useScene.getState().nodes[node.id]) {
448
- 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)
449
483
  }
450
484
  }
451
485
 
@@ -490,17 +524,46 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
490
524
 
491
525
  const syncParentFramePreview = (position: [number, number, number]) => {
492
526
  if (!frameParent) return
493
- useLiveNodeOverrides.getState().set(node.id, {
527
+ const entries: Array<readonly [AnyNodeId, Record<string, unknown>]> = [
528
+ [node.id as AnyNodeId, { position, rotation: rotationRef.current }],
529
+ ]
530
+ const derivedEntries = parentFrame?.previewOverrides?.({
531
+ node,
532
+ parent: frameParent,
494
533
  position,
495
- rotation: rotationRef.current,
534
+ sceneApi: createSceneApi(useScene),
496
535
  })
497
- useScene.getState().markDirty(frameParent.id as AnyNodeId)
536
+ if (derivedEntries) {
537
+ for (const [id, values] of derivedEntries) {
538
+ if (id === node.id) continue
539
+ entries.push([id, values as Record<string, unknown>])
540
+ }
541
+ }
542
+
543
+ const nextIds = new Set(entries.map(([id]) => id))
544
+ for (const id of parentFramePreviewIdsRef.current) {
545
+ if (!nextIds.has(id)) useLiveNodeOverrides.getState().clear(id)
546
+ }
547
+ useLiveNodeOverrides.getState().setMany(entries)
548
+ parentFramePreviewIdsRef.current = [...nextIds]
549
+
550
+ const scene = useScene.getState()
551
+ for (const [id] of entries) {
552
+ if (scene.nodes[id]) scene.markDirty(id)
553
+ }
554
+ if (frameParent.id !== node.id) scene.markDirty(frameParent.id as AnyNodeId)
498
555
  }
499
556
 
500
557
  const clearParentFramePreview = () => {
501
558
  if (!frameParent) return
502
- useLiveNodeOverrides.getState().clear(node.id)
503
- useScene.getState().markDirty(frameParent.id as AnyNodeId)
559
+ const ids = new Set<AnyNodeId>([node.id as AnyNodeId, ...parentFramePreviewIdsRef.current])
560
+ const scene = useScene.getState()
561
+ for (const id of ids) {
562
+ useLiveNodeOverrides.getState().clear(id)
563
+ if (scene.nodes[id]) scene.markDirty(id)
564
+ }
565
+ parentFramePreviewIdsRef.current = []
566
+ scene.markDirty(frameParent.id as AnyNodeId)
504
567
  }
505
568
 
506
569
  setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
@@ -510,13 +573,36 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
510
573
  // override so the user can drop on top of an existing item on purpose. Only
511
574
  // shelves show the box, so this no-ops for every other movable kind.
512
575
  const recomputeValidity = () => {
513
- if (!boxDimensions) return
514
- if (altRef.current) {
515
- validRef.current = true
516
- setValid(true)
576
+ const rejection = itemSurfaceMove?.rejection ?? null
577
+ setSurfaceRejection(rejection)
578
+ if (rejection) {
579
+ validRef.current = false
580
+ setValid(false)
581
+ return
582
+ }
583
+ if (!boxDimensions && !movableValidityConfig) return
584
+ if (altRef.current || itemSurfaceMove?.hosted) {
585
+ const valid = !itemSurfaceMove?.hosted || itemSurfaceMove.valid
586
+ validRef.current = valid
587
+ setValid(valid)
517
588
  return
518
589
  }
519
- const levelId = useViewer.getState().selection.levelId ?? node.parentId
590
+ if (parentFrameCollides && frameParent && parentFrame?.isValidPosition) {
591
+ const candidate = {
592
+ ...(node as Record<string, unknown>),
593
+ position: lastCursorRef.current,
594
+ } as AnyNode
595
+ const validPosition = parentFrame.isValidPosition({
596
+ node: candidate,
597
+ parent: frameParent,
598
+ position: lastCursorRef.current,
599
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
600
+ })
601
+ validRef.current = validPosition
602
+ setValid(validPosition)
603
+ return
604
+ }
605
+ const levelId = currentLevelId()
520
606
  if (!levelId) {
521
607
  validRef.current = true
522
608
  setValid(true)
@@ -526,7 +612,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
526
612
  const liveRotation = previewRotationY(rotationRef.current)
527
613
  const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
528
614
  const effectiveNode = {
529
- ...(node as Record<string, unknown>),
615
+ ...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
530
616
  position: livePosition,
531
617
  rotation: Array.isArray((node as { rotation?: unknown }).rotation)
532
618
  ? [
@@ -551,15 +637,27 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
551
637
  const { valid: placeable } =
552
638
  resolvedFootprints.length > 0
553
639
  ? spatialGridManager.canPlaceOnFloorFootprints(levelId, resolvedFootprints, [node.id])
554
- : spatialGridManager.canPlaceOnFloor(
555
- levelId,
556
- getVisualPosition(livePosition),
557
- boxDimensions,
558
- [0, liveRotation, 0],
559
- [node.id],
560
- )
561
- validRef.current = placeable
562
- setValid(placeable)
640
+ : boxDimensions
641
+ ? spatialGridManager.canPlaceOnFloor(
642
+ levelId,
643
+ getVisualPosition(livePosition),
644
+ boxDimensions,
645
+ [0, liveRotation, 0],
646
+ [node.id],
647
+ )
648
+ : { valid: true }
649
+ const kindValid = movableValidityConfig?.isValidPosition
650
+ ? movableValidityConfig.isValidPosition({
651
+ node: effectiveNode,
652
+ position: livePosition,
653
+ rotation: rotationRef.current,
654
+ levelId: levelId as AnyNodeId | null,
655
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
656
+ })
657
+ : true
658
+ const positionValid = placeable && kindValid
659
+ validRef.current = positionValid
660
+ setValid(positionValid)
563
661
  }
564
662
  recomputeValidity()
565
663
 
@@ -571,10 +669,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
571
669
  // passes through the moved mesh and continues to the grid plane,
572
670
  // so `grid:move` keeps firing and the cursor tracks correctly.
573
671
  // We restore the original raycast on cleanup.
574
- const mesh = sceneRegistry.nodes.get(node.id)
575
672
  const restoreRaycasts: Array<() => void> = []
576
- if (mesh) {
577
- 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)
578
678
  const original = child.raycast
579
679
  child.raycast = () => {}
580
680
  restoreRaycasts.push(() => {
@@ -582,6 +682,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
582
682
  })
583
683
  })
584
684
  }
685
+ // Reparenting remounts the renderer, so new meshes must also let the cursor ray through.
686
+ suppressRaycastsRef.current = suppressRaycasts
687
+ suppressRaycasts()
585
688
 
586
689
  // Static alignment candidates — anchors of every OTHER alignable object
587
690
  // (items, walls, fences, slabs, ceilings, columns) ON THE SAME LEVEL,
@@ -593,7 +696,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
593
696
  const alignmentCandidates = collectAlignmentAnchors(
594
697
  useScene.getState().nodes,
595
698
  node.id,
596
- useViewer.getState().selection.levelId ?? node.parentId,
699
+ currentLevelId(),
597
700
  )
598
701
 
599
702
  // Connectivity snapshot (existing port-bearing nodes only — fresh
@@ -606,7 +709,76 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
606
709
  if (snapshot.connections.length > 0) connectivityRef.current = snapshot
607
710
  }
608
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
+
609
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
610
782
  // The pointer decides the target surface AND the cursor plan point,
611
783
  // both resolved from the true camera ray in one place. The event's
612
784
  // own hit can't be used directly: its plane rides at the ghost's
@@ -620,20 +792,99 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
620
792
  // single fixed point per pointer ray.
621
793
  const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
622
794
  supportCapRef.current = pointed?.elevation ?? null
795
+ supportSurfaceRef.current = pointed
623
796
  const rawX = pointed?.localPoint?.[0] ?? event.localPosition[0]
624
797
  const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2]
625
798
  revealFreshPlacement()
626
799
 
627
- const resolved = resolvePlanarCursorPosition({
800
+ const magnetic = isMagneticSnapActive()
801
+ const attachmentEnabled = magnetic || isGridSnapActive()
802
+ const absolute = useAbsoluteCursorPlacement || cursorAttached || centeredAfterHost
803
+ const centerOffset: [number, number, number] =
804
+ absolute && dragBounds?.center
805
+ ? offsetPlanPositionByLocalCenter(
806
+ [0, 0, 0],
807
+ dragBounds.center,
808
+ previewRotationY(freeRotationRef.current),
809
+ )
810
+ : [0, 0, 0]
811
+ let attachmentRotationY: number | null = null
812
+ const resolved = resolvePrioritizedPlanarCursorPosition({
628
813
  cursor: [rawX, rawZ],
629
814
  original: [originalPlanPosition[0], originalPlanPosition[2]],
630
815
  anchor: dragAnchorRef.current,
631
- mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
816
+ mode: absolute ? 'absolute' : 'relative',
817
+ localCenter: dragBounds?.center,
818
+ rotationY: previewRotationY(freeRotationRef.current),
632
819
  // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only.
633
- snap: snapToGridStep,
820
+ snap: gridSnapPositionConfig ? undefined : snapToGridStep,
821
+ snapPoint:
822
+ isGridSnapActive() && gridSnapPositionConfig
823
+ ? ([planX, planZ]) => {
824
+ const snappedPosition = gridSnapPositionConfig({
825
+ node,
826
+ // Kind-owned grid hooks exchange origins and apply their own footprint offsets.
827
+ candidatePosition: canonicalPositionFromPlan(
828
+ planX - centerOffset[0],
829
+ floorY,
830
+ planZ - centerOffset[2],
831
+ ),
832
+ candidateRotation: freeRotationRef.current,
833
+ movingIds: [node.id as AnyNodeId],
834
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
835
+ levelId: (currentLevelId() as AnyNodeId | null) ?? null,
836
+ gridStep: useEditor.getState().gridSnapStep,
837
+ })
838
+ const snappedPlanPosition = getVisualPosition(
839
+ snappedPosition,
840
+ freeRotationRef.current,
841
+ )
842
+ return [
843
+ snappedPlanPosition[0] + centerOffset[0],
844
+ snappedPlanPosition[2] + centerOffset[2],
845
+ ]
846
+ }
847
+ : undefined,
848
+ resolveAttachment:
849
+ attachmentEnabled && (groupMoveSnapPoseConfig || groupMoveSnapConfig)
850
+ ? ([planX, planZ]) => {
851
+ const snapArgs: Parameters<NonNullable<typeof groupMoveSnapPoseConfig>>[0] = {
852
+ node,
853
+ candidatePosition: canonicalPositionFromPlan(planX, floorY, planZ),
854
+ candidateRotation:
855
+ absolute && dragBounds?.center ? freeRotationRef.current : rotationRef.current,
856
+ movingIds: [node.id as AnyNodeId],
857
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
858
+ levelId: (currentLevelId() as AnyNodeId | null) ?? null,
859
+ }
860
+ const snappedPosition: GroupMoveSnapResult | null = groupMoveSnapPoseConfig
861
+ ? groupMoveSnapPoseConfig(snapArgs)
862
+ : (() => {
863
+ const position = groupMoveSnapConfig?.(snapArgs)
864
+ return position ? { position } : null
865
+ })()
866
+ if (!snappedPosition) return null
867
+ attachmentRotationY = snappedPosition.rotation ?? null
868
+ const snappedPlanPosition = getVisualPosition(
869
+ snappedPosition.position,
870
+ snappedPosition.rotation ?? rotationRef.current,
871
+ )
872
+ return [snappedPlanPosition[0], snappedPlanPosition[2]]
873
+ }
874
+ : undefined,
634
875
  })
635
876
  dragAnchorRef.current = resolved.anchor
636
877
  let [x, z] = resolved.point
878
+ const attachmentSnapped = resolved.attachmentSnapped
879
+ attachmentRotationRef.current = attachmentSnapped ? attachmentRotationY : null
880
+ const nextRotationY = resolveAttachmentPreviewRotation(
881
+ freeRotationRef.current,
882
+ attachmentRotationRef.current,
883
+ )
884
+ if (nextRotationY !== rotationRef.current) {
885
+ rotationRef.current = nextRotationY
886
+ setCursorRotationY(previewRotationY(nextRotationY))
887
+ }
637
888
 
638
889
  // Figma-style alignment snap layered on top of grid snap: when the
639
890
  // moving item's edge lines up (on X or Z) with another item's edge,
@@ -642,8 +893,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
642
893
  // point. Alignment "lines" are DISPLAYED in every mode except Off
643
894
  // (isAlignmentGuideActive); the magnetic pull toward them applies only in
644
895
  // 'lines' mode (magnetic). Alt is force-place, not a snap bypass.
645
- const magnetic = isMagneticSnapActive()
646
- if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
896
+ if (!attachmentSnapped && isAlignmentGuideActive() && alignmentCandidates.length > 0) {
647
897
  const result = resolveAlignment({
648
898
  moving: movingDragBoundsAnchors(
649
899
  node,
@@ -664,25 +914,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
664
914
  useAlignmentGuides.getState().clear()
665
915
  }
666
916
 
667
- // Kind-owned attachment snap (cabinet → wall): an attach behavior like
668
- // door/window wall placement, not an alignment guide — active in every
669
- // snapping mode except Off.
670
- if ((magnetic || isGridSnapActive()) && groupMoveSnapConfig) {
671
- const snappedPosition = groupMoveSnapConfig({
672
- node,
673
- candidatePosition: canonicalPositionFromPlan(x, originalPosition[1], z),
674
- movingIds: [node.id as AnyNodeId],
675
- nodes: useScene.getState().nodes as Record<string, AnyNode>,
676
- levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? null,
677
- })
678
- if (snappedPosition) {
679
- const snappedPlanPosition = getVisualPosition(snappedPosition)
680
- x = snappedPlanPosition[0]
681
- z = snappedPlanPosition[2]
682
- useAlignmentGuides.getState().clear()
683
- }
684
- }
685
-
686
917
  // Magnetic port snap (duct terminals): mate a collar onto a nearby
687
918
  // duct run end. Takes precedence over grid / alignment snap; Alt
688
919
  // bypasses. Only kinds that opted in via `movable.portSnap`.
@@ -700,8 +931,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
700
931
  }
701
932
  }
702
933
 
703
- let position = canonicalPositionFromPlan(x, originalPosition[1], z)
704
- if ((magnetic || isGridSnapActive()) && parentFrame?.magneticSnap && frameParent) {
934
+ let position = canonicalPositionFromPlan(x, floorY, z)
935
+ if (
936
+ !attachmentSnapped &&
937
+ (magnetic || isGridSnapActive()) &&
938
+ parentFrame?.magneticSnap &&
939
+ frameParent
940
+ ) {
705
941
  const preSnapPosition = position
706
942
  const snappedPosition = parentFrame.magneticSnap(
707
943
  node,
@@ -732,6 +968,24 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
732
968
  if (guides.length > 0) useAlignmentGuides.getState().set(guides)
733
969
  }
734
970
  }
971
+ if (!parentFrame && pointed?.sourceNodeId) {
972
+ const rotation = toCommitRotation(rotationRef.current)
973
+ const effectiveNode = {
974
+ ...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
975
+ position,
976
+ rotation,
977
+ } as AnyNode
978
+ position = resolveFrozenFloorPlacementPatch(
979
+ effectiveNode,
980
+ { ...useScene.getState().nodes, [node.id]: effectiveNode },
981
+ {
982
+ position,
983
+ rotation,
984
+ elevation: pointed.elevation,
985
+ preferredSlabId: pointed.supportSlabId,
986
+ },
987
+ ).position
988
+ }
735
989
  const visualPosition = getVisualPosition(position)
736
990
  hasMovedRef.current = true
737
991
  setCursorPosition(visualPosition)
@@ -762,15 +1016,26 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
762
1016
  // Carry connected ductwork along (preview only — committed on drop).
763
1017
  previewConnectivity(position, rotationRef.current)
764
1018
 
765
- const nextSnapKey = movementSfxStepKey({
1019
+ const soundStep = {
766
1020
  coords: [x, z],
767
- gridSnapActive: isGridSnapActive(),
1021
+ gridSnapActive: isGridSnapActive() && !attachmentSnapped,
768
1022
  gridStep: useEditor.getState().gridSnapStep,
769
- })
770
- const prev = previousSnapRef.current
771
- if (prev !== nextSnapKey) {
772
- sfxEmitter.emit('sfx:grid-snap')
773
- 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)
774
1039
  }
775
1040
  }
776
1041
 
@@ -790,8 +1055,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
790
1055
  * AND scene updated) — never the original.
791
1056
  */
792
1057
  const commitAtCursor = (event: ClickTriggerEvent) => {
793
- // One physical click can reach here twice: node clicks (`slab:click`,
794
- // `item:click`, …) are synthesized on *pointerup* (`use-node-events`),
1058
+ // One physical click can reach here twice: `node:click` is synthesized
1059
+ // on *pointerup* (`use-node-events`),
795
1060
  // while `grid:click` rides the browser's native *click* event from a
796
1061
  // canvas DOM listener (`use-grid-events`) that deliberately ignores
797
1062
  // stopPropagation — and this effect stays subscribed until React
@@ -799,6 +1064,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
799
1064
  // finds the fresh draft already deleted and takes the orphan re-create
800
1065
  // path below, minting a hidden ghost copy and replaying the SFX.
801
1066
  if (committed) return
1067
+ gridDispatch.flush()
1068
+ movementSfx.flush()
1069
+ if (itemSurfaceMove?.rejection || (itemSurfaceMove?.hosted && !itemSurfaceMove.valid)) return
802
1070
  // Ignore a commit that fires before the cursor has moved into place —
803
1071
  // it's the stray trailing click of whatever armed this move, not a
804
1072
  // deliberate drop. Prevents preset re-arm from double-placing.
@@ -814,11 +1082,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
814
1082
 
815
1083
  if (useScene.getState().nodes[node.id]) {
816
1084
  const effectiveNode = {
817
- ...(node as Record<string, unknown>),
1085
+ ...((useScene.getState().nodes[node.id] ?? node) as Record<string, unknown>),
818
1086
  position,
819
1087
  rotation,
820
1088
  } as AnyNode
821
1089
  const data = {
1090
+ ...(itemSurfaceMove ? { parentId: effectiveNode.parentId } : {}),
822
1091
  position,
823
1092
  rotation,
824
1093
  // The pointer cap makes the persisted host reproduce the capped
@@ -830,7 +1099,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
830
1099
  ...useScene.getState().nodes,
831
1100
  [node.id]: effectiveNode,
832
1101
  },
833
- { maxElevation: supportCapRef.current },
1102
+ {
1103
+ maxElevation: supportCapRef.current,
1104
+ preferredSlabId: supportSurfaceRef.current?.supportSlabId,
1105
+ pinSupport: supportSurfaceRef.current?.sourceNodeId != null,
1106
+ },
834
1107
  ),
835
1108
  ...(isNew
836
1109
  ? {
@@ -855,10 +1128,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
855
1128
  buildPreviewNode(position, rotationRef.current),
856
1129
  ).filter((u) => useScene.getState().nodes[u.id])
857
1130
  : []
1131
+ const surfaceId = surfaceAttachmentId(effectiveNode)
1132
+ itemSurfaceMove?.restore()
858
1133
  useScene.temporal.getState().resume()
859
1134
  useScene
860
1135
  .getState()
861
- .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
+ ])
862
1141
  // Kind-owned derived-state maintenance after a parent-frame move
863
1142
  // (cabinet run re-flow + linked corner-run re-anchor). Runs in the
864
1143
  // resumed window so its writes are undoable alongside the move.
@@ -902,7 +1181,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
902
1181
  ...useScene.getState().nodes,
903
1182
  [reparsed.id]: reparsed,
904
1183
  },
905
- { maxElevation: supportCapRef.current },
1184
+ {
1185
+ maxElevation: supportCapRef.current,
1186
+ preferredSlabId: supportSurfaceRef.current?.supportSlabId,
1187
+ pinSupport: supportSurfaceRef.current?.sourceNodeId != null,
1188
+ },
906
1189
  ),
907
1190
  }) as AnyNode
908
1191
  useScene.temporal.getState().resume()
@@ -961,24 +1244,54 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
961
1244
  else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
962
1245
  else return
963
1246
  e.preventDefault()
1247
+ const nextFreeRotation = resolveMoveRotationStep(
1248
+ freeRotationRef.current,
1249
+ delta,
1250
+ attachmentRotationRef.current,
1251
+ )
1252
+ if (nextFreeRotation === null) return
964
1253
  sfxEmitter.emit('sfx:item-rotate')
965
- rotationRef.current += delta
1254
+ const hostedPose = itemSurfaceMove?.rotate(nextFreeRotation)
1255
+ let position = hostedPose?.position ?? lastCursorRef.current
1256
+ if (
1257
+ !hostedPose &&
1258
+ hasMovedRef.current &&
1259
+ (useAbsoluteCursorPlacement || cursorAttached) &&
1260
+ dragBounds?.center
1261
+ ) {
1262
+ const planCenter = offsetPlanPositionByLocalCenter(
1263
+ getVisualPosition(position),
1264
+ dragBounds.center,
1265
+ previewRotationY(rotationRef.current),
1266
+ )
1267
+ const planOrigin = offsetPlanPositionByLocalCenter(
1268
+ planCenter,
1269
+ [-dragBounds.center[0], 0, -dragBounds.center[2]],
1270
+ previewRotationY(nextFreeRotation),
1271
+ )
1272
+ position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2])
1273
+ }
1274
+ lastCursorRef.current = position
1275
+ freeRotationRef.current = hostedPose?.rotationY ?? nextFreeRotation
1276
+ rotationRef.current = freeRotationRef.current
966
1277
  setCursorRotationY(previewRotationY(rotationRef.current))
967
- const position = lastCursorRef.current
968
1278
  const visualPosition = getVisualPosition(position)
969
1279
  setCursorPosition(visualPosition)
970
1280
  applyMeshPose(position)
971
- useLiveTransforms.getState().set(node.id, {
972
- position,
973
- rotation: rotationRef.current,
974
- supportElevationCap: supportCapRef.current ?? undefined,
975
- })
1281
+ if (!itemSurfaceMove?.hosted)
1282
+ useLiveTransforms.getState().set(node.id, {
1283
+ position,
1284
+ rotation: rotationRef.current,
1285
+ supportElevationCap: supportCapRef.current ?? undefined,
1286
+ })
976
1287
  syncParentFramePreview(position)
977
1288
  markMovedNodeDirty()
978
1289
  // Rotating the fitting swings its collars — connected ducts follow.
979
1290
  previewConnectivity(position, rotationRef.current)
980
1291
  // Rotation changes the footprint's collision span — re-check validity.
981
- recomputeValidity()
1292
+ if (!itemSurfaceMove?.hosted && itemSurfaceMove?.rejection && lastSurfaceEvent)
1293
+ onItemMove(lastSurfaceEvent)
1294
+ else recomputeValidity()
982
1295
  }
983
1296
  const onKeyUp = (e: KeyboardEvent) => {
984
1297
  if (e.key === 'Alt') {
@@ -989,7 +1302,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
989
1302
  window.addEventListener('keydown', onKeyDown)
990
1303
  window.addEventListener('keyup', onKeyUp)
991
1304
 
992
- 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)
993
1315
  emitter.on('grid:click', commitAtCursor)
994
1316
 
995
1317
  const onPlacementDragPointerUp = (event: PointerEvent) => {
@@ -1007,23 +1329,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1007
1329
  }
1008
1330
  window.addEventListener('pointerup', onPlacementDragPointerUp)
1009
1331
 
1010
- // Listen on every common kind's click event too. mitt's typing keeps
1011
- // `${kind}:click` as a fixed union so the cast is safe at runtime —
1012
- // we're just routing them through the shared commit path.
1013
- type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
1014
- type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
1015
- for (const kind of CLICK_TRIGGER_KINDS) {
1016
- const key = `${kind}:click` as ClickKey
1017
- emitter.on(key, commitAtCursor as never)
1018
- }
1332
+ emitter.on('node:click', commitAtCursor)
1019
1333
 
1020
1334
  const onCancel = () => {
1335
+ movementSfx.cancel()
1336
+ gridDispatch.cancel()
1021
1337
  useLiveTransforms.getState().clear(node.id)
1022
1338
  clearConnectivityOverrides()
1023
1339
  clearParentFramePreview()
1024
1340
  if (isNew) {
1341
+ updateSurfaceNode(node.id, {}, null)
1025
1342
  useScene.getState().deleteNode(node.id as AnyNodeId)
1026
1343
  } else {
1344
+ itemSurfaceMove?.restore()
1027
1345
  applyMeshPose(originalPosition, originalRotationY)
1028
1346
  markMovedNodeDirty()
1029
1347
  }
@@ -1035,15 +1353,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1035
1353
  emitter.on('tool:cancel', onCancel)
1036
1354
 
1037
1355
  return () => {
1356
+ movementSfx.cancel()
1357
+ gridDispatch.cancel()
1038
1358
  window.removeEventListener('keydown', onKeyDown)
1039
1359
  window.removeEventListener('keyup', onKeyUp)
1040
- 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)
1041
1368
  emitter.off('grid:click', commitAtCursor)
1042
1369
  window.removeEventListener('pointerup', onPlacementDragPointerUp)
1043
- for (const kind of CLICK_TRIGGER_KINDS) {
1044
- const key = `${kind}:click` as ClickKey
1045
- emitter.off(key, commitAtCursor as never)
1046
- }
1370
+ emitter.off('node:click', commitAtCursor)
1047
1371
  emitter.off('tool:cancel', onCancel)
1048
1372
  // Restore the moved meshes' raycast so they're hoverable / selectable
1049
1373
  // again after the drag ends.
@@ -1052,10 +1376,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1052
1376
  // unmount / commit paths uniformly.
1053
1377
  useAlignmentGuides.getState().clear()
1054
1378
  const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
1055
- if (!(committed || isNew || finalisedBy2D)) {
1379
+ if (!committed && !finalisedBy2D && (!isNew || itemSurfaceMove)) {
1056
1380
  useLiveTransforms.getState().clear(node.id)
1057
1381
  clearConnectivityOverrides()
1058
1382
  clearParentFramePreview()
1383
+ itemSurfaceMove?.restore()
1059
1384
  applyMeshPose(originalPosition, originalRotationY)
1060
1385
  markMovedNodeDirty()
1061
1386
  }
@@ -1063,13 +1388,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1063
1388
  }
1064
1389
  }, [
1065
1390
  boxDimensions,
1391
+ resolvedFootprint,
1392
+ itemSurfaceMove,
1066
1393
  dragBounds,
1067
1394
  canonicalPositionFromPlan,
1068
1395
  parentFrame,
1069
1396
  frameParent,
1397
+ parentFrameCollides,
1070
1398
  cursorAttached,
1071
1399
  portSnapConfig,
1072
1400
  groupMoveSnapConfig,
1401
+ groupMoveSnapPoseConfig,
1402
+ movableValidityConfig,
1403
+ gridSnapPositionConfig,
1073
1404
  exitMoveMode,
1074
1405
  isFreshPlacement,
1075
1406
  node,
@@ -1104,12 +1435,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1104
1435
 
1105
1436
  if (boxDimensions && !dragBounds?.center) {
1106
1437
  return (
1107
- <PlacementBox
1108
- dimensions={boxDimensions}
1109
- position={cursorPosition}
1110
- rotationY={cursorRotationY}
1111
- valid={valid}
1112
- />
1438
+ <group ref={previewGroupRef}>
1439
+ <PlacementBox
1440
+ dimensions={boxDimensions}
1441
+ position={cursorPosition}
1442
+ rotationY={cursorRotationY}
1443
+ valid={valid}
1444
+ />
1445
+ </group>
1113
1446
  )
1114
1447
  }
1115
1448
 
@@ -1119,17 +1452,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1119
1452
  : cursorPosition
1120
1453
 
1121
1454
  return (
1122
- <>
1455
+ <group ref={previewGroupRef}>
1123
1456
  <CursorSphere color="#a78bfa" height={2.5} position={dragCenterPosition} />
1124
1457
  <DragBoundingBox
1125
1458
  center={dragBounds?.center}
1126
1459
  centerY={dragBounds?.centerY}
1127
- 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
+ }
1128
1469
  nodeId={node.id}
1129
1470
  position={cursorPosition}
1130
1471
  rotationY={cursorRotationY}
1131
1472
  size={dragBounds?.size}
1132
1473
  />
1133
- </>
1474
+ </group>
1134
1475
  )
1135
1476
  }