@pascal-app/editor 0.9.1 → 0.9.2

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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -3,30 +3,52 @@
3
3
  import '../../../three-types'
4
4
 
5
5
  import {
6
+ type AlignmentGuide,
6
7
  type AnyNode,
7
8
  type AnyNodeId,
9
+ analyzePortConnectivity,
10
+ bboxCornerAnchors,
8
11
  collectAlignmentAnchors,
12
+ createSceneApi,
9
13
  type EventSuffix,
10
14
  emitter,
15
+ footprintAABBFrom,
11
16
  type GridEvent,
17
+ getFloorPlacedFootprints,
12
18
  movingFootprintAnchors,
13
19
  type NodeEvent,
14
20
  nodeRegistry,
21
+ type ParentFrameSnapMatch,
22
+ type PortConnectivity,
15
23
  resolveAlignment,
24
+ resolveConnectivityUpdates,
25
+ resolveFacingIndicator,
16
26
  sceneRegistry,
17
27
  spatialGridManager,
28
+ useLiveNodeOverrides,
18
29
  useLiveTransforms,
19
30
  useScene,
20
31
  } from '@pascal-app/core'
21
- import { useAlignmentGuides } from '@pascal-app/editor'
22
32
  import { useViewer } from '@pascal-app/viewer'
23
33
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
24
34
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
25
35
  import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
26
36
  import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
27
37
  import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
38
+ import { movementSfxStepKey } from '../../../lib/sfx/movement-tick'
28
39
  import { sfxEmitter } from '../../../lib/sfx-bus'
29
- import useEditor from '../../../store/use-editor'
40
+ import { resolveSnapFlags } from '../../../lib/snapping-mode'
41
+
42
+ import useAlignmentGuides from '../../../store/use-alignment-guides'
43
+ import useEditor, {
44
+ getActiveSnappingMode,
45
+ isAlignmentGuideActive,
46
+ isGridSnapActive,
47
+ isMagneticSnapActive,
48
+ } from '../../../store/use-editor'
49
+
50
+ import useFacingPose from '../../../store/use-facing-pose'
51
+ import { swallowNextClick } from '../../editor/node-arrow-handles'
30
52
  import { CursorSphere } from '../shared/cursor-sphere'
31
53
  import { DragBoundingBox } from '../shared/drag-bounding-box'
32
54
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
@@ -36,12 +58,127 @@ import { PlacementBox } from '../shared/placement-box'
36
58
  /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
37
59
  * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
38
60
  const snapToGridStep = (value: number) => {
61
+ if (!resolveSnapFlags(getActiveSnappingMode()).grid) return value
39
62
  const step = useEditor.getState().gridSnapStep
40
63
  return Math.round(value / step) * step
41
64
  }
42
65
 
