@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,701 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ bboxCornerAnchors,
7
+ collectAlignmentAnchors,
8
+ DEFAULT_ANGLE_STEP,
9
+ type FloorplanPalette,
10
+ pauseSceneHistory,
11
+ pauseSpaceDetection,
12
+ resumeSceneHistory,
13
+ resumeSpaceDetection,
14
+ useLiveNodeOverrides,
15
+ useLiveTransforms,
16
+ useScene,
17
+ } from '@pascal-app/core'
18
+ import { useViewer } from '@pascal-app/viewer'
19
+ import { memo, type PointerEvent as ReactPointerEvent, useEffect, useMemo, useState } from 'react'
20
+ import { create } from 'zustand'
21
+ import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
22
+ import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
23
+ import { clientToPlan } from '../../lib/floorplan/plan-coords'
24
+ import { isHistoryShortcut } from '../../lib/history'
25
+ import { sfxEmitter } from '../../lib/sfx-bus'
26
+ import useAlignmentGuides from '../../store/use-alignment-guides'
27
+ import useEditor, {
28
+ isAlignmentGuideActive,
29
+ isGridSnapActive,
30
+ isMagneticSnapActive,
31
+ } from '../../store/use-editor'
32
+ import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
33
+ import {
34
+ classifyParticipant,
35
+ collectParticipants,
36
+ computeGroupBox,
37
+ expandToComponent,
38
+ levelFrame,
39
+ participantExtents,
40
+ rotateGroupPatches,
41
+ rotateGroupSnapshots,
42
+ translateGroupPatches,
43
+ type Vec2,
44
+ } from '../editor/group-transform-shared'
45
+ import { swallowNextClick } from '../editor/handles/use-handle-drag'
46
+ import { useMeshSettleEpoch } from '../editor/use-mesh-settle-epoch'
47
+
48
+ // 2D sibling of the 3D body-drag group move (`group-move-3d.ts`): dragging
49
+ // any selected element of a multi-selection slides the whole selection
50
+ // rigidly (Photoshop semantics). Both share the participant snapshot +
51
+ // translate math, the live preview channel (`useLiveNodeOverrides.setMany` +
52
+ // welded `LinkedNeighbor` endpoints), the snapping entry points (grid step
53
+ // via `isGridSnapActive`, Figma alignment via the shared resolver), and the
54
+ // single-undo commit (history pause → one `updateNodes` → resume).
55
+
56
+ // Same engage threshold as the layer's Cmd-drag direct move.
57
+ const DRAG_THRESHOLD_PX = 4
58
+
59
+ // Live plan-frame drag delta / rotation, published so the dashed group bbox
60
+ // overlay rides along without re-rendering the whole registry layer per tick.
61
+ type FloorplanGroupDragState = {
62
+ delta: Vec2 | null
63
+ rotation: { pivotX: number; pivotZ: number; angle: number } | null
64
+ set: (delta: Vec2 | null) => void
65
+ setRotation: (rotation: FloorplanGroupDragState['rotation']) => void
66
+ }
67
+ export const useFloorplanGroupDrag = create<FloorplanGroupDragState>((set) => ({
68
+ delta: null,
69
+ rotation: null,
70
+ set: (delta) => set({ delta }),
71
+ setRotation: (rotation) => set({ rotation }),
72
+ }))
73
+
74
+ /**
75
+ * Try to start a 2D group move for a pointer-down on `nodeId`. Returns false
76
+ * (attaching nothing) unless the node is a transformable member of a
77
+ * multi-selection — the caller falls through to its single-node behavior.
78
+ *
79
+ * `immediate` engages the session on pointer-down (move-handle dot semantics:
80
+ * the dot is an explicit move control); otherwise the session arms and only
81
+ * engages once the pointer travels past the drag threshold, and a plain
82
+ * click falls through to `onClickFallthrough` (the collapse-to-single
83
+ * selection click).
84
+ */
85
+ export function startFloorplanGroupMove(
86
+ nodeId: AnyNodeId,
87
+ event: { clientX: number; clientY: number; pointerId: number },
88
+ opts: { immediate?: boolean; onClickFallthrough?: () => void } = {},
89
+ ): boolean {
90
+ const { selectedIds, levelId } = useViewer.getState().selection
91
+ if (selectedIds.length < 2 || !selectedIds.includes(nodeId)) return false
92
+ const sceneNodes = useScene.getState().nodes
93
+ // The pressed element itself must transform — dragging a selected door /
94
+ // window (which rides its host wall) keeps today's single-node behavior.
95
+ if (classifyParticipant(sceneNodes[nodeId], levelId, sceneNodes) === null) return false
96
+ const startPlan = clientToPlan(event.clientX, event.clientY)
97
+ if (!startPlan) return false
98
+
99
+ const startX = event.clientX
100
+ const startY = event.clientY
101
+ const pointerId = event.pointerId
102
+
103
+ type Session = {
104
+ starts: ReturnType<typeof collectParticipants>['starts']
105
+ links: ReturnType<typeof collectParticipants>['links']
106
+ affectedIds: AnyNodeId[]
107
+ candidates: ReturnType<typeof collectAlignmentAnchors>
108
+ restAnchors: ReturnType<typeof bboxCornerAnchors>
109
+ restCenter: Vec2
110
+ lastDelta: Vec2 | null
111
+ }
112
+ let session: Session | null = null
113
+
114
+ const engage = (): Session | null => {
115
+ const nodes = useScene.getState().nodes
116
+ const participantIds = selectedIds.filter(
117
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
118
+ )
119
+ // Move the full connected wall/fence component, mirroring the 3D gizmo.
120
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
121
+ const { starts, links } = collectParticipants(fullIds, nodes, levelId)
122
+ if (starts.length === 0) return null
123
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
124
+
125
+ // Alignment candidates — anchors of everything OUTSIDE the moving set
126
+ // (selection + welded neighbours), gathered once at drag start.
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
+
134
+ // The group aligns as one rigid footprint: its bbox corners + center are
135
+ // the moving anchors. `computeGroupBox` is world-space (the 3D scene stays
136
+ // mounted under every view mode); plan coords are level-frame, so convert.
137
+ const restBox = computeGroupBox(fullIds)
138
+ const { inverse: frameInv } = levelFrame(levelId)
139
+ const boxMin = restBox ? restBox.min.clone().applyMatrix4(frameInv) : null
140
+ const boxMax = restBox ? restBox.max.clone().applyMatrix4(frameInv) : null
141
+ const restAnchors =
142
+ boxMin && boxMax
143
+ ? bboxCornerAnchors(
144
+ 'group-move',
145
+ Math.min(boxMin.x, boxMax.x),
146
+ Math.min(boxMin.z, boxMax.z),
147
+ Math.max(boxMin.x, boxMax.x),
148
+ Math.max(boxMin.z, boxMax.z),
149
+ )
150
+ : []
151
+
152
+ for (const id of affectedIds) {
153
+ useLiveTransforms.getState().clear(id)
154
+ }
155
+
156
+ document.body.style.cursor = 'grabbing'
157
+ sfxEmitter.emit('sfx:item-pick')
158
+ swallowNextClick()
159
+ useViewer.getState().setInputDragging(true)
160
+ pauseSceneHistory(useScene)
161
+ useInteractionScope.getState().begin({
162
+ kind: 'handle-drag',
163
+ nodeId,
164
+ handle: GROUP_MOVE_DRAG_LABEL,
165
+ })
166
+ // Rotation pivot for mid-drag R/T — the participant DATA extents' center
167
+ // (stable across the drag; rotations re-seed around the same point).
168
+ const ext = participantExtents(starts)
169
+ const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0]
170
+
171
+ return { starts, links, affectedIds, candidates, restAnchors, restCenter, lastDelta: null }
172
+ }
173
+
174
+ const applyMove = (e: PointerEvent, s: Session) => {
175
+ const plan = clientToPlan(e.clientX, e.clientY)
176
+ if (!plan) return
177
+ // Snap the slide DELTA to the active grid step so the selection's
178
+ // internal layout stays intact — grid-aligned members stay aligned.
179
+ // Mode-driven like the 3D group move: the `handle-drag` scope resolves
180
+ // to the item snap context, so Shift cycles the mode mid-drag.
181
+ const step = useEditor.getState().gridSnapStep
182
+ const snap = isGridSnapActive() && step > 0
183
+ let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0]
184
+ let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
185
+
186
+ // Figma-style alignment layered on top: guides display in every mode
187
+ // except Off; the magnetic pull applies only in 'lines' mode.
188
+ if (isAlignmentGuideActive() && s.candidates.length > 0 && s.restAnchors.length > 0) {
189
+ const proposed: [number, number] = [startPlan[0] + dx, startPlan[1] + dz]
190
+ const aligned = applyFloorplanAlignment(
191
+ proposed,
192
+ s.restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
193
+ s.candidates,
194
+ { applySnap: isMagneticSnapActive() },
195
+ )
196
+ dx = aligned.point[0] - startPlan[0]
197
+ dz = aligned.point[1] - startPlan[1]
198
+ } else {
199
+ useAlignmentGuides.getState().clear()
200
+ }
201
+
202
+ applyDelta(s, dx, dz)
203
+ }
204
+
205
+ const applyDelta = (s: Session, dx: number, dz: number) => {
206
+ // Ticker on each delta change — parity with the 3D group move's SFX.
207
+ if (!s.lastDelta || s.lastDelta[0] !== dx || s.lastDelta[1] !== dz) {
208
+ sfxEmitter.emit('sfx:grid-snap')
209
+ s.lastDelta = [dx, dz]
210
+ }
211
+
212
+ const entries = translateGroupPatches(s.starts, s.links, dx, dz)
213
+ const patchById = new Map(entries)
214
+ const liveTransforms = useLiveTransforms.getState()
215
+ for (const start of s.starts) {
216
+ if (start.kind === 'scalar') {
217
+ const patch = patchById.get(start.id)
218
+ if (patch) {
219
+ liveTransforms.set(start.id, {
220
+ position: patch.position as [number, number, number],
221
+ rotation: start.rotation,
222
+ })
223
+ }
224
+ }
225
+ useScene.getState().markDirty(start.id)
226
+ }
227
+ for (const l of s.links) {
228
+ useScene.getState().markDirty(l.id)
229
+ }
230
+ useLiveNodeOverrides.getState().setMany(entries)
231
+ useFloorplanGroupDrag.getState().set([dx, dz])
232
+ }
233
+
234
+ // Mid-drag R/T: rotate the SNAPSHOTS around the rest pivot and re-apply the
235
+ // current delta — the carried group turns exactly like the idle keyboard
236
+ // rotate, and the commit stays a single updateNodes.
237
+ const rotateSession = (s: Session, direction: 1 | -1) => {
238
+ const rotated = rotateGroupSnapshots(
239
+ s.starts,
240
+ s.links,
241
+ { x: s.restCenter[0], z: s.restCenter[1] },
242
+ -direction * (Math.PI / 4),
243
+ )
244
+ s.starts = rotated.starts
245
+ s.links = rotated.links
246
+ const ext = participantExtents(rotated.starts)
247
+ if (ext) {
248
+ s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
249
+ }
250
+ sfxEmitter.emit('sfx:item-rotate')
251
+ applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0)
252
+ }
253
+
254
+ const clearLivePreviews = (s: Session) => {
255
+ const overrides = useLiveNodeOverrides.getState()
256
+ const liveTransforms = useLiveTransforms.getState()
257
+ for (const id of s.affectedIds) {
258
+ overrides.clear(id)
259
+ liveTransforms.clear(id)
260
+ useScene.getState().markDirty(id)
261
+ }
262
+ }
263
+
264
+ const removeListeners = () => {
265
+ window.removeEventListener('pointermove', onMove)
266
+ window.removeEventListener('pointerup', onUp, true)
267
+ window.removeEventListener('pointercancel', onPointerCancel)
268
+ window.removeEventListener('keydown', onKeyDown, true)
269
+ }
270
+
271
+ // History resume is NOT here — it pairs exactly one-to-one with the
272
+ // `pauseSceneHistory` in `engage()`, on the commit and cancel paths.
273
+ const teardown = () => {
274
+ removeListeners()
275
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
276
+ useAlignmentGuides.getState().clear()
277
+ // Deferred so a canvas pointerup later in this same dispatch still sees
278
+ // the drag as active (split view — see onUp).
279
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
280
+ useInteractionScope
281
+ .getState()
282
+ .endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_MOVE_DRAG_LABEL)
283
+ useFloorplanGroupDrag.getState().set(null)
284
+ }
285
+
286
+ const onMove = (e: PointerEvent) => {
287
+ if (e.pointerId !== pointerId) return
288
+ if (!session) {
289
+ if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX) return
290
+ session = engage()
291
+ if (!session) {
292
+ removeListeners()
293
+ return
294
+ }
295
+ }
296
+ applyMove(e, session)
297
+ }
298
+
299
+ // Capture-phase registration: in split view the release can land over the
300
+ // 3D canvas, whose use-node-events synthesizes a selection click on every
301
+ // pointerup — suppressed while `inputDragging` is raised, which teardown
302
+ // therefore lowers on a 0ms timer (after this event's dispatch).
303
+ const onUp = (e: PointerEvent) => {
304
+ if (e.pointerId !== pointerId) return
305
+ if (!session) {
306
+ removeListeners()
307
+ opts.onClickFallthrough?.()
308
+ return
309
+ }
310
+ // Eat the click that follows pointer-up so the background click handler
311
+ // doesn't clear the multi-selection the drag is preserving.
312
+ swallowNextClick()
313
+ sfxEmitter.emit('sfx:item-place')
314
+ const overrides = useLiveNodeOverrides.getState()
315
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
316
+ for (const id of session.affectedIds) {
317
+ const patch = overrides.get(id)
318
+ if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
319
+ }
320
+ // Resume before the commit so the single batched `updateNodes` is the one
321
+ // tracked set — collapsing the whole group move into one undo step.
322
+ // Group transforms move existing structure rigidly — the wall-driven room
323
+ // auto-detection must not re-create floors/ceilings for the walls' new
324
+ // positions (that belongs to wall building/editing). Paused around the
325
+ // commit; the sync rolls its baseline forward for paused changes.
326
+ pauseSpaceDetection()
327
+ resumeSceneHistory(useScene)
328
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
329
+ resumeSpaceDetection()
330
+ clearLivePreviews(session)
331
+ session = null
332
+ teardown()
333
+ }
334
+
335
+ const cancel = () => {
336
+ if (session) {
337
+ clearLivePreviews(session)
338
+ resumeSceneHistory(useScene)
339
+ session = null
340
+ }
341
+ teardown()
342
+ }
343
+
344
+ const onPointerCancel = (e: PointerEvent) => {
345
+ if (e.pointerId !== pointerId) return
346
+ cancel()
347
+ }
348
+
349
+ // Capture phase so the drag's Escape wins over the global `use-keyboard`
350
+ // Escape arm (registered earlier on window in the bubble phase), which
351
+ // would otherwise clear the multi-selection mid-cancel.
352
+ const onKeyDown = (e: KeyboardEvent) => {
353
+ const key = e.key.toLowerCase()
354
+ if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
355
+ if (!session) return
356
+ e.preventDefault()
357
+ e.stopPropagation()
358
+ rotateSession(session, key === 'r' ? 1 : -1)
359
+ return
360
+ }
361
+ if (e.key === 'Delete' || e.key === 'Backspace') {
362
+ // Deleting mid-move: revert the session first, then let the global
363
+ // Delete arm remove the selection — no dangling carry.
364
+ cancel()
365
+ return
366
+ }
367
+ // ⌘Z mid-gesture cancels like Escape — never a history jump under a live pointer.
368
+ if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
369
+ e.preventDefault()
370
+ e.stopPropagation()
371
+ swallowNextClick()
372
+ cancel()
373
+ }
374
+
375
+ window.addEventListener('pointermove', onMove)
376
+ window.addEventListener('pointerup', onUp, true)
377
+ window.addEventListener('pointercancel', onPointerCancel)
378
+ window.addEventListener('keydown', onKeyDown, true)
379
+
380
+ if (opts.immediate) {
381
+ session = engage()
382
+ if (!session) {
383
+ removeListeners()
384
+ return false
385
+ }
386
+ }
387
+ return true
388
+ }
389
+
390
+ /**
391
+ * 2D group rotate, driven from the dashed selection box's corner handles —
392
+ * the floor-plan sibling of the 3D `GroupRotateHandle`. The group spins
393
+ * rigidly around its data-extents center; 15° increments by default, Shift
394
+ * for free rotation, one `updateNodes` on release (one undo step).
395
+ */
396
+ export function startFloorplanGroupRotate(event: {
397
+ clientX: number
398
+ clientY: number
399
+ pointerId: number
400
+ }): boolean {
401
+ const { selectedIds, levelId } = useViewer.getState().selection
402
+ if (selectedIds.length < 2) return false
403
+ const nodes = useScene.getState().nodes
404
+ const participantIds = selectedIds.filter(
405
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
406
+ )
407
+ if (participantIds.length === 0) return false
408
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
409
+ const { starts, links } = collectParticipants(fullIds, nodes, levelId)
410
+ if (starts.length === 0) return false
411
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
412
+ const ext = participantExtents(starts)
413
+ if (!ext) return false
414
+ const pivot = { x: (ext.minX + ext.maxX) / 2, z: (ext.minZ + ext.maxZ) / 2 }
415
+ const startPlan = clientToPlan(event.clientX, event.clientY)
416
+ if (!startPlan) return false
417
+ // Bearing around the pivot in the plan frame — the same atan2 x→z sense
418
+ // `rotateGroupPatches` orbits in.
419
+ const angleOf = (p: readonly [number, number]) => Math.atan2(p[1] - pivot.z, p[0] - pivot.x)
420
+ const initialAngle = angleOf(startPlan)
421
+ const pointerId = event.pointerId
422
+
423
+ for (const id of affectedIds) {
424
+ useLiveTransforms.getState().clear(id)
425
+ }
426
+ document.body.style.cursor = 'grabbing'
427
+ sfxEmitter.emit('sfx:item-pick')
428
+ swallowNextClick()
429
+ useViewer.getState().setInputDragging(true)
430
+ pauseSceneHistory(useScene)
431
+ useInteractionScope.getState().begin({
432
+ kind: 'handle-drag',
433
+ nodeId: (participantIds[0] ?? '') as AnyNodeId,
434
+ handle: GROUP_ROTATE_DRAG_LABEL,
435
+ })
436
+
437
+ const onMove = (e: PointerEvent) => {
438
+ if (e.pointerId !== pointerId) return
439
+ const plan = clientToPlan(e.clientX, e.clientY)
440
+ if (!plan) return
441
+ let delta = angleOf([plan[0], plan[1]]) - initialAngle
442
+ while (delta > Math.PI) delta -= 2 * Math.PI
443
+ while (delta < -Math.PI) delta += 2 * Math.PI
444
+ // 15° increments by default; Shift rotates freely — the same contract
445
+ // as the 3D group rotate gizmo (and the HUD hint its scope surfaces).
446
+ if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
447
+
448
+ const entries = rotateGroupPatches(starts, links, pivot, delta)
449
+ const patchById = new Map(entries)
450
+ const liveTransforms = useLiveTransforms.getState()
451
+ for (const start of starts) {
452
+ if (start.kind === 'scalar') {
453
+ const patch = patchById.get(start.id)
454
+ if (patch) {
455
+ liveTransforms.set(start.id, {
456
+ position: patch.position as [number, number, number],
457
+ rotation: patch.rotation as number,
458
+ })
459
+ }
460
+ }
461
+ useScene.getState().markDirty(start.id)
462
+ }
463
+ for (const l of links) {
464
+ useScene.getState().markDirty(l.id)
465
+ }
466
+ useLiveNodeOverrides.getState().setMany(entries)
467
+ // The dashed box spins with the group for live feedback.
468
+ useFloorplanGroupDrag.getState().setRotation({ pivotX: pivot.x, pivotZ: pivot.z, angle: delta })
469
+ }
470
+
471
+ const clearLivePreviews = () => {
472
+ const overrides = useLiveNodeOverrides.getState()
473
+ const liveTransforms = useLiveTransforms.getState()
474
+ for (const id of affectedIds) {
475
+ overrides.clear(id)
476
+ liveTransforms.clear(id)
477
+ useScene.getState().markDirty(id)
478
+ }
479
+ }
480
+
481
+ const removeListeners = () => {
482
+ window.removeEventListener('pointermove', onMove)
483
+ window.removeEventListener('pointerup', onUp, true)
484
+ window.removeEventListener('pointercancel', onPointerCancel)
485
+ window.removeEventListener('keydown', onKeyDown, true)
486
+ }
487
+
488
+ // History resume pairs one-to-one with the pause above, on the commit and
489
+ // cancel paths only.
490
+ const teardown = () => {
491
+ removeListeners()
492
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
493
+ // Deferred so a canvas pointerup later in this same dispatch still sees
494
+ // the drag as active (split view — see onUp).
495
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
496
+ useInteractionScope
497
+ .getState()
498
+ .endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_ROTATE_DRAG_LABEL)
499
+ useFloorplanGroupDrag.getState().setRotation(null)
500
+ }
501
+
502
+ // Capture registration + deferred inputDragging — see the drag session.
503
+ const onUp = (e: PointerEvent) => {
504
+ if (e.pointerId !== pointerId) return
505
+ swallowNextClick()
506
+ sfxEmitter.emit('sfx:item-place')
507
+ const overrides = useLiveNodeOverrides.getState()
508
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
509
+ for (const id of affectedIds) {
510
+ const patch = overrides.get(id)
511
+ if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
512
+ }
513
+ // One tracked set = one undo step; rigid rotations must not re-create
514
+ // the room's auto floors/ceilings (see the move sessions).
515
+ pauseSpaceDetection()
516
+ resumeSceneHistory(useScene)
517
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
518
+ resumeSpaceDetection()
519
+ clearLivePreviews()
520
+ teardown()
521
+ }
522
+
523
+ const cancel = () => {
524
+ clearLivePreviews()
525
+ resumeSceneHistory(useScene)
526
+ teardown()
527
+ }
528
+
529
+ const onPointerCancel = (e: PointerEvent) => {
530
+ if (e.pointerId !== pointerId) return
531
+ cancel()
532
+ }
533
+
534
+ // Capture phase so Escape wins over the global `use-keyboard` arm.
535
+ const onKeyDown = (e: KeyboardEvent) => {
536
+ if (e.key === 'Delete' || e.key === 'Backspace') {
537
+ // Deleting mid-rotate: revert first, then let the global Delete arm run.
538
+ cancel()
539
+ return
540
+ }
541
+ // ⌘Z mid-gesture cancels like Escape — never a history jump under a live pointer.
542
+ if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
543
+ e.preventDefault()
544
+ e.stopPropagation()
545
+ swallowNextClick()
546
+ cancel()
547
+ }
548
+
549
+ window.addEventListener('pointermove', onMove)
550
+ window.addEventListener('pointerup', onUp, true)
551
+ window.addEventListener('pointercancel', onPointerCancel)
552
+ window.addEventListener('keydown', onKeyDown, true)
553
+ return true
554
+ }
555
+
556
+ const GROUP_BOX_CURSOR_STYLE = { cursor: 'move' } as const
557
+ // Curved-arrow rotate cursor (no native CSS equivalent) — black glyph with a
558
+ // white halo so it reads on light and dark plans; falls back to `grab`.
559
+ const ROTATE_CURSOR_SVG = encodeURIComponent(
560
+ '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24"><g fill="none" stroke="#fff" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></g><g fill="none" stroke="#000" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></g></svg>',
561
+ )
562
+ const GROUP_BOX_ROTATE_CURSOR_STYLE = {
563
+ cursor: `url("data:image/svg+xml,${ROTATE_CURSOR_SVG}") 11 11, grab`,
564
+ } as const
565
+
566
+ /**
567
+ * Dashed bounding box around the current multi-selection's transformable
568
+ * participants (expanded to the welded wall/fence component) — shows what a
569
+ * group drag will carry along, and IS the group's drag handle: press anywhere
570
+ * inside it to slide the group, click to pick it up. Holding a selection
571
+ * modifier (Cmd/Ctrl/Shift) lets pointer events pass through so members under
572
+ * the box can still be toggled in and out. Rides the live drag delta so it
573
+ * tracks the group mid-gesture. Mounted inside the floor-plan scene `<g>`, so
574
+ * plan coords render directly.
575
+ */
576
+ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionBox({
577
+ palette,
578
+ unitsPerPixel,
579
+ onPointerDown,
580
+ onRotatePointerDown,
581
+ }: {
582
+ palette: FloorplanPalette | undefined
583
+ unitsPerPixel: number
584
+ onPointerDown?: (event: ReactPointerEvent<SVGGElement>) => void
585
+ onRotatePointerDown?: (event: ReactPointerEvent<SVGGElement>) => void
586
+ }) {
587
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
588
+ const levelId = useViewer((s) => s.selection.levelId)
589
+ const nodes = useScene((s) => s.nodes)
590
+ const delta = useFloorplanGroupDrag((s) => s.delta)
591
+ const liveRotation = useFloorplanGroupDrag((s) => s.rotation)
592
+ const movingNode = useMovingNode()
593
+ const mode = useEditor((s) => s.mode)
594
+
595
+ // While a selection modifier is held the box steps aside so clicks reach
596
+ // the entries underneath (toggle membership) instead of starting a drag.
597
+ const [modifierHeld, setModifierHeld] = useState(false)
598
+ useEffect(() => {
599
+ const update = (e: KeyboardEvent) => setModifierHeld(e.metaKey || e.ctrlKey || e.shiftKey)
600
+ const clear = () => setModifierHeld(false)
601
+ window.addEventListener('keydown', update)
602
+ window.addEventListener('keyup', update)
603
+ window.addEventListener('blur', clear)
604
+ return () => {
605
+ window.removeEventListener('keydown', update)
606
+ window.removeEventListener('keyup', update)
607
+ window.removeEventListener('blur', clear)
608
+ }
609
+ }, [])
610
+
611
+ // `meshEpoch` re-runs the measurement once the meshes settle after a scene
612
+ // change (undo/redo included) — `computeGroupBox` reads mesh world bounds,
613
+ // which lag the `nodes` commit by a frame or two.
614
+ const meshEpoch = useMeshSettleEpoch(nodes)
615
+ const box = useMemo(() => {
616
+ if (selectedIds.length < 2 || !levelId) return null
617
+ const participantIds = selectedIds.filter(
618
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
619
+ )
620
+ if (participantIds.length === 0) return null
621
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
622
+ const world = computeGroupBox(fullIds)
623
+ if (!world) return null
624
+ const { inverse } = levelFrame(levelId)
625
+ const min = world.min.clone().applyMatrix4(inverse)
626
+ const max = world.max.clone().applyMatrix4(inverse)
627
+ return {
628
+ x: Math.min(min.x, max.x),
629
+ z: Math.min(min.z, max.z),
630
+ width: Math.abs(max.x - min.x),
631
+ depth: Math.abs(max.z - min.z),
632
+ }
633
+ // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
634
+ }, [selectedIds, levelId, nodes, meshEpoch])
635
+
636
+ if (!box || movingNode || mode === 'delete') return null
637
+
638
+ const pad = 6 * unitsPerPixel
639
+ const stroke = palette?.selectedStroke ?? '#3b82f6'
640
+ const interactive = !modifierHeld && !!onPointerDown
641
+ // Mid-gesture the box rides the live delta (group move) or spins around the
642
+ // rotation pivot (corner rotate) — SVG rotate() is degrees around a plan
643
+ // point, and positive matches the atan2 x→z sense on the y-down plan.
644
+ const transform = liveRotation
645
+ ? `rotate(${(liveRotation.angle * 180) / Math.PI} ${liveRotation.pivotX} ${liveRotation.pivotZ})`
646
+ : delta
647
+ ? `translate(${delta[0]} ${delta[1]})`
648
+ : undefined
649
+ return (
650
+ <g
651
+ data-group-selection-box
652
+ onPointerDown={interactive ? onPointerDown : undefined}
653
+ pointerEvents={interactive ? 'auto' : 'none'}
654
+ style={interactive ? GROUP_BOX_CURSOR_STYLE : undefined}
655
+ transform={transform}
656
+ >
657
+ <rect
658
+ fill="transparent"
659
+ height={box.depth + 2 * pad}
660
+ stroke={stroke}
661
+ strokeDasharray={`${4 * unitsPerPixel} ${3 * unitsPerPixel}`}
662
+ strokeWidth={1.2 * unitsPerPixel}
663
+ width={box.width + 2 * pad}
664
+ x={box.x - pad}
665
+ y={box.z - pad}
666
+ />
667
+ {/* Corner rotate handles — the 2D counterpart of the 3D rotate gizmo:
668
+ drag a corner to spin the group (15° steps, Shift = free). */}
669
+ {interactive && onRotatePointerDown
670
+ ? (
671
+ [
672
+ [box.x - pad, box.z - pad],
673
+ [box.x + box.width + pad, box.z - pad],
674
+ [box.x + box.width + pad, box.z + box.depth + pad],
675
+ [box.x - pad, box.z + box.depth + pad],
676
+ ] as Array<[number, number]>
677
+ ).map(([cx, cz], index) => (
678
+ <g
679
+ data-group-rotate-handle
680
+ key={`corner-${index}`}
681
+ onPointerDown={(event) => {
682
+ event.stopPropagation()
683
+ onRotatePointerDown(event)
684
+ }}
685
+ style={GROUP_BOX_ROTATE_CURSOR_STYLE}
686
+ >
687
+ <circle cx={cx} cy={cz} fill="transparent" r={8 * unitsPerPixel} />
688
+ <circle
689
+ cx={cx}
690
+ cy={cz}
691
+ fill="#ffffff"
692
+ r={3.2 * unitsPerPixel}
693
+ stroke={stroke}
694
+ strokeWidth={1.2 * unitsPerPixel}
695
+ />
696
+ </g>
697
+ ))
698
+ : null}
699
+ </g>
700
+ )
701
+ })