@pascal-app/editor 1.0.0-beta.5 → 1.0.0

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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -10,11 +10,13 @@ import {
10
10
  bboxCornerAnchors,
11
11
  collectAlignmentAnchors,
12
12
  createSceneApi,
13
- type EventSuffix,
14
13
  emitter,
14
+ findLevelAncestorId,
15
15
  footprintAABBFrom,
16
16
  type GridEvent,
17
+ type GroupMoveSnapResult,
17
18
  getFloorPlacedFootprints,
19
+ type MovableConfig,
18
20
  movingFootprintAnchors,
19
21
  type NodeEvent,
20
22
  nodeRegistry,
@@ -23,6 +25,7 @@ import {
23
25
  resolveAlignment,
24
26
  resolveConnectivityUpdates,
25
27
  resolveFacingIndicator,
28
+ resolveFrozenFloorPlacementPatch,
26
29
  resolveSupportSlabPatch,
27
30
  sceneRegistry,
28
31
  spatialGridManager,
@@ -31,12 +34,17 @@ import {
31
34
  useScene,
32
35
  } from '@pascal-app/core'
33
36
  import { useViewer } from '@pascal-app/viewer'
34
- import { useThree } from '@react-three/fiber'
37
+ import { useFrame, useThree } from '@react-three/fiber'
35
38
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
39
+ import type { Group } from 'three'
36
40
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
37
41
  import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
38
42
  import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
39
- import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
43
+ import {
44
+ offsetPlanPositionByLocalCenter,
45
+ resolvePrioritizedPlanarCursorPosition,
46
+ } from '../../../lib/planar-cursor-placement'
47
+ import { resolveAttachmentPreviewRotation } from '../../../lib/rigid-plan-svg-transform'
40
48
  import { movementSfxStepKey } from '../../../lib/sfx/movement-tick'
41
49
  import { sfxEmitter } from '../../../lib/sfx-bus'
42
50
  import { resolveSnapFlags } from '../../../lib/snapping-mode'
@@ -56,7 +64,10 @@ import { DragBoundingBox } from '../shared/drag-bounding-box'
56
64
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
57
65
  import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
58
66
  import { PlacementBox } from '../shared/placement-box'
59
- import { resolvePointerSupportSurface } from '../shared/pointer-support-cap'
67
+ import {
68
+ type PointerSupportSurface,
69
+ resolvePointerSupportSurface,
70
+ } from '../shared/pointer-support-cap'
60
71
 
61
72
  /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
62
73
  * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
@@ -69,6 +80,15 @@ const snapToGridStep = (value: number) => {
69
80
  /** 45° steps, matching the GLB item placement rotation. */
70
81
  const ROTATION_STEP = Math.PI / 4
71
82
 
83
+ export function resolveMoveRotationStep(
84
+ freeRotation: number,
85
+ delta: number,
86
+ attachmentRotation: number | null,
87
+ ): number | null {
88
+ if (attachmentRotation !== null) return null
89
+ return freeRotation + delta
90
+ }
91
+
72
92
  /** Default magnetic radius (meters, XZ) for `movable.portSnap`. */
73
93
  const PORT_SNAP_RADIUS_M = 0.5
74
94
  const VALID_COLOR = 0x22_c5_5e
@@ -80,20 +100,6 @@ type DragBoundsOverride = {
80
100
  centerY?: number
81
101
  }
82
102
 
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
103
  /**
98
104
  * Alignment anchors for the moving node. When the kind declares
99
105
  * `capabilities.dragBounds` with an off-origin `center` (a composite cabinet
@@ -203,12 +209,10 @@ const ALIGNMENT_THRESHOLD_M = 0.08
203
209
  * Cancel imperatively snaps the mesh back to its original position and
204
210
  * resumes history without ever having touched the store mid-drag.
205
211
  *
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
212
+ * **Commit triggers**: the tool listens for `grid:click` and the generic
213
+ * `node:click` event. A click on the grid plane fires `grid:click`; a click
214
+ * on the moved node itself (or any other 3D geometry the ray happens to land
215
+ * on) fires `node:click`. Without the node-click listener, clicking on the
212
216
  * cursor's own mesh during a move would silently drop the commit —
213
217
  * the user perceives "click did nothing" because the click hit the
214
218
  * vertical face of e.g. a shelf instead of the grid plane below it.
@@ -219,21 +223,21 @@ const ALIGNMENT_THRESHOLD_M = 0.08
219
223
  */
220
224
  type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
221
225
 
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
226
  export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
227
+ const previewGroupRef = useRef<Group>(null)
228
+ useFrame(() => {
229
+ if (!previewGroupRef.current) return
230
+ const nodes = useScene.getState().nodes
231
+ const parentId = nodes[node.id]?.parentId ?? node.parentId
232
+ const parent = parentId ? nodes[parentId as AnyNodeId] : undefined
233
+ // Building-parented kinds already preview in the tool group's frame.
234
+ const levelId =
235
+ (parentId ? findLevelAncestorId(parentId as AnyNodeId, nodes) : null) ??
236
+ (parent?.type === 'building' ? null : useViewer.getState().selection.levelId)
237
+ previewGroupRef.current.position.y = levelId
238
+ ? (sceneRegistry.nodes.get(levelId)?.position.y ?? 0)
239
+ : 0
240
+ })
237
241
  // Live camera ref — the pointer-surface cap reconstructs the cursor world
238
242
  // ray (camera → grid hit) to find which walking surface is aimed at.
239
243
  const camera = useThree((s) => s.camera)
@@ -243,6 +247,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
243
247
  // refreshed per grid move. Caps the floor-support election so a deck
244
248
  // hanging above the aimed-at floor never lifts the dragged node.
245
249
  const supportCapRef = useRef<number | null>(null)
250
+ const supportSurfaceRef = useRef<PointerSupportSurface | null>(null)
246
251
  // Kinds whose `position` lives in a host parent's local frame declare
247
252
  // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the
248
253
  // plan-frame cursor through the capability's hooks and previews via
@@ -302,12 +307,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
302
307
  // and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
303
308
  // and committed to the scene on drop.
304
309
  const rotationRef = useRef(originalRotationY)
310
+ const freeRotationRef = useRef(originalRotationY)
311
+ const attachmentRotationRef = useRef<number | null>(null)
305
312
  // Snapshot of which ducts / fittings are mated to this node's ports at
306
313
  // drag-start (duct fittings only). Drives the "connected ductwork follows"
307
314
  // behaviour: connected nodes preview through `useLiveNodeOverrides` during
308
315
  // the drag and commit alongside the moved node on drop. Null for kinds with
309
316
  // no ports, so every other movable kind is unaffected.
310
317
  const connectivityRef = useRef<PortConnectivity | null>(null)
318
+ // Node ids touched by a parent-frame kind's derived live preview, such as
319
+ // linked cabinet corner runs. This is separate from port connectivity so
320
+ // each preview channel can be cleared independently.
321
+ const parentFramePreviewIdsRef = useRef<AnyNodeId[]>([])
311
322
  // Node ids this drag has pushed live overrides onto — cleared on
312
323
  // commit / cancel / unmount so a follow-on drag starts clean.
313
324
  const overriddenIdsRef = useRef<AnyNodeId[]>([])
@@ -317,8 +328,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
317
328
  // refuse an invalid drop unless Alt forces it. The gate + footprint both come
318
329
  // from the kind's declarative `floorPlaced` capability, so opting a new kind
319
330
  // 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.
331
+ // Parent-frame kinds skip the world-frame floor-collision check — their
332
+ // position isn't in the level frame the spatial grid indexes. They may
333
+ // still provide a parent-frame collision check and use the same bounds box.
322
334
  const collides =
323
335
  !frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
324
336
  // Snapshot the scene once at drag-start — bounds depend on `node` (locked
@@ -333,6 +345,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
333
345
  | undefined) ?? null,
334
346
  [node],
335
347
  )
348
+ const parentFrameCollides = Boolean(
349
+ frameParent && parentFrame?.isValidPosition && dragBounds?.size,
350
+ )
336
351
  // Collision extents: the declared drag bounds (composite kinds — a cabinet
337
352
  // run spans its modules) win over the single-node footprint.
338
353
  const resolvedFootprint = useMemo(
@@ -343,8 +358,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
343
358
  [dragBounds, node],
344
359
  )
345
360
  const boxDimensions = useMemo(
346
- () => (collides ? resolvedFootprint : null),
347
- [collides, resolvedFootprint],
361
+ () => (collides || parentFrameCollides ? resolvedFootprint : null),
362
+ [collides, parentFrameCollides, resolvedFootprint],
348
363
  )
349
364
  const [valid, setValid] = useState(true)
350
365
  const previewRotationY = useCallback(
@@ -400,6 +415,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
400
415
  // to settle a dragged run flush against a wall without forking the move tool.
401
416
  const groupMoveSnapConfig =
402
417
  nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null
418
+ const groupMoveSnapPoseConfig =
419
+ nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnapPose ?? null
420
+ const movableValidityConfig =
421
+ (nodeRegistry.get(node.type)?.capabilities?.movable as MovableConfig | undefined) ?? null
422
+ const gridSnapPositionConfig =
423
+ nodeRegistry.get(node.type)?.capabilities?.movable?.gridSnapPosition ?? null
403
424
  // Mirrors of `valid` / Alt for the event handlers inside the effect, which
404
425
  // can't read React state without stale closures.
405
426
  const validRef = useRef(true)
@@ -415,11 +436,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
415
436
  dragAnchorRef.current = null
416
437
  hasMovedRef.current = false
417
438
  rotationRef.current = originalRotationY
439
+ freeRotationRef.current = originalRotationY
440
+ attachmentRotationRef.current = null
418
441
  altRef.current = false
419
442
  validRef.current = true
420
443
  // No pointer surface known yet — uncapped election (the node keeps its
421
444
  // persisted host / committed elevation until the first grid move).
422
445
  supportCapRef.current = null
446
+ supportSurfaceRef.current = null
423
447
  // Re-sync the box transform to the (possibly new) node. `node` changes
424
448
  // without this component remounting whenever a positioned preset re-arms a
425
449
  // fresh clone after a drop, or the user picks a different catalog tile —
@@ -490,17 +514,46 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
490
514
 
491
515
  const syncParentFramePreview = (position: [number, number, number]) => {
492
516
  if (!frameParent) return
493
- useLiveNodeOverrides.getState().set(node.id, {
517
+ const entries: Array<readonly [AnyNodeId, Record<string, unknown>]> = [
518
+ [node.id as AnyNodeId, { position, rotation: rotationRef.current }],
519
+ ]
520
+ const derivedEntries = parentFrame?.previewOverrides?.({
521
+ node,
522
+ parent: frameParent,
494
523
  position,
495
- rotation: rotationRef.current,
524
+ sceneApi: createSceneApi(useScene),
496
525
  })
497
- useScene.getState().markDirty(frameParent.id as AnyNodeId)
526
+ if (derivedEntries) {
527
+ for (const [id, values] of derivedEntries) {
528
+ if (id === node.id) continue
529
+ entries.push([id, values as Record<string, unknown>])
530
+ }
531
+ }
532
+
533
+ const nextIds = new Set(entries.map(([id]) => id))
534
+ for (const id of parentFramePreviewIdsRef.current) {
535
+ if (!nextIds.has(id)) useLiveNodeOverrides.getState().clear(id)
536
+ }
537
+ useLiveNodeOverrides.getState().setMany(entries)
538
+ parentFramePreviewIdsRef.current = [...nextIds]
539
+
540
+ const scene = useScene.getState()
541
+ for (const [id] of entries) {
542
+ if (scene.nodes[id]) scene.markDirty(id)
543
+ }
544
+ if (frameParent.id !== node.id) scene.markDirty(frameParent.id as AnyNodeId)
498
545
  }
499
546
 
500
547
  const clearParentFramePreview = () => {
501
548
  if (!frameParent) return
502
- useLiveNodeOverrides.getState().clear(node.id)
503
- useScene.getState().markDirty(frameParent.id as AnyNodeId)
549
+ const ids = new Set<AnyNodeId>([node.id as AnyNodeId, ...parentFramePreviewIdsRef.current])
550
+ const scene = useScene.getState()
551
+ for (const id of ids) {
552
+ useLiveNodeOverrides.getState().clear(id)
553
+ if (scene.nodes[id]) scene.markDirty(id)
554
+ }
555
+ parentFramePreviewIdsRef.current = []
556
+ scene.markDirty(frameParent.id as AnyNodeId)
504
557
  }
505
558
 
506
559
  setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
@@ -510,12 +563,27 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
510
563
  // override so the user can drop on top of an existing item on purpose. Only
511
564
  // shelves show the box, so this no-ops for every other movable kind.
512
565
  const recomputeValidity = () => {
513
- if (!boxDimensions) return
566
+ if (!boxDimensions && !movableValidityConfig) return
514
567
  if (altRef.current) {
515
568
  validRef.current = true
516
569
  setValid(true)
517
570
  return
518
571
  }
572
+ if (parentFrameCollides && frameParent && parentFrame?.isValidPosition) {
573
+ const candidate = {
574
+ ...(node as Record<string, unknown>),
575
+ position: lastCursorRef.current,
576
+ } as AnyNode
577
+ const validPosition = parentFrame.isValidPosition({
578
+ node: candidate,
579
+ parent: frameParent,
580
+ position: lastCursorRef.current,
581
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
582
+ })
583
+ validRef.current = validPosition
584
+ setValid(validPosition)
585
+ return
586
+ }
519
587
  const levelId = useViewer.getState().selection.levelId ?? node.parentId
520
588
  if (!levelId) {
521
589
  validRef.current = true
@@ -551,15 +619,27 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
551
619
  const { valid: placeable } =
552
620
  resolvedFootprints.length > 0
553
621
  ? 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)
622
+ : boxDimensions
623
+ ? spatialGridManager.canPlaceOnFloor(
624
+ levelId,
625
+ getVisualPosition(livePosition),
626
+ boxDimensions,
627
+ [0, liveRotation, 0],
628
+ [node.id],
629
+ )
630
+ : { valid: true }
631
+ const kindValid = movableValidityConfig?.isValidPosition
632
+ ? movableValidityConfig.isValidPosition({
633
+ node: effectiveNode,
634
+ position: livePosition,
635
+ rotation: rotationRef.current,
636
+ levelId: levelId as AnyNodeId | null,
637
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
638
+ })
639
+ : true
640
+ const positionValid = placeable && kindValid
641
+ validRef.current = positionValid
642
+ setValid(positionValid)
563
643
  }
564
644
  recomputeValidity()
565
645
 
@@ -620,20 +700,105 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
620
700
  // single fixed point per pointer ray.
621
701
  const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
622
702
  supportCapRef.current = pointed?.elevation ?? null
703
+ supportSurfaceRef.current = pointed
623
704
  const rawX = pointed?.localPoint?.[0] ?? event.localPosition[0]
624
705
  const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2]
625
706
  revealFreshPlacement()
626
707
 
627
- const resolved = resolvePlanarCursorPosition({
708
+ const magnetic = isMagneticSnapActive()
709
+ const attachmentEnabled = magnetic || isGridSnapActive()
710
+ const absolute = useAbsoluteCursorPlacement || cursorAttached
711
+ const centerOffset: [number, number, number] =
712
+ absolute && dragBounds?.center
713
+ ? offsetPlanPositionByLocalCenter(
714
+ [0, 0, 0],
715
+ dragBounds.center,
716
+ previewRotationY(freeRotationRef.current),
717
+ )
718
+ : [0, 0, 0]
719
+ let attachmentRotationY: number | null = null
720
+ const resolved = resolvePrioritizedPlanarCursorPosition({
628
721
  cursor: [rawX, rawZ],
629
722
  original: [originalPlanPosition[0], originalPlanPosition[2]],
630
723
  anchor: dragAnchorRef.current,
631
- mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
724
+ mode: absolute ? 'absolute' : 'relative',
725
+ localCenter: dragBounds?.center,
726
+ rotationY: previewRotationY(freeRotationRef.current),
632
727
  // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only.
633
- snap: snapToGridStep,
728
+ snap: gridSnapPositionConfig ? undefined : snapToGridStep,
729
+ snapPoint:
730
+ isGridSnapActive() && gridSnapPositionConfig
731
+ ? ([planX, planZ]) => {
732
+ const snappedPosition = gridSnapPositionConfig({
733
+ node,
734
+ // Kind-owned grid hooks exchange origins and apply their own footprint offsets.
735
+ candidatePosition: canonicalPositionFromPlan(
736
+ planX - centerOffset[0],
737
+ originalPosition[1],
738
+ planZ - centerOffset[2],
739
+ ),
740
+ candidateRotation: freeRotationRef.current,
741
+ movingIds: [node.id as AnyNodeId],
742
+ 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,
747
+ gridStep: useEditor.getState().gridSnapStep,
748
+ })
749
+ const snappedPlanPosition = getVisualPosition(
750
+ snappedPosition,
751
+ freeRotationRef.current,
752
+ )
753
+ return [
754
+ snappedPlanPosition[0] + centerOffset[0],
755
+ snappedPlanPosition[2] + centerOffset[2],
756
+ ]
757
+ }
758
+ : undefined,
759
+ resolveAttachment:
760
+ attachmentEnabled && (groupMoveSnapPoseConfig || groupMoveSnapConfig)
761
+ ? ([planX, planZ]) => {
762
+ const snapArgs: Parameters<NonNullable<typeof groupMoveSnapPoseConfig>>[0] = {
763
+ node,
764
+ candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ),
765
+ candidateRotation:
766
+ absolute && dragBounds?.center ? freeRotationRef.current : rotationRef.current,
767
+ movingIds: [node.id as AnyNodeId],
768
+ 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,
773
+ }
774
+ const snappedPosition: GroupMoveSnapResult | null = groupMoveSnapPoseConfig
775
+ ? groupMoveSnapPoseConfig(snapArgs)
776
+ : (() => {
777
+ const position = groupMoveSnapConfig?.(snapArgs)
778
+ return position ? { position } : null
779
+ })()
780
+ if (!snappedPosition) return null
781
+ attachmentRotationY = snappedPosition.rotation ?? null
782
+ const snappedPlanPosition = getVisualPosition(
783
+ snappedPosition.position,
784
+ snappedPosition.rotation ?? rotationRef.current,
785
+ )
786
+ return [snappedPlanPosition[0], snappedPlanPosition[2]]
787
+ }
788
+ : undefined,
634
789
  })
635
790
  dragAnchorRef.current = resolved.anchor
636
791
  let [x, z] = resolved.point
792
+ const attachmentSnapped = resolved.attachmentSnapped
793
+ attachmentRotationRef.current = attachmentSnapped ? attachmentRotationY : null
794
+ const nextRotationY = resolveAttachmentPreviewRotation(
795
+ freeRotationRef.current,
796
+ attachmentRotationRef.current,
797
+ )
798
+ if (nextRotationY !== rotationRef.current) {
799
+ rotationRef.current = nextRotationY
800
+ setCursorRotationY(previewRotationY(nextRotationY))
801
+ }
637
802
 
638
803
  // Figma-style alignment snap layered on top of grid snap: when the
639
804
  // moving item's edge lines up (on X or Z) with another item's edge,
@@ -642,8 +807,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
642
807
  // point. Alignment "lines" are DISPLAYED in every mode except Off
643
808
  // (isAlignmentGuideActive); the magnetic pull toward them applies only in
644
809
  // 'lines' mode (magnetic). Alt is force-place, not a snap bypass.
645
- const magnetic = isMagneticSnapActive()
646
- if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
810
+ if (!attachmentSnapped && isAlignmentGuideActive() && alignmentCandidates.length > 0) {
647
811
  const result = resolveAlignment({
648
812
  moving: movingDragBoundsAnchors(
649
813
  node,
@@ -664,25 +828,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
664
828
  useAlignmentGuides.getState().clear()
665
829
  }
666
830
 
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
831
  // Magnetic port snap (duct terminals): mate a collar onto a nearby
687
832
  // duct run end. Takes precedence over grid / alignment snap; Alt
688
833
  // bypasses. Only kinds that opted in via `movable.portSnap`.
@@ -701,7 +846,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
701
846
  }
702
847
 
703
848
  let position = canonicalPositionFromPlan(x, originalPosition[1], z)
704
- if ((magnetic || isGridSnapActive()) && parentFrame?.magneticSnap && frameParent) {
849
+ if (
850
+ !attachmentSnapped &&
851
+ (magnetic || isGridSnapActive()) &&
852
+ parentFrame?.magneticSnap &&
853
+ frameParent
854
+ ) {
705
855
  const preSnapPosition = position
706
856
  const snappedPosition = parentFrame.magneticSnap(
707
857
  node,
@@ -732,6 +882,24 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
732
882
  if (guides.length > 0) useAlignmentGuides.getState().set(guides)
733
883
  }
734
884
  }
885
+ if (!parentFrame && pointed?.sourceNodeId) {
886
+ const rotation = toCommitRotation(rotationRef.current)
887
+ const effectiveNode = {
888
+ ...(node as Record<string, unknown>),
889
+ position,
890
+ rotation,
891
+ } as AnyNode
892
+ position = resolveFrozenFloorPlacementPatch(
893
+ effectiveNode,
894
+ { ...useScene.getState().nodes, [node.id]: effectiveNode },
895
+ {
896
+ position,
897
+ rotation,
898
+ elevation: pointed.elevation,
899
+ preferredSlabId: pointed.supportSlabId,
900
+ },
901
+ ).position
902
+ }
735
903
  const visualPosition = getVisualPosition(position)
736
904
  hasMovedRef.current = true
737
905
  setCursorPosition(visualPosition)
@@ -764,7 +932,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
764
932
 
765
933
  const nextSnapKey = movementSfxStepKey({
766
934
  coords: [x, z],
767
- gridSnapActive: isGridSnapActive(),
935
+ gridSnapActive: isGridSnapActive() && !attachmentSnapped,
768
936
  gridStep: useEditor.getState().gridSnapStep,
769
937
  })
770
938
  const prev = previousSnapRef.current
@@ -790,8 +958,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
790
958
  * AND scene updated) — never the original.
791
959
  */
792
960
  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`),
961
+ // One physical click can reach here twice: `node:click` is synthesized
962
+ // on *pointerup* (`use-node-events`),
795
963
  // while `grid:click` rides the browser's native *click* event from a
796
964
  // canvas DOM listener (`use-grid-events`) that deliberately ignores
797
965
  // stopPropagation — and this effect stays subscribed until React
@@ -830,7 +998,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
830
998
  ...useScene.getState().nodes,
831
999
  [node.id]: effectiveNode,
832
1000
  },
833
- { maxElevation: supportCapRef.current },
1001
+ {
1002
+ maxElevation: supportCapRef.current,
1003
+ preferredSlabId: supportSurfaceRef.current?.supportSlabId,
1004
+ pinSupport: supportSurfaceRef.current?.sourceNodeId != null,
1005
+ },
834
1006
  ),
835
1007
  ...(isNew
836
1008
  ? {
@@ -902,7 +1074,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
902
1074
  ...useScene.getState().nodes,
903
1075
  [reparsed.id]: reparsed,
904
1076
  },
905
- { maxElevation: supportCapRef.current },
1077
+ {
1078
+ maxElevation: supportCapRef.current,
1079
+ preferredSlabId: supportSurfaceRef.current?.supportSlabId,
1080
+ pinSupport: supportSurfaceRef.current?.sourceNodeId != null,
1081
+ },
906
1082
  ),
907
1083
  }) as AnyNode
908
1084
  useScene.temporal.getState().resume()
@@ -961,10 +1137,35 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
961
1137
  else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
962
1138
  else return
963
1139
  e.preventDefault()
1140
+ const nextFreeRotation = resolveMoveRotationStep(
1141
+ freeRotationRef.current,
1142
+ delta,
1143
+ attachmentRotationRef.current,
1144
+ )
1145
+ if (nextFreeRotation === null) return
964
1146
  sfxEmitter.emit('sfx:item-rotate')
965
- rotationRef.current += delta
1147
+ let position = lastCursorRef.current
1148
+ if (
1149
+ hasMovedRef.current &&
1150
+ (useAbsoluteCursorPlacement || cursorAttached) &&
1151
+ dragBounds?.center
1152
+ ) {
1153
+ const planCenter = offsetPlanPositionByLocalCenter(
1154
+ getVisualPosition(position),
1155
+ dragBounds.center,
1156
+ previewRotationY(rotationRef.current),
1157
+ )
1158
+ const planOrigin = offsetPlanPositionByLocalCenter(
1159
+ planCenter,
1160
+ [-dragBounds.center[0], 0, -dragBounds.center[2]],
1161
+ previewRotationY(nextFreeRotation),
1162
+ )
1163
+ position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2])
1164
+ lastCursorRef.current = position
1165
+ }
1166
+ freeRotationRef.current = nextFreeRotation
1167
+ rotationRef.current = freeRotationRef.current
966
1168
  setCursorRotationY(previewRotationY(rotationRef.current))
967
- const position = lastCursorRef.current
968
1169
  const visualPosition = getVisualPosition(position)
969
1170
  setCursorPosition(visualPosition)
970
1171
  applyMeshPose(position)
@@ -1007,15 +1208,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1007
1208
  }
1008
1209
  window.addEventListener('pointerup', onPlacementDragPointerUp)
1009
1210
 
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
- }
1211
+ emitter.on('node:click', commitAtCursor)
1019
1212
 
1020
1213
  const onCancel = () => {
1021
1214
  useLiveTransforms.getState().clear(node.id)
@@ -1040,10 +1233,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1040
1233
  emitter.off('grid:move', onGridMove)
1041
1234
  emitter.off('grid:click', commitAtCursor)
1042
1235
  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
- }
1236
+ emitter.off('node:click', commitAtCursor)
1047
1237
  emitter.off('tool:cancel', onCancel)
1048
1238
  // Restore the moved meshes' raycast so they're hoverable / selectable
1049
1239
  // again after the drag ends.
@@ -1067,9 +1257,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1067
1257
  canonicalPositionFromPlan,
1068
1258
  parentFrame,
1069
1259
  frameParent,
1260
+ parentFrameCollides,
1070
1261
  cursorAttached,
1071
1262
  portSnapConfig,
1072
1263
  groupMoveSnapConfig,
1264
+ groupMoveSnapPoseConfig,
1265
+ movableValidityConfig,
1266
+ gridSnapPositionConfig,
1073
1267
  exitMoveMode,
1074
1268
  isFreshPlacement,
1075
1269
  node,
@@ -1104,12 +1298,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1104
1298
 
1105
1299
  if (boxDimensions && !dragBounds?.center) {
1106
1300
  return (
1107
- <PlacementBox
1108
- dimensions={boxDimensions}
1109
- position={cursorPosition}
1110
- rotationY={cursorRotationY}
1111
- valid={valid}
1112
- />
1301
+ <group ref={previewGroupRef}>
1302
+ <PlacementBox
1303
+ dimensions={boxDimensions}
1304
+ position={cursorPosition}
1305
+ rotationY={cursorRotationY}
1306
+ valid={valid}
1307
+ />
1308
+ </group>
1113
1309
  )
1114
1310
  }
1115
1311
 
@@ -1119,7 +1315,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1119
1315
  : cursorPosition
1120
1316
 
1121
1317
  return (
1122
- <>
1318
+ <group ref={previewGroupRef}>
1123
1319
  <CursorSphere color="#a78bfa" height={2.5} position={dragCenterPosition} />
1124
1320
  <DragBoundingBox
1125
1321
  center={dragBounds?.center}
@@ -1130,6 +1326,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1130
1326
  rotationY={cursorRotationY}
1131
1327
  size={dragBounds?.size}
1132
1328
  />
1133
- </>
1329
+ </group>
1134
1330
  )
1135
1331
  }