43
- /** 90° steps, matching the GLB item placement rotation. */
44
- const ROTATION_STEP = Math.PI / 2
66
+ /** 45° steps, matching the GLB item placement rotation. */
67
+ const ROTATION_STEP = Math.PI / 4
68
+
69
+ /** Default magnetic radius (meters, XZ) for `movable.portSnap`. */
70
+ const PORT_SNAP_RADIUS_M = 0.5
71
+ const VALID_COLOR = 0x22_c5_5e
72
+ const INVALID_COLOR = 0xef_44_44
73
+
74
+ type DragBoundsOverride = {
75
+ size: [number, number, number]
76
+ center?: [number, number, number]
77
+ centerY?: number
78
+ }
79
+
80
+ function offsetPlanPositionByLocalCenter(
81
+ position: [number, number, number],
82
+ center: [number, number, number],
83
+ rotationY: number,
84
+ ): [number, number, number] {
85
+ const cos = Math.cos(rotationY)
86
+ const sin = Math.sin(rotationY)
87
+ return [
88
+ position[0] + center[0] * cos + center[2] * sin,
89
+ position[1] + center[1],
90
+ position[2] - center[0] * sin + center[2] * cos,
91
+ ]
92
+ }
93
+
94
+ /**
95
+ * Alignment anchors for the moving node. When the kind declares
96
+ * `capabilities.dragBounds` with an off-origin `center` (a composite cabinet
97
+ * run whose modules extend past the node origin), the anchors come from that
98
+ * declared box instead of the origin-centred footprint.
99
+ */
100
+ function movingDragBoundsAnchors(
101
+ node: AnyNode,
102
+ bounds: DragBoundsOverride | null,
103
+ x: number,
104
+ z: number,
105
+ rotationY: number,
106
+ ) {
107
+ if (!bounds?.center) return movingFootprintAnchors(node, x, z, rotationY)
108
+ const center = offsetPlanPositionByLocalCenter([x, 0, z], bounds.center, rotationY)
109
+ const aabb = footprintAABBFrom(center, bounds.size, rotationY)
110
+ return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
111
+ }
112
+
113
+ function alignmentGuideFromParentFrameMatch(match: ParentFrameSnapMatch): AlignmentGuide {
114
+ return {
115
+ axis: match.axis,
116
+ coord: match.axis === 'x' ? match.from.x : match.from.z,
117
+ from: match.from,
118
+ to: match.to,
119
+ anchor: match.from,
120
+ movingAnchorKind: 'corner',
121
+ candidateAnchorKind: 'corner',
122
+ candidateNodeId: match.candidateNodeId,
123
+ distance: Math.hypot(match.to.x - match.from.x, match.to.z - match.from.z),
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Magnetic port snap for a dragged node: if one of the node's own ports
129
+ * (read live from `def.ports`) lands within `radius` of a matching scene
130
+ * port at the candidate XZ, return the node XZ that mates them exactly.
131
+ *
132
+ * Pure core: ports come through `nodeRegistry` so this stays layer-clean.
133
+ * Ports are level-local meters — the same frame as the cursor's
134
+ * `localPosition`, so no extra transform is needed. The dragged node's
135
+ * ports move rigidly with its position, so a port at candidate `(x,z)`
136
+ * sits at `portStored + (candidate - nodeStored)`. We pick the closest
137
+ * (own-port, target-port) pair and shift the node so they coincide in XZ.
138
+ */
139
+ function resolvePortSnap(
140
+ node: AnyNode,
141
+ candidate: [number, number],
142
+ config: { systems?: readonly string[]; radius?: number },
143
+ ): [number, number] | null {
144
+ const nodePos = (node as { position?: [number, number, number] }).position
145
+ if (!nodePos) return null
146
+ const ownPorts = nodeRegistry.get(node.type)?.ports?.(node)
147
+ if (!ownPorts || ownPorts.length === 0) return null
148
+
149
+ const radius = config.radius ?? PORT_SNAP_RADIUS_M
150
+ const radiusSq = radius * radius
151
+ const { systems } = config
152
+ const dragDx = candidate[0] - nodePos[0]
153
+ const dragDz = candidate[1] - nodePos[2]
154
+
155
+ const nodes = useScene.getState().nodes
156
+ let bestDistSq = radiusSq
157
+ let snap: [number, number] | null = null
158
+
159
+ for (const node2 of Object.values(nodes)) {
160
+ if (!node2 || node2.id === node.id) continue
161
+ const targets = nodeRegistry.get(node2.type)?.ports?.(node2)
162
+ if (!targets) continue
163
+ for (const target of targets) {
164
+ if (systems && target.system !== undefined && !systems.includes(target.system)) continue
165
+ for (const own of ownPorts) {
166
+ // Own port at the candidate position = stored port + drag delta.
167
+ const ownX = own.position[0] + dragDx
168
+ const ownZ = own.position[2] + dragDz
169
+ const dx = target.position[0] - ownX
170
+ const dz = target.position[2] - ownZ
171
+ const distSq = dx * dx + dz * dz
172
+ if (distSq <= bestDistSq) {
173
+ bestDistSq = distSq
174
+ // Shift the node so this own port lands on the target (XZ only).
175
+ snap = [candidate[0] + dx, candidate[1] + dz]
176
+ }
177
+ }
178
+ }
179
+ }
180
+ return snap
181
+ }
45
182
 
46
183
  /** Figma-style alignment-snap threshold (meters), matching the 2D
47
184
  * floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull
@@ -94,6 +231,15 @@ const CLICK_TRIGGER_KINDS = [
94
231
  ] as const
95
232
 
96
233
  export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
234
+ // Kinds whose `position` lives in a host parent's local frame declare
235
+ // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the
236
+ // plan-frame cursor through the capability's hooks and previews via
237
+ // `useLiveNodeOverrides` so the parent's composite geometry re-flows.
238
+ const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame ?? null
239
+ const frameParent = useMemo(
240
+ () => parentFrame?.resolveParent(node, useScene.getState().nodes) ?? null,
241
+ [parentFrame, node],
242
+ )
97
243
  const originalPosition: [number, number, number] = useMemo(
98
244
  () =>
99
245
  'position' in node && Array.isArray((node as { position?: unknown }).position)
@@ -119,7 +265,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
119
265
  return 0
120
266
  }, [node])
121
267
  const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition)
122
- const previousSnapRef = useRef<[number, number] | null>(null)
268
+ const previousSnapRef = useRef<string | null>(null)
123
269
  /**
124
270
  * The latest snapped cursor position from `grid:move`. We commit at
125
271
  * THIS position regardless of which event variant fires the click —
@@ -144,28 +290,107 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
144
290
  // and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
145
291
  // and committed to the scene on drop.
146
292
  const rotationRef = useRef(originalRotationY)
293
+ // Snapshot of which ducts / fittings are mated to this node's ports at
294
+ // drag-start (duct fittings only). Drives the "connected ductwork follows"
295
+ // behaviour: connected nodes preview through `useLiveNodeOverrides` during
296
+ // the drag and commit alongside the moved node on drop. Null for kinds with
297
+ // no ports, so every other movable kind is unaffected.
298
+ const connectivityRef = useRef<PortConnectivity | null>(null)
299
+ // Node ids this drag has pushed live overrides onto — cleared on
300
+ // commit / cancel / unmount so a follow-on drag starts clean.
301
+ const overriddenIdsRef = useRef<AnyNodeId[]>([])
147
302
 
148
- // Shelf placement shows the same green/red footprint box GLB items use
149
- // (instead of the vertical-arrow cursor) and refuses an invalid drop unless
150
- // Shift forces it. The footprint comes from the kind's `floorPlaced`
151
- // capability so this stays generic if we ever opt other kinds in.
152
- const isShelf = node.type === 'shelf'
153
- const boxDimensions = useMemo(
303
+ // Colliding floor kinds (item / shelf / column) show the same green/red
304
+ // footprint box GLB items use (instead of the vertical-arrow cursor) and
305
+ // refuse an invalid drop unless Alt forces it. The gate + footprint both come
306
+ // from the kind's declarative `floorPlaced` capability, so opting a new kind
307
+ // in is just `collides: true` — no change here.
308
+ // Parent-frame kinds skip the world-frame floor-collision box — their
309
+ // position isn't in the level frame the spatial grid indexes.
310
+ const collides =
311
+ !frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
312
+ // Snapshot the scene once at drag-start — bounds depend on `node` (locked
313
+ // for the lifetime of this tool) and any sibling state the kind reads. If a
314
+ // future kind needs live sibling state mid-drag, switch to a subscribed
315
+ // selector; for v1 (elevator shaft, cabinet run) start-time is correct and
316
+ // avoids subscribing the whole `nodes` map.
317
+ const dragBounds = useMemo(
318
+ (): DragBoundsOverride | null =>
319
+ (nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) as
320
+ | DragBoundsOverride
321
+ | undefined) ?? null,
322
+ [node],
323
+ )
324
+ // Collision extents: the declared drag bounds (composite kinds — a cabinet
325
+ // run spans its modules) win over the single-node footprint.
326
+ const resolvedFootprint = useMemo(
154
327
  () =>
155
- isShelf
156
- ? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
157
- null)
158
- : null,
159
- [isShelf, node],
328
+ dragBounds?.size ??
329
+ nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
330
+ null,
331
+ [dragBounds, node],
332
+ )
333
+ const boxDimensions = useMemo(
334
+ () => (collides ? resolvedFootprint : null),
335
+ [collides, resolvedFootprint],
160
336
  )
161
337
  const [valid, setValid] = useState(true)
162
- const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
338
+ const previewRotationY = useCallback(
339
+ (rotationY = rotationRef.current) =>
340
+ parentFrame && frameParent
341
+ ? parentFrame.parentRotationY(frameParent, useScene.getState().nodes) + rotationY
342
+ : rotationY,
343
+ [parentFrame, frameParent],
344
+ )
345
+ const visualPositionFor = useCallback(
346
+ (position: [number, number, number], rotationY = rotationRef.current) => {
347
+ if (parentFrame && frameParent) {
348
+ return parentFrame.localToPlan(frameParent, position, useScene.getState().nodes)
349
+ }
350
+ return getFloorStackPreviewPosition({
351
+ node,
352
+ position,
353
+ rotation: (() => {
354
+ const r = (node as { rotation?: unknown }).rotation
355
+ return Array.isArray(r)
356
+ ? [(r[0] as number) ?? 0, rotationY, (r[2] as number) ?? 0]
357
+ : rotationY
358
+ })(),
359
+ })
360
+ },
361
+ [parentFrame, frameParent, node],
362
+ )
363
+ const canonicalPositionFromPlan = useCallback(
364
+ (planX: number, localY: number, planZ: number): [number, number, number] =>
365
+ parentFrame && frameParent
366
+ ? parentFrame.planToLocal(frameParent, planX, localY, planZ, useScene.getState().nodes)
367
+ : [planX, localY, planZ],
368
+ [parentFrame, frameParent],
369
+ )
370
+ const originalPlanPosition = useMemo(
371
+ () => visualPositionFor(originalPosition, originalRotationY),
372
+ [originalPosition, originalRotationY, visualPositionFor],
373
+ )
374
+ const [cursorRotationY, setCursorRotationY] = useState(() => previewRotationY(originalRotationY))
163
375
  const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
164
376
  useFreshPlacementVisibility({ node })
165
- // Mirrors of `valid` / Shift for the event handlers inside the effect, which
377
+ // Kinds that declare `movable.cursorAttached` (duct fittings) pin to the
378
+ // cursor instead of preserving the grab offset — small connector-like
379
+ // nodes read an offset drag as "lagging behind the mouse".
380
+ const cursorAttached = nodeRegistry.get(node.type)?.capabilities?.movable?.cursorAttached === true
381
+ // Kinds that declare `movable.portSnap` (duct terminals) magnetically
382
+ // mate one of their own ports onto a nearby scene port while dragging —
383
+ // a register collar drops onto a duct run end. Reads `def.ports` through
384
+ // the core registry, so it stays layer-clean (no @pascal-app/nodes import).
385
+ const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
386
+ // Kind-owned magnetic snap for the generic 3D move path. Cabinets use this
387
+ // to settle a dragged run flush against a wall without forking the move tool.
388
+ const groupMoveSnapConfig =
389
+ nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null
390
+ // Mirrors of `valid` / Alt for the event handlers inside the effect, which
166
391
  // can't read React state without stale closures.
167
392
  const validRef = useRef(true)
168
- const shiftRef = useRef(false)
393
+ const altRef = useRef(false)
169
394
 
170
395
  const exitMoveMode = useCallback(() => {
171
396
  useEditor.getState().setMovingNode(null)
@@ -177,14 +402,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
177
402
  dragAnchorRef.current = null
178
403
  hasMovedRef.current = false
179
404
  rotationRef.current = originalRotationY
180
- shiftRef.current = false
405
+ altRef.current = false
181
406
  validRef.current = true
182
407
  // Re-sync the box transform to the (possibly new) node. `node` changes
183
408
  // without this component remounting whenever a positioned preset re-arms a
184
409
  // fresh clone after a drop, or the user picks a different catalog tile —
185
410
  // and `useState` only honours its initial value, so without this the box
186
411
  // would keep the previous clone's rotation/position until the next R/T.
187
- setCursorRotationY(originalRotationY)
412
+ setCursorRotationY(previewRotationY(originalRotationY))
188
413
  lastCursorRef.current = originalPosition
189
414
  let committed = false
190
415
  const isNew = isFreshPlacement
@@ -195,15 +420,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
195
420
  ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
196
421
  : y
197
422
 
198
- const getVisualPosition = (
199
- position: [number, number, number],
200
- rotationY = rotationRef.current,
201
- ): [number, number, number] => {
202
- return getFloorStackPreviewPosition({
203
- node,
204
- position,
205
- rotation: toCommitRotation(rotationY),
206
- })
423
+ const getVisualPosition = visualPositionFor
424
+ const applyMeshPose = (position: [number, number, number], rotationY = rotationRef.current) => {
425
+ const object = sceneRegistry.nodes.get(node.id)
426
+ if (!object) return
427
+ object.position.set(...position)
428
+ object.rotation.y = rotationY
207
429
  }
208
430
  const markMovedNodeDirty = () => {
209
431
  if (useScene.getState().nodes[node.id]) {
@@ -211,15 +433,69 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
211
433
  }
212
434
  }
213
435
 
436
+ // Connectivity follow (duct fittings): the moved node with its live drag
437
+ // transform, so `def.ports` recomputes for `resolveConnectivityUpdates`.
438
+ // Uses the logical (un-stacked) position + Y rotation that commit writes,
439
+ // not the floor-lifted visual position.
440
+ const buildPreviewNode = (position: [number, number, number], rotationY: number): AnyNode =>
441
+ ({
442
+ ...(node as Record<string, unknown>),
443
+ position,
444
+ rotation: toCommitRotation(rotationY),
445
+ }) as AnyNode
446
+
447
+ // Resolve the patches that keep connected ductwork attached and preview
448
+ // them through `useLiveNodeOverrides` (transient — no history churn;
449
+ // GeometrySystem merges overrides via getEffectiveNode). Each connected
450
+ // node is re-dirtied so its geometry rebuilds against the new override.
451
+ const previewConnectivity = (position: [number, number, number], rotationY: number) => {
452
+ const connectivity = connectivityRef.current
453
+ if (!connectivity) return
454
+ const updates = resolveConnectivityUpdates(
455
+ connectivity,
456
+ buildPreviewNode(position, rotationY),
457
+ )
458
+ if (updates.length === 0) return
459
+ useLiveNodeOverrides
460
+ .getState()
461
+ .setMany(updates.map((u) => [u.id, u.data as Record<string, unknown>] as const))
462
+ overriddenIdsRef.current = updates.map((u) => u.id)
463
+ for (const u of updates) {
464
+ if (useScene.getState().nodes[u.id]) useScene.getState().markDirty(u.id)
465
+ }
466
+ }
467
+
468
+ const clearConnectivityOverrides = () => {
469
+ for (const id of overriddenIdsRef.current) {
470
+ useLiveNodeOverrides.getState().clear(id)
471
+ if (useScene.getState().nodes[id]) useScene.getState().markDirty(id)
472
+ }
473
+ }
474
+
475
+ const syncParentFramePreview = (position: [number, number, number]) => {
476
+ if (!frameParent) return
477
+ useLiveNodeOverrides.getState().set(node.id, {
478
+ position,
479
+ rotation: rotationRef.current,
480
+ })
481
+ useScene.getState().markDirty(frameParent.id as AnyNodeId)
482
+ }
483
+
484
+ const clearParentFramePreview = () => {
485
+ if (!frameParent) return
486
+ useLiveNodeOverrides.getState().clear(node.id)
487
+ useScene.getState().markDirty(frameParent.id as AnyNodeId)
488
+ }
489
+
214
490
  setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
215
491
 
216
492
  // Re-run the floor-collision check at the live cursor + rotation and push
217
- // the result to the box colour. Shift forces a valid (green) override so
218
- // the user can drop on top of an existing item on purpose. Only shelves
219
- // show the box, so this no-ops for every other movable kind.
493
+ // the result to the box colour. Alt (free place) forces a valid (green)
494
+ // override so the user can drop on top of an existing item on purpose. Only
495
+ // shelves show the box, so this no-ops for every other movable kind.
220
496
  const recomputeValidity = () => {
221
497
  if (!boxDimensions) return
222
- if (shiftRef.current) {
498
+ if (altRef.current) {
223
499
  validRef.current = true
224
500
  setValid(true)
225
501
  return
@@ -230,14 +506,42 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
230
506
  setValid(true)
231
507
  return
232
508
  }
233
- const [x, y, z] = lastCursorRef.current
234
- const { valid: placeable } = spatialGridManager.canPlaceOnFloor(
235
- levelId,
236
- [x, y, z],
237
- boxDimensions,
238
- [0, rotationRef.current, 0],
239
- [node.id],
240
- )
509
+ const livePosition = lastCursorRef.current
510
+ const liveRotation = previewRotationY(rotationRef.current)
511
+ const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
512
+ const effectiveNode = {
513
+ ...(node as Record<string, unknown>),
514
+ position: livePosition,
515
+ rotation: Array.isArray((node as { rotation?: unknown }).rotation)
516
+ ? [
517
+ ((node as { rotation?: unknown }).rotation as [number?, number?, number?])[0] ?? 0,
518
+ rotationRef.current,
519
+ ((node as { rotation?: unknown }).rotation as [number?, number?, number?])[2] ?? 0,
520
+ ]
521
+ : rotationRef.current,
522
+ } as AnyNode
523
+ const footprints = floorPlaced
524
+ ? getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes: useScene.getState().nodes })
525
+ : []
526
+ const resolvedFootprints: Array<{
527
+ position: [number, number, number]
528
+ dimensions: [number, number, number]
529
+ rotation: [number, number, number]
530
+ }> = footprints.map((footprint) => ({
531
+ position: footprint.position ?? livePosition,
532
+ dimensions: footprint.dimensions,
533
+ rotation: footprint.rotation,
534
+ }))
535
+ const { valid: placeable } =
536
+ resolvedFootprints.length > 0
537
+ ? spatialGridManager.canPlaceOnFloorFootprints(levelId, resolvedFootprints, [node.id])
538
+ : spatialGridManager.canPlaceOnFloor(
539
+ levelId,
540
+ getVisualPosition(livePosition),
541
+ boxDimensions,
542
+ [0, liveRotation, 0],
543
+ [node.id],
544
+ )
241
545
  validRef.current = placeable
242
546
  setValid(placeable)
243
547
  }
@@ -276,6 +580,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
276
580
  useViewer.getState().selection.levelId ?? node.parentId,
277
581
  )
278
582
 
583
+ // Connectivity snapshot (existing port-bearing nodes only — fresh
584
+ // placements aren't connected to anything yet). Records which ducts /
585
+ // fittings are mated to this node's ports so they can follow the drag.
586
+ connectivityRef.current = null
587
+ overriddenIdsRef.current = []
588
+ if (!isNew && nodeRegistry.get(node.type)?.ports) {
589
+ const snapshot = analyzePortConnectivity(node, useScene.getState().nodes)
590
+ if (snapshot.connections.length > 0) connectivityRef.current = snapshot
591
+ }
592
+
279
593
  const onGridMove = (event: GridEvent) => {
280
594
  const rawX = event.localPosition[0]
281
595
  const rawZ = event.localPosition[2]
@@ -283,9 +597,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
283
597
 
284
598
  const resolved = resolvePlanarCursorPosition({
285
599
  cursor: [rawX, rawZ],
286
- original: [originalPosition[0], originalPosition[2]],
600
+ original: [originalPlanPosition[0], originalPlanPosition[2]],
287
601
  anchor: dragAnchorRef.current,
288
- mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
602
+ mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
603
+ // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only.
289
604
  snap: snapToGridStep,
290
605
  })
291
606
  dragAnchorRef.current = resolved.anchor
@@ -293,17 +608,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
293
608
 
294
609
  // Figma-style alignment snap layered on top of grid snap: when the
295
610
  // moving item's edge lines up (on X or Z) with another item's edge,
296
- // snap and publish a guide. The guide connects to the nearest real
297
- // corner of the candidate (resolver tie-break), so the dot always sits
298
- // on an actual point. Alt bypasses.
299
- const bypass = event.nativeEvent?.altKey === true
300
- if (!bypass && alignmentCandidates.length > 0) {
611
+ // publish a guide. The guide connects to the nearest real corner of the
612
+ // candidate (resolver tie-break), so the dot always sits on an actual
613
+ // point. Alignment "lines" are DISPLAYED in every mode except Off
614
+ // (isAlignmentGuideActive); the magnetic pull toward them applies only in
615
+ // 'lines' mode (magnetic). Alt is force-place, not a snap bypass.
616
+ const magnetic = isMagneticSnapActive()
617
+ if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
301
618
  const result = resolveAlignment({
302
- moving: movingFootprintAnchors(node, x, z, rotationRef.current),
619
+ moving: movingDragBoundsAnchors(
620
+ node,
621
+ dragBounds,
622
+ x,
623
+ z,
624
+ previewRotationY(rotationRef.current),
625
+ ),
303
626
  candidates: alignmentCandidates,
304
627
  threshold: ALIGNMENT_THRESHOLD_M,
305
628
  })
306
- if (result.snap) {
629
+ if (result.snap && magnetic) {
307
630
  x += result.snap.dx
308
631
  z += result.snap.dz
309
632
  }
@@ -312,7 +635,74 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
312
635
  useAlignmentGuides.getState().clear()
313
636
  }
314
637
 
315
- const position: [number, number, number] = [x, originalPosition[1], z]
638
+ // Kind-owned attachment snap (cabinet wall): an attach behavior like
639
+ // door/window wall placement, not an alignment guide — active in every
640
+ // snapping mode except Off.
641
+ if ((magnetic || isGridSnapActive()) && groupMoveSnapConfig) {
642
+ const snappedPosition = groupMoveSnapConfig({
643
+ node,
644
+ candidatePosition: canonicalPositionFromPlan(x, originalPosition[1], z),
645
+ movingIds: [node.id as AnyNodeId],
646
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
647
+ levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? null,
648
+ })
649
+ if (snappedPosition) {
650
+ const snappedPlanPosition = getVisualPosition(snappedPosition)
651
+ x = snappedPlanPosition[0]
652
+ z = snappedPlanPosition[2]
653
+ useAlignmentGuides.getState().clear()
654
+ }
655
+ }
656
+
657
+ // Magnetic port snap (duct terminals): mate a collar onto a nearby
658
+ // duct run end. Takes precedence over grid / alignment snap; Alt
659
+ // bypasses. Only kinds that opted in via `movable.portSnap`.
660
+ if (magnetic && portSnapConfig) {
661
+ // Build the preview node at the ORIGINAL position but with the LIVE
662
+ // rotation so `def.ports` reflects any mid-drag R/T rotation. Without
663
+ // this the snap solver mates the pre-rotation collar and commit then
664
+ // writes the rotated node offset from the port it visually snapped to.
665
+ const snapNode = buildPreviewNode(originalPosition, rotationRef.current)
666
+ const mated = resolvePortSnap(snapNode, [x, z], portSnapConfig)
667
+ if (mated) {
668
+ x = mated[0]
669
+ z = mated[1]
670
+ useAlignmentGuides.getState().clear()
671
+ }
672
+ }
673
+
674
+ let position = canonicalPositionFromPlan(x, originalPosition[1], z)
675
+ if ((magnetic || isGridSnapActive()) && parentFrame?.magneticSnap && frameParent) {
676
+ const preSnapPosition = position
677
+ const snappedPosition = parentFrame.magneticSnap(
678
+ node,
679
+ frameParent,
680
+ position,
681
+ useScene.getState().nodes,
682
+ )
683
+ if (
684
+ snappedPosition[0] !== position[0] ||
685
+ snappedPosition[1] !== position[1] ||
686
+ snappedPosition[2] !== position[2]
687
+ ) {
688
+ position = snappedPosition
689
+ const snappedPlanPosition = getVisualPosition(position)
690
+ x = snappedPlanPosition[0]
691
+ z = snappedPlanPosition[2]
692
+ }
693
+ if (isAlignmentGuideActive() && parentFrame.magneticSnapMatches) {
694
+ const guides = parentFrame
695
+ .magneticSnapMatches(
696
+ node,
697
+ frameParent,
698
+ preSnapPosition,
699
+ snappedPosition,
700
+ useScene.getState().nodes,
701
+ )
702
+ .map(alignmentGuideFromParentFrameMatch)
703
+ if (guides.length > 0) useAlignmentGuides.getState().set(guides)
704
+ }
705
+ }
316
706
  const visualPosition = getVisualPosition(position)
317
707
  hasMovedRef.current = true
318
708
  setCursorPosition(visualPosition)
@@ -320,7 +710,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
320
710
  recomputeValidity()
321
711
 
322
712
  // Pure imperative: move the mesh via its registered Object3D ref.
323
- sceneRegistry.nodes.get(node.id)?.position.set(...visualPosition)
713
+ applyMeshPose(position)
324
714
  // Publish to `useLiveTransforms` so the 2D floor plan can mirror
325
715
  // the drag in real-time (the floor-plan layer subscribes to this
326
716
  // store and overrides the node's rendered position when an entry
@@ -335,12 +725,20 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
335
725
  position,
336
726
  rotation: rotationRef.current,
337
727
  })
728
+ syncParentFramePreview(position)
338
729
  markMovedNodeDirty()
730
+ // Carry connected ductwork along (preview only — committed on drop).
731
+ previewConnectivity(position, rotationRef.current)
339
732
 
733
+ const nextSnapKey = movementSfxStepKey({
734
+ coords: [x, z],
735
+ gridSnapActive: isGridSnapActive(),
736
+ gridStep: useEditor.getState().gridSnapStep,
737
+ })
340
738
  const prev = previousSnapRef.current
341
- if (!prev || prev[0] !== x || prev[1] !== z) {
739
+ if (prev !== nextSnapKey) {
342
740
  sfxEmitter.emit('sfx:grid-snap')
343
- previousSnapRef.current = [x, z]
741
+ previousSnapRef.current = nextSnapKey
344
742
  }
345
743
  }
346
744
 
@@ -360,18 +758,26 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
360
758
  * AND scene updated) — never the original.
361
759
  */
362
760
  const commitAtCursor = (event: ClickTriggerEvent) => {
761
+ // One physical click can reach here twice: node clicks (`slab:click`,
762
+ // `item:click`, …) are synthesized on *pointerup* (`use-node-events`),
763
+ // while `grid:click` rides the browser's native *click* event from a
764
+ // canvas DOM listener (`use-grid-events`) that deliberately ignores
765
+ // stopPropagation — and this effect stays subscribed until React
766
+ // re-renders after `exitMoveMode`. Without this guard the second pass
767
+ // finds the fresh draft already deleted and takes the orphan re-create
768
+ // path below, minting a hidden ghost copy and replaying the SFX.
769
+ if (committed) return
363
770
  // Ignore a commit that fires before the cursor has moved into place —
364
771
  // it's the stray trailing click of whatever armed this move, not a
365
772
  // deliberate drop. Prevents preset re-arm from double-placing.
366
773
  if (!hasMovedRef.current) return
367
774
  // Refuse a drop on an invalid (red) footprint, matching the GLB item
368
- // tool — unless Shift is held to force placement. Other kinds carry no
369
- // validity box (`validRef` stays true), so they're never blocked.
370
- if (!validRef.current && !shiftRef.current) return
775
+ // tool — unless Alt (free place) is held to force placement. Other kinds
776
+ // carry no validity box (`validRef` stays true), so they're never blocked.
777
+ if (!validRef.current && !altRef.current) return
371
778
  const position: [number, number, number] = [...lastCursorRef.current]
372
779
 
373
780
  const rotation = toCommitRotation(rotationRef.current)
374
- const visualPosition = getVisualPosition(position)
375
781
  let committedId = node.id as AnyNodeId
376
782
 
377
783
  if (useScene.getState().nodes[node.id]) {
@@ -393,8 +799,28 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
393
799
  committedId = finalId
394
800
  }
395
801
  } else {
802
+ // Fold the connected-ductwork follow-updates into the SAME
803
+ // batch as the moved node so the whole thing is one undo step.
804
+ const connectivityUpdates = connectivityRef.current
805
+ ? resolveConnectivityUpdates(
806
+ connectivityRef.current,
807
+ buildPreviewNode(position, rotationRef.current),
808
+ ).filter((u) => useScene.getState().nodes[u.id])
809
+ : []
396
810
  useScene.temporal.getState().resume()
397
- useScene.getState().updateNode(node.id, data)
811
+ useScene
812
+ .getState()
813
+ .updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates])
814
+ // Kind-owned derived-state maintenance after a parent-frame move
815
+ // (cabinet run re-flow + linked corner-run re-anchor). Runs in the
816
+ // resumed window so its writes are undoable alongside the move.
817
+ if (parentFrame?.onCommit && frameParent) {
818
+ const liveNode = useScene.getState().nodes[node.id as AnyNodeId]
819
+ const liveParent = useScene.getState().nodes[frameParent.id as AnyNodeId]
820
+ if (liveNode && liveParent) {
821
+ parentFrame.onCommit(liveNode, liveParent, createSceneApi(useScene))
822
+ }
823
+ }
398
824
  useScene.temporal.getState().pause()
399
825
  committed = true
400
826
  }
