@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
@@ -0,0 +1,397 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ bboxCornerAnchors,
5
+ collectAlignmentAnchors,
6
+ pauseSceneHistory,
7
+ pauseSpaceDetection,
8
+ resolveAlignment,
9
+ resumeSceneHistory,
10
+ resumeSpaceDetection,
11
+ useLiveNodeOverrides,
12
+ useLiveTransforms,
13
+ useScene,
14
+ } from '@pascal-app/core'
15
+ import { useViewer } from '@pascal-app/viewer'
16
+ import { type Camera, Plane, type Raycaster, Vector2, Vector3 } from 'three'
17
+ import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
18
+ import { isHistoryShortcut } from '../../lib/history'
19
+ import { sfxEmitter } from '../../lib/sfx-bus'
20
+ import useAlignmentGuides from '../../store/use-alignment-guides'
21
+ import useEditor, {
22
+ isAlignmentGuideActive,
23
+ isGridSnapActive,
24
+ isMagneticSnapActive,
25
+ } from '../../store/use-editor'
26
+ import useInteractionScope from '../../store/use-interaction-scope'
27
+ import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
28
+ import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
29
+ import { startGroupPickUp } from './group-actions'
30
+ import {
31
+ classifyParticipant,
32
+ collectParticipants,
33
+ computeGroupBox,
34
+ expandToComponent,
35
+ levelFrame,
36
+ participantExtents,
37
+ rotateGroupSnapshots,
38
+ translateGroupPatches,
39
+ type Vec2,
40
+ } from './group-transform-shared'
41
+ import { swallowNextClick } from './handles/use-handle-drag'
42
+
43
+ // 3D sibling of the 2D floorplan group move (and successor of the removed
44
+ // group-move gizmo cross): pressing any selected element's body in a
45
+ // multi-selection and dragging past the threshold slides the whole selection
46
+ // on the ground plane; a plain click (no drag) enters the group pick-up,
47
+ // parity with the single-item click-to-move. Shares the group participant
48
+ // snapshot, welded junctions, snapping entry points, live override previews,
49
+ // and single-undo commit with the 2D session.
50
+
51
+ // Figma-style alignment-snap threshold (meters) — same pull distance as the
52
+ // single-node registry move.
53
+ const ALIGNMENT_THRESHOLD_M = 0.08
54
+ const DRAG_THRESHOLD_PX = 4
55
+
56
+ /**
57
+ * Arm a 3D group move from a pointer-down on `nodeId`. Returns false
58
+ * (attaching nothing) unless the node is a transformable member of a
59
+ * multi-selection.
60
+ */
61
+ export function armGroupMove3d(args: {
62
+ nodeId: AnyNodeId
63
+ clientX: number
64
+ clientY: number
65
+ pointerId: number
66
+ nativeEvent: PointerEvent
67
+ camera: Camera
68
+ raycaster: Raycaster
69
+ domElement: HTMLCanvasElement
70
+ }): boolean {
71
+ const { nodeId, clientX, clientY, pointerId, camera, raycaster, domElement } = args
72
+ const { selectedIds, levelId } = useViewer.getState().selection
73
+ if (selectedIds.length < 2 || !selectedIds.includes(nodeId)) return false
74
+ const sceneNodes = useScene.getState().nodes
75
+ if (classifyParticipant(sceneNodes[nodeId], levelId, sceneNodes) === null) return false
76
+
77
+ suppressBoxSelectForPointer(args.nativeEvent)
78
+
79
+ const ndc = new Vector2()
80
+ const setNDC = (x: number, y: number) => {
81
+ const rect = domElement.getBoundingClientRect()
82
+ ndc.set(((x - rect.left) / rect.width) * 2 - 1, -((y - rect.top) / rect.height) * 2 + 1)
83
+ }
84
+
85
+ type Session = {
86
+ starts: ReturnType<typeof collectParticipants>['starts']
87
+ links: ReturnType<typeof collectParticipants>['links']
88
+ affectedIds: AnyNodeId[]
89
+ candidates: ReturnType<typeof collectAlignmentAnchors>
90
+ restAnchors: ReturnType<typeof bboxCornerAnchors>
91
+ restCenter: Vec2
92
+ plane: Plane
93
+ startLocal: Vector3
94
+ frameInv: ReturnType<typeof levelFrame>['inverse']
95
+ lastDelta: Vec2 | null
96
+ }
97
+ let session: Session | null = null
98
+
99
+ const engage = (): Session | null => {
100
+ const nodes = useScene.getState().nodes
101
+ const participantIds = selectedIds.filter(
102
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
103
+ )
104
+ // Move the full connected wall/fence component, mirroring the 2D session.
105
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
106
+ const { starts, links } = collectParticipants(fullIds, nodes, levelId)
107
+ if (starts.length === 0) return null
108
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
109
+
110
+ // Horizontal drag plane at the group's base; placements live in the level
111
+ // frame, so world-space plane hits convert through it (a rotated building
112
+ // would otherwise drift off-axis from the cursor).
113
+ const restBox = computeGroupBox(fullIds)
114
+ if (!restBox) return null
115
+ const plane = new Plane(new Vector3(0, 1, 0), -restBox.min.y)
116
+ const { inverse: frameInv } = levelFrame(levelId)
117
+
118
+ setNDC(clientX, clientY)
119
+ raycaster.setFromCamera(ndc, camera)
120
+ const hit = new Vector3()
121
+ if (!raycaster.ray.intersectPlane(plane, hit)) return null
122
+ const startLocal = hit.clone().applyMatrix4(frameInv)
123
+
124
+ // Alignment candidates — anchors of everything OUTSIDE the moving set,
125
+ // gathered once at drag start. The group aligns as one rigid footprint:
126
+ // its bbox corners + center are the moving anchors.
127
+ const movingIdSet = new Set<string>(affectedIds)
128
+ const staticNodes: Record<string, AnyNode> = {}
129
+ for (const [nid, n] of Object.entries(nodes)) {
130
+ if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
131
+ }
132
+ const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
133
+ const boxMin = restBox.min.clone().applyMatrix4(frameInv)
134
+ const boxMax = restBox.max.clone().applyMatrix4(frameInv)
135
+ const restAnchors = bboxCornerAnchors(
136
+ 'group-move',
137
+ Math.min(boxMin.x, boxMax.x),
138
+ Math.min(boxMin.z, boxMax.z),
139
+ Math.max(boxMin.x, boxMax.x),
140
+ Math.max(boxMin.z, boxMax.z),
141
+ )
142
+
143
+ for (const id of affectedIds) {
144
+ useLiveTransforms.getState().clear(id)
145
+ }
146
+
147
+ domElement.style.cursor = 'grabbing'
148
+ sfxEmitter.emit('sfx:item-pick')
149
+ swallowNextClick()
150
+ useViewer.getState().setInputDragging(true)
151
+ pauseSceneHistory(useScene)
152
+ useInteractionScope.getState().begin({
153
+ kind: 'handle-drag',
154
+ nodeId,
155
+ handle: GROUP_MOVE_DRAG_LABEL,
156
+ })
157
+ // Rotation pivot for mid-drag R/T — the participant DATA extents' center.
158
+ const ext = participantExtents(starts)
159
+ const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0]
160
+
161
+ return {
162
+ starts,
163
+ links,
164
+ affectedIds,
165
+ candidates,
166
+ restAnchors,
167
+ restCenter,
168
+ plane,
169
+ startLocal,
170
+ frameInv,
171
+ lastDelta: null,
172
+ }
173
+ }
174
+
175
+ const applyMove = (e: PointerEvent, s: Session) => {
176
+ setNDC(e.clientX, e.clientY)
177
+ raycaster.setFromCamera(ndc, camera)
178
+ const moveHit = new Vector3()
179
+ if (!raycaster.ray.intersectPlane(s.plane, moveHit)) return
180
+ const moveLocal = moveHit.applyMatrix4(s.frameInv)
181
+
182
+ // Snap the slide DELTA to the active grid step so the selection's internal
183
+ // layout stays intact. Mode-driven: the `handle-drag` scope resolves to
184
+ // the item snap context, so Shift cycles the mode mid-drag.
185
+ const step = useEditor.getState().gridSnapStep
186
+ const snap = isGridSnapActive() && step > 0
187
+ let dx = snap
188
+ ? Math.round((moveLocal.x - s.startLocal.x) / step) * step
189
+ : moveLocal.x - s.startLocal.x
190
+ let dz = snap
191
+ ? Math.round((moveLocal.z - s.startLocal.z) / step) * step
192
+ : moveLocal.z - s.startLocal.z
193
+
194
+ // Figma-style alignment layered on top: guides display in every mode
195
+ // except Off; the magnetic pull applies only in 'lines' mode.
196
+ if (isAlignmentGuideActive() && s.candidates.length > 0 && s.restAnchors.length > 0) {
197
+ const result = resolveAlignment({
198
+ moving: s.restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
199
+ candidates: s.candidates,
200
+ threshold: ALIGNMENT_THRESHOLD_M,
201
+ })
202
+ if (result.snap && isMagneticSnapActive()) {
203
+ dx += result.snap.dx
204
+ dz += result.snap.dz
205
+ }
206
+ useAlignmentGuides.getState().set(result.guides)
207
+ } else {
208
+ useAlignmentGuides.getState().clear()
209
+ }
210
+
211
+ applyDelta(s, dx, dz)
212
+ }
213
+
214
+ const applyDelta = (s: Session, dx: number, dz: number) => {
215
+ // Ticker on each delta change — parity with the single-node move SFX.
216
+ if (!s.lastDelta || s.lastDelta[0] !== dx || s.lastDelta[1] !== dz) {
217
+ sfxEmitter.emit('sfx:grid-snap')
218
+ s.lastDelta = [dx, dz]
219
+ }
220
+
221
+ const entries = translateGroupPatches(s.starts, s.links, dx, dz)
222
+ const patchById = new Map(entries)
223
+ const liveTransforms = useLiveTransforms.getState()
224
+ for (const start of s.starts) {
225
+ if (start.kind === 'scalar') {
226
+ const patch = patchById.get(start.id)
227
+ if (patch) {
228
+ liveTransforms.set(start.id, {
229
+ position: patch.position as [number, number, number],
230
+ rotation: start.rotation,
231
+ })
232
+ }
233
+ }
234
+ useScene.getState().markDirty(start.id)
235
+ }
236
+ for (const l of s.links) {
237
+ useScene.getState().markDirty(l.id)
238
+ }
239
+ useLiveNodeOverrides.getState().setMany(entries)
240
+ // The 2D dashed group bbox rides the same delta in split view.
241
+ useFloorplanGroupDrag.getState().set([dx, dz])
242
+ }
243
+
244
+ // Mid-drag R/T: rotate the SNAPSHOTS around the rest pivot and re-apply the
245
+ // current delta — the carried group turns exactly like the idle keyboard
246
+ // rotate, and the commit stays a single updateNodes.
247
+ const rotateSession = (s: Session, direction: 1 | -1) => {
248
+ const rotated = rotateGroupSnapshots(
249
+ s.starts,
250
+ s.links,
251
+ { x: s.restCenter[0], z: s.restCenter[1] },
252
+ -direction * (Math.PI / 4),
253
+ )
254
+ s.starts = rotated.starts
255
+ s.links = rotated.links
256
+ const ext = participantExtents(rotated.starts)
257
+ if (ext) {
258
+ s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
259
+ }
260
+ sfxEmitter.emit('sfx:item-rotate')
261
+ applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0)
262
+ }
263
+
264
+ const clearLivePreviews = (s: Session) => {
265
+ const overrides = useLiveNodeOverrides.getState()
266
+ const liveTransforms = useLiveTransforms.getState()
267
+ for (const id of s.affectedIds) {
268
+ overrides.clear(id)
269
+ liveTransforms.clear(id)
270
+ useScene.getState().markDirty(id)
271
+ }
272
+ }
273
+
274
+ const removeListeners = () => {
275
+ window.removeEventListener('pointermove', onMove)
276
+ window.removeEventListener('pointerup', onUp, true)
277
+ window.removeEventListener('pointercancel', onPointerCancel)
278
+ window.removeEventListener('keydown', onKeyDown, true)
279
+ }
280
+
281
+ // History resume is NOT here — it pairs one-to-one with the
282
+ // `pauseSceneHistory` in `engage()`, on the commit and cancel paths.
283
+ const teardown = () => {
284
+ removeListeners()
285
+ if (domElement.style.cursor === 'grabbing') domElement.style.cursor = ''
286
+ useAlignmentGuides.getState().clear()
287
+ // Deferred so the canvas pointerup later in this same dispatch still sees
288
+ // the drag as active (see onUp).
289
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
290
+ useInteractionScope
291
+ .getState()
292
+ .endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_MOVE_DRAG_LABEL)
293
+ useFloorplanGroupDrag.getState().set(null)
294
+ }
295
+
296
+ const onMove = (e: PointerEvent) => {
297
+ if (e.pointerId !== pointerId) return
298
+ if (!session) {
299
+ if (Math.hypot(e.clientX - clientX, e.clientY - clientY) < DRAG_THRESHOLD_PX) return
300
+ session = engage()
301
+ if (!session) {
302
+ removeListeners()
303
+ return
304
+ }
305
+ }
306
+ applyMove(e, session)
307
+ }
308
+
309
+ // Registered in CAPTURE phase so this runs before the canvas handlers:
310
+ // `use-node-events` synthesizes a selection click on EVERY pointerup,
311
+ // suppressed only while `inputDragging` is set — so the gesture must keep
312
+ // it raised through this event's dispatch (released on a 0ms timer) or the
313
+ // release re-routes selection to whatever sits under the cursor. Plain
314
+ // stopPropagation would ALSO kill the window bubble listeners that clear
315
+ // the box-select pointer suppression, leaving the marquee dead.
316
+ const onUp = (e: PointerEvent) => {
317
+ if (e.pointerId !== pointerId) return
318
+ if (!session) {
319
+ // Plain click — enter the group pick-up, parity with the single-item
320
+ // click-to-move. Eat the click so the selection manager's click
321
+ // handling doesn't collapse the multi-selection underneath it.
322
+ removeListeners()
323
+ swallowNextClick()
324
+ useViewer.getState().setInputDragging(true)
325
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
326
+ startGroupPickUp()
327
+ return
328
+ }
329
+ swallowNextClick()
330
+ sfxEmitter.emit('sfx:item-place')
331
+ const overrides = useLiveNodeOverrides.getState()
332
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
333
+ for (const id of session.affectedIds) {
334
+ const patch = overrides.get(id)
335
+ if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
336
+ }
337
+ // Resume before the commit so the single batched `updateNodes` is the one
338
+ // tracked set — collapsing the whole group move into one undo step.
339
+ // Group transforms move existing structure rigidly — the wall-driven room
340
+ // auto-detection must not re-create floors/ceilings for the walls' new
341
+ // positions (that belongs to wall building/editing). Paused around the
342
+ // commit; the sync rolls its baseline forward for paused changes.
343
+ pauseSpaceDetection()
344
+ resumeSceneHistory(useScene)
345
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
346
+ resumeSpaceDetection()
347
+ clearLivePreviews(session)
348
+ session = null
349
+ teardown()
350
+ }
351
+
352
+ const cancel = () => {
353
+ if (session) {
354
+ clearLivePreviews(session)
355
+ resumeSceneHistory(useScene)
356
+ session = null
357
+ }
358
+ teardown()
359
+ }
360
+
361
+ const onPointerCancel = (e: PointerEvent) => {
362
+ if (e.pointerId !== pointerId) return
363
+ cancel()
364
+ }
365
+
366
+ // Capture phase so the drag's Escape / R / T win over the global
367
+ // `use-keyboard` arms, which would otherwise act on stale store state
368
+ // (or clear the multi-selection) mid-session.
369
+ const onKeyDown = (e: KeyboardEvent) => {
370
+ const key = e.key.toLowerCase()
371
+ if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
372
+ if (!session) return
373
+ e.preventDefault()
374
+ e.stopPropagation()
375
+ rotateSession(session, key === 'r' ? 1 : -1)
376
+ return
377
+ }
378
+ if (e.key === 'Delete' || e.key === 'Backspace') {
379
+ // Deleting mid-move: revert the session first, then let the global
380
+ // Delete arm remove the selection — no dangling carry.
381
+ cancel()
382
+ return
383
+ }
384
+ // ⌘Z mid-move cancels like Escape — never a history jump under a live pointer.
385
+ if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
386
+ e.preventDefault()
387
+ e.stopPropagation()
388
+ swallowNextClick()
389
+ cancel()
390
+ }
391
+
392
+ window.addEventListener('pointermove', onMove)
393
+ window.addEventListener('pointerup', onUp, true)
394
+ window.addEventListener('pointercancel', onPointerCancel)
395
+ window.addEventListener('keydown', onKeyDown, true)
396
+ return true
397
+ }