@@ -420,11 +846,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
420
846
  // canonical position, then restamp the lifted presentation Y for the
421
847
  // current frame.
422
848
  useLiveTransforms.getState().clear(node.id)
423
- const mesh = sceneRegistry.nodes.get(node.id)
424
- if (mesh) {
425
- mesh.position.set(...visualPosition)
426
- mesh.rotation.y = rotationRef.current
427
- }
849
+ // Connected ductwork is now committed to the store — drop its live
850
+ // overrides so the renderers read the canonical path/position.
851
+ clearConnectivityOverrides()
852
+ clearParentFramePreview()
853
+ applyMeshPose(position)
428
854
 
429
855
  useAlignmentGuides.getState().clear()
430
856
  if (isNew && committed) {
@@ -448,14 +874,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
448
874
  if (typeof direct === 'function') direct.call(event)
449
875
  }
450
876
 
451
- // R / T rotate the dragged node about Y in 90° steps — matching the GLB
877
+ // R / T rotate the dragged node about Y in 45° steps — matching the GLB
452
878
  // item placement keys (and the "Rotate" hints the move HUD shows). Applied
453
879
  // imperatively + mirrored to the live transform; committed on drop.
454
880
  const onKeyDown = (e: KeyboardEvent) => {
455
- // Hold Shift to force placement on an invalid (red) footprint, matching
456
- // the GLB item tool. Recolour the box to green while held.
457
- if (e.key === 'Shift') {
458
- shiftRef.current = true
881
+ // Hold Alt (free place) to force placement on an invalid (red) footprint,
882
+ // matching the GLB item tool. Recolour the box to green while held.
883
+ if (e.key === 'Alt') {
884
+ altRef.current = true
459
885
  recomputeValidity()
460
886
  return
461
887
  }
@@ -467,26 +893,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
467
893
  e.preventDefault()
468
894
  sfxEmitter.emit('sfx:item-rotate')
469
895
  rotationRef.current += delta
470
- setCursorRotationY(rotationRef.current)
896
+ setCursorRotationY(previewRotationY(rotationRef.current))
471
897
  const position = lastCursorRef.current
472
898
  const visualPosition = getVisualPosition(position)
473
899
  setCursorPosition(visualPosition)
474
- const m = sceneRegistry.nodes.get(node.id)
475
- if (m) {
476
- m.position.set(...visualPosition)
477
- m.rotation.y = rotationRef.current
478
- }
900
+ applyMeshPose(position)
479
901
  useLiveTransforms.getState().set(node.id, {
480
902
  position,
481
903
  rotation: rotationRef.current,
482
904
  })
905
+ syncParentFramePreview(position)
483
906
  markMovedNodeDirty()
907
+ // Rotating the fitting swings its collars — connected ducts follow.
908
+ previewConnectivity(position, rotationRef.current)
484
909
  // Rotation changes the footprint's collision span — re-check validity.
485
910
  recomputeValidity()
486
911
  }
487
912
  const onKeyUp = (e: KeyboardEvent) => {
488
- if (e.key === 'Shift') {
489
- shiftRef.current = false
913
+ if (e.key === 'Alt') {
914
+ altRef.current = false
490
915
  recomputeValidity()
491
916
  }
492
917
  }
@@ -496,6 +921,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
496
921
  emitter.on('grid:move', onGridMove)
497
922
  emitter.on('grid:click', commitAtCursor)
498
923
 
924
+ const onPlacementDragPointerUp = (event: PointerEvent) => {
925
+ if (!useEditor.getState().placementDragMode) return
926
+ if (event.button !== 0) return
927
+ swallowNextClick()
928
+ if (!hasMovedRef.current) {
929
+ exitMoveMode()
930
+ return
931
+ }
932
+ commitAtCursor({
933
+ nativeEvent: event,
934
+ stopPropagation: () => event.stopPropagation(),
935
+ } as unknown as ClickTriggerEvent)
936
+ }
937
+ window.addEventListener('pointerup', onPlacementDragPointerUp)
938
+
499
939
  // Listen on every common kind's click event too. mitt's typing keeps
500
940
  // `${kind}:click` as a fixed union so the cast is safe at runtime —
501
941
  // we're just routing them through the shared commit path.
@@ -508,14 +948,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
508
948
 
509
949
  const onCancel = () => {
510
950
  useLiveTransforms.getState().clear(node.id)
951
+ clearConnectivityOverrides()
952
+ clearParentFramePreview()
511
953
  if (isNew) {
512
954
  useScene.getState().deleteNode(node.id as AnyNodeId)
513
955
  } else {
514
- const m = sceneRegistry.nodes.get(node.id)
515
- if (m) {
516
- m.position.set(...getVisualPosition(originalPosition, originalRotationY))
517
- m.rotation.y = originalRotationY
518
- }
956
+ applyMeshPose(originalPosition, originalRotationY)
519
957
  markMovedNodeDirty()
520
958
  }
521
959
  useAlignmentGuides.getState().clear()
@@ -530,6 +968,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
530
968
  window.removeEventListener('keyup', onKeyUp)
531
969
  emitter.off('grid:move', onGridMove)
532
970
  emitter.off('grid:click', commitAtCursor)
971
+ window.removeEventListener('pointerup', onPlacementDragPointerUp)
533
972
  for (const kind of CLICK_TRIGGER_KINDS) {
534
973
  const key = `${kind}:click` as ClickKey
535
974
  emitter.off(key, commitAtCursor as never)
@@ -544,39 +983,55 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
544
983
  const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
545
984
  if (!(committed || isNew || finalisedBy2D)) {
546
985
  useLiveTransforms.getState().clear(node.id)
547
- sceneRegistry.nodes
548
- .get(node.id)
549
- ?.position.set(...getVisualPosition(originalPosition, originalRotationY))
986
+ clearConnectivityOverrides()
987
+ clearParentFramePreview()
988
+ applyMeshPose(originalPosition, originalRotationY)
550
989
  markMovedNodeDirty()
551
990
  }
552
991
  useScene.temporal.getState().resume()
553
992
  }
554
993
  }, [
555
994
  boxDimensions,
995
+ dragBounds,
996
+ canonicalPositionFromPlan,
997
+ parentFrame,
998
+ frameParent,
999
+ cursorAttached,
1000
+ portSnapConfig,
1001
+ groupMoveSnapConfig,
556
1002
  exitMoveMode,
557
1003
  isFreshPlacement,
558
1004
  node,
559
1005
  originalPosition,
1006
+ originalPlanPosition,
560
1007
  originalRotationY,
1008
+ previewRotationY,
561
1009
  revealFreshPlacement,
562
1010
  useAbsoluteCursorPlacement,
1011
+ visualPositionFor,
563
1012
  ])
564
1013
 
565
- // Snapshot the scene once at drag-start bounds depend on `node` (locked
566
- // for the lifetime of this tool) and any sibling state the kind reads. If a
567
- // future kind needs live sibling state mid-drag, switch to a subscribed
568
- // selector; for v1 (elevator shaft height from level set) start-time is
569
- // correct and avoids subscribing the whole `nodes` map.
570
- const dragBounds = useMemo(
571
- () =>
572
- nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) ??
573
- null,
574
- [node],
575
- )
1014
+ // Forward-facing triangle for the footprint-box branch (item / shelf / column
1015
+ // anything that renders `<PlacementBox>`). Published to the editor-side
1016
+ // overlay; the `<DragBoundingBox>` branch (e.g. stair, which has no centred
1017
+ // footprint) publishes its own. The box is centred on `cursorPosition`, so
1018
+ // the footprint centre is the origin. Clears on unmount.
1019
+ const facing = resolveFacingIndicator(node.type)
1020
+ useEffect(() => {
1021
+ if (!previewVisible || !facing || !boxDimensions) return
1022
+ useFacingPose.getState().set({
1023
+ position: cursorPosition,
1024
+ rotationY: cursorRotationY,
1025
+ depth: boxDimensions[2],
1026
+ center: dragBounds?.center ? [dragBounds.center[0], dragBounds.center[2]] : undefined,
1027
+ reversed: facing.reversed,
1028
+ })
1029
+ }, [previewVisible, facing, boxDimensions, dragBounds, cursorPosition, cursorRotationY])
1030
+ useEffect(() => () => useFacingPose.getState().clear(), [])
576
1031
 
577
1032
  if (!previewVisible) return null
578
1033
 
579
- if (boxDimensions) {
1034
+ if (boxDimensions && !dragBounds?.center) {
580
1035
  return (
581
1036
  <PlacementBox
582
1037
  dimensions={boxDimensions}
@@ -587,11 +1042,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
587
1042
  )
588
1043
  }
589
1044
 
1045
+ const dragCenterPosition =
1046
+ dragBounds?.center && dragBounds.size
1047
+ ? offsetPlanPositionByLocalCenter(cursorPosition, dragBounds.center, cursorRotationY)
1048
+ : cursorPosition
1049
+
590
1050
  return (
591
1051
  <>
592
- <CursorSphere color="#a78bfa" height={2.5} position={cursorPosition} />
1052
+ <CursorSphere color="#a78bfa" height={2.5} position={dragCenterPosition} />
593
1053
  <DragBoundingBox
1054
+ center={dragBounds?.center}
594
1055
  centerY={dragBounds?.centerY}
1056
+ color={boxDimensions ? (valid ? VALID_COLOR : INVALID_COLOR) : undefined}
595
1057
  nodeId={node.id}
596
1058
  position={cursorPosition}
597
1059
  rotationY={cursorRotationY}