@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,457 @@
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 { Plane, Vector2, Vector3 } from 'three'
17
+ import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
18
+ import { clientToPlan } from '../../lib/floorplan/plan-coords'
19
+ import { duplicateNodesToLevel } from '../../lib/scene-clipboard'
20
+ import { sfxEmitter } from '../../lib/sfx-bus'
21
+ import useAlignmentGuides from '../../store/use-alignment-guides'
22
+ import useEditor, {
23
+ isAlignmentGuideActive,
24
+ isGridSnapActive,
25
+ isMagneticSnapActive,
26
+ } from '../../store/use-editor'
27
+ import useInteractionScope from '../../store/use-interaction-scope'
28
+ import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
29
+ import {
30
+ classifyParticipant,
31
+ collectParticipants,
32
+ computeGroupBox,
33
+ expandToComponent,
34
+ levelFrame,
35
+ participantExtents,
36
+ rotateGroupSnapshots,
37
+ translateGroupPatches,
38
+ type Vec2,
39
+ } from './group-transform-shared'
40
+ import { swallowNextClick } from './handles/use-handle-drag'
41
+ import { getEditorThreeContext } from './three-context-bridge'
42
+
43
+ // Whole-selection actions behind the group action menu (Move / Duplicate /
44
+ // Delete), shared by the 2D and 3D menus. The Move flow is a "pick-up": the
45
+ // selection rides the cursor (relative to where tracking starts, so nothing
46
+ // teleports) across BOTH surfaces — floor plan via the scene CTM, 3D via a
47
+ // ground-plane raycast through the bridged camera — until a click commits it
48
+ // as one undo step. Escape / right-click cancels.
49
+
50
+ const ALIGNMENT_THRESHOLD_M = 0.08
51
+ // Matches the keyboard Delete arm's accidental-bulk-delete guard.
52
+ const BULK_DELETE_THRESHOLD = 10
53
+
54
+ // Any non-empty selection can be picked up (the menus themselves gate on a
55
+ // multi-selection; Duplicate can legitimately land on a single cloned root).
56
+ function groupParticipantIds(): string[] {
57
+ const { selectedIds, levelId } = useViewer.getState().selection
58
+ if (selectedIds.length === 0) return []
59
+ const nodes = useScene.getState().nodes
60
+ return selectedIds.filter(
61
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
62
+ )
63
+ }
64
+
65
+ /** True when the current selection is a group the pick-up move can carry. */
66
+ export function canGroupPickUp(): boolean {
67
+ return groupParticipantIds().length > 0
68
+ }
69
+
70
+ /**
71
+ * Pick up the current multi-selection: it follows the cursor (delta-relative)
72
+ * until a click commits, mirroring the single-node `movingNode` flow. Returns
73
+ * false when the selection holds no transformable participants.
74
+ *
75
+ * `scopeToSelection` limits the moving set to the selected participants —
76
+ * no connected-component expansion and no welded-neighbor endpoints. The
77
+ * Duplicate flow needs this: its clones sit EXACTLY on the originals, so
78
+ * junction coincidence would otherwise weld the originals into the pick-up
79
+ * and drag them along with the copies.
80
+ */
81
+ export function startGroupPickUp(
82
+ opts: { onCancel?: () => void; scopeToSelection?: boolean } = {},
83
+ ): boolean {
84
+ const { selectedIds, levelId } = useViewer.getState().selection
85
+ const participantIds = groupParticipantIds()
86
+ if (participantIds.length === 0) return false
87
+ const nodes = useScene.getState().nodes
88
+ const fullIds = opts.scopeToSelection
89
+ ? participantIds
90
+ : expandToComponent(participantIds, nodes, levelId)
91
+ const collected = collectParticipants(fullIds, nodes, levelId)
92
+ // Mutable: mid-carry R/T rotates these snapshots in place.
93
+ let starts = collected.starts
94
+ let links = opts.scopeToSelection ? [] : collected.links
95
+ if (starts.length === 0) return false
96
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
97
+
98
+ // Rest bounds in the level frame. Prefer the mounted meshes' world box
99
+ // (footprint-accurate), but fall back to the participant DATA when the
100
+ // meshes aren't up yet — Duplicate starts the pick-up synchronously after
101
+ // `createNodes`, one frame before the clones' renderers mount.
102
+ const { inverse: frameInv } = levelFrame(levelId)
103
+ const restBox = computeGroupBox(fullIds)
104
+ let minX = Number.POSITIVE_INFINITY
105
+ let minZ = Number.POSITIVE_INFINITY
106
+ let maxX = Number.NEGATIVE_INFINITY
107
+ let maxZ = Number.NEGATIVE_INFINITY
108
+ if (restBox) {
109
+ const boxMin = restBox.min.clone().applyMatrix4(frameInv)
110
+ const boxMax = restBox.max.clone().applyMatrix4(frameInv)
111
+ minX = Math.min(boxMin.x, boxMax.x)
112
+ minZ = Math.min(boxMin.z, boxMax.z)
113
+ maxX = Math.max(boxMin.x, boxMax.x)
114
+ maxZ = Math.max(boxMin.z, boxMax.z)
115
+ } else {
116
+ const reach = (x: number, z: number) => {
117
+ minX = Math.min(minX, x)
118
+ minZ = Math.min(minZ, z)
119
+ maxX = Math.max(maxX, x)
120
+ maxZ = Math.max(maxZ, z)
121
+ }
122
+ for (const s of starts) {
123
+ if (s.kind === 'endpoint') {
124
+ reach(s.start[0], s.start[1])
125
+ reach(s.end[0], s.end[1])
126
+ } else if (s.kind === 'polygon') {
127
+ for (const [x, z] of s.polygon) {
128
+ reach(x, z)
129
+ }
130
+ } else {
131
+ reach(s.position[0], s.position[2])
132
+ }
133
+ }
134
+ }
135
+ if (!Number.isFinite(minX)) return false
136
+ // Rotation pivot for mid-carry R/T; stable across the whole pick-up.
137
+ const restCenter: [number, number] = [(minX + maxX) / 2, (minZ + maxZ) / 2]
138
+ // Ground plane for the 3D surface: the meshes' base when available, floor
139
+ // level otherwise. Placements live in the level frame, so both surfaces
140
+ // resolve into it before measuring.
141
+ const plane = new Plane(new Vector3(0, 1, 0), -(restBox?.min.y ?? 0))
142
+
143
+ // Alignment candidates — everything outside the moving set; the group
144
+ // aligns as one rigid footprint via its bbox corners + center.
145
+ const movingIdSet = new Set<string>(affectedIds)
146
+ const staticNodes: Record<string, AnyNode> = {}
147
+ for (const [nid, n] of Object.entries(nodes)) {
148
+ if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
149
+ }
150
+ const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
151
+ let restAnchors = bboxCornerAnchors('group-move', minX, minZ, maxX, maxZ)
152
+
153
+ // Cursor → level-frame plan point, whichever surface the pointer is over.
154
+ const ndc = new Vector2()
155
+ const resolvePlanPoint = (e: PointerEvent): Vec2 | null => {
156
+ const three = getEditorThreeContext()
157
+ if (three && e.target === three.domElement) {
158
+ const rect = three.domElement.getBoundingClientRect()
159
+ ndc.set(
160
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
161
+ -((e.clientY - rect.top) / rect.height) * 2 + 1,
162
+ )
163
+ three.raycaster.setFromCamera(ndc, three.camera)
164
+ const hit = new Vector3()
165
+ if (!three.raycaster.ray.intersectPlane(plane, hit)) return null
166
+ const local = hit.applyMatrix4(frameInv)
167
+ return [local.x, local.z]
168
+ }
169
+ // Anywhere inside the floor-plan viewport counts (the scene `<g>` only
170
+ // covers painted elements, so bounds-test the owning SVG).
171
+ const sceneEl = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
172
+ const svg = sceneEl?.ownerSVGElement
173
+ if (!svg) return null
174
+ const rect = svg.getBoundingClientRect()
175
+ if (
176
+ e.clientX < rect.left ||
177
+ e.clientX > rect.right ||
178
+ e.clientY < rect.top ||
179
+ e.clientY > rect.bottom
180
+ ) {
181
+ return null
182
+ }
183
+ const plan = clientToPlan(e.clientX, e.clientY)
184
+ return plan ? [plan[0], plan[1]] : null
185
+ }
186
+
187
+ let startPlan: Vec2 | null = null
188
+ let lastDelta: Vec2 | null = null
189
+
190
+ for (const id of affectedIds) {
191
+ useLiveTransforms.getState().clear(id)
192
+ }
193
+ sfxEmitter.emit('sfx:item-pick')
194
+ pauseSceneHistory(useScene)
195
+ useInteractionScope.getState().begin({
196
+ kind: 'handle-drag',
197
+ nodeId: (participantIds[0] ?? '') as AnyNodeId,
198
+ handle: GROUP_MOVE_DRAG_LABEL,
199
+ })
200
+ document.body.style.cursor = 'grabbing'
201
+
202
+ const applyMove = (e: PointerEvent) => {
203
+ const plan = resolvePlanPoint(e)
204
+ if (!plan) return
205
+ // Delta-relative to where tracking starts so the group never teleports
206
+ // to the cursor.
207
+ if (!startPlan) {
208
+ startPlan = plan
209
+ return
210
+ }
211
+ const step = useEditor.getState().gridSnapStep
212
+ const snap = isGridSnapActive() && step > 0
213
+ let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0]
214
+ let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
215
+
216
+ if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) {
217
+ const result = resolveAlignment({
218
+ moving: restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
219
+ candidates,
220
+ threshold: ALIGNMENT_THRESHOLD_M,
221
+ })
222
+ if (result.snap && isMagneticSnapActive()) {
223
+ dx += result.snap.dx
224
+ dz += result.snap.dz
225
+ }
226
+ useAlignmentGuides.getState().set(result.guides)
227
+ } else {
228
+ useAlignmentGuides.getState().clear()
229
+ }
230
+
231
+ applyDelta(dx, dz)
232
+ }
233
+
234
+ const applyDelta = (dx: number, dz: number) => {
235
+ if (!lastDelta || lastDelta[0] !== dx || lastDelta[1] !== dz) {
236
+ sfxEmitter.emit('sfx:grid-snap')
237
+ lastDelta = [dx, dz]
238
+ }
239
+
240
+ const entries = translateGroupPatches(starts, links, dx, dz)
241
+ const patchById = new Map(entries)
242
+ const liveTransforms = useLiveTransforms.getState()
243
+ for (const start of starts) {
244
+ if (start.kind === 'scalar') {
245
+ const patch = patchById.get(start.id)
246
+ if (patch) {
247
+ liveTransforms.set(start.id, {
248
+ position: patch.position as [number, number, number],
249
+ rotation: start.rotation,
250
+ })
251
+ }
252
+ }
253
+ useScene.getState().markDirty(start.id)
254
+ }
255
+ for (const l of links) {
256
+ useScene.getState().markDirty(l.id)
257
+ }
258
+ useLiveNodeOverrides.getState().setMany(entries)
259
+ useFloorplanGroupDrag.getState().set([dx, dz])
260
+ }
261
+
262
+ // Mid-carry R/T: rotate the SNAPSHOTS around the rest pivot and re-apply
263
+ // the current delta — the carried group turns exactly like the idle
264
+ // keyboard rotate, and the placement stays a single updateNodes.
265
+ const rotateCarried = (direction: 1 | -1) => {
266
+ const rotated = rotateGroupSnapshots(
267
+ starts,
268
+ links,
269
+ { x: restCenter[0], z: restCenter[1] },
270
+ -direction * (Math.PI / 4),
271
+ )
272
+ starts = rotated.starts
273
+ links = rotated.links
274
+ const ext = participantExtents(rotated.starts)
275
+ if (ext) {
276
+ restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
277
+ }
278
+ sfxEmitter.emit('sfx:item-rotate')
279
+ applyDelta(lastDelta?.[0] ?? 0, lastDelta?.[1] ?? 0)
280
+ }
281
+
282
+ const clearLivePreviews = () => {
283
+ const overrides = useLiveNodeOverrides.getState()
284
+ const liveTransforms = useLiveTransforms.getState()
285
+ for (const id of affectedIds) {
286
+ overrides.clear(id)
287
+ liveTransforms.clear(id)
288
+ useScene.getState().markDirty(id)
289
+ }
290
+ }
291
+
292
+ const removeListeners = () => {
293
+ window.removeEventListener('pointermove', onMove)
294
+ window.removeEventListener('pointerdown', onPointerDown, true)
295
+ window.removeEventListener('pointerup', onPointerUp, true)
296
+ window.removeEventListener('keydown', onKeyDown, true)
297
+ window.removeEventListener('contextmenu', onContextMenu, true)
298
+ }
299
+
300
+ // History resume pairs one-to-one with the pause above, on the commit and
301
+ // cancel paths only.
302
+ const teardown = () => {
303
+ removeListeners()
304
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
305
+ useAlignmentGuides.getState().clear()
306
+ useInteractionScope
307
+ .getState()
308
+ .endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_MOVE_DRAG_LABEL)
309
+ useFloorplanGroupDrag.getState().set(null)
310
+ }
311
+
312
+ const commit = () => {
313
+ swallowNextClick()
314
+ sfxEmitter.emit('sfx:item-place')
315
+ const overrides = useLiveNodeOverrides.getState()
316
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
317
+ for (const id of affectedIds) {
318
+ const patch = overrides.get(id)
319
+ if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
320
+ }
321
+ // Group transforms move existing structure rigidly — the wall-driven room
322
+ // auto-detection must not re-create floors/ceilings for the walls' new
323
+ // positions (that belongs to wall building/editing). Paused around the
324
+ // commit; the sync rolls its baseline forward for paused changes.
325
+ pauseSpaceDetection()
326
+ resumeSceneHistory(useScene)
327
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
328
+ resumeSpaceDetection()
329
+ clearLivePreviews()
330
+ teardown()
331
+ }
332
+
333
+ const cancel = () => {
334
+ clearLivePreviews()
335
+ resumeSceneHistory(useScene)
336
+ teardown()
337
+ opts.onCancel?.()
338
+ }
339
+
340
+ const onMove = (e: PointerEvent) => {
341
+ applyMove(e)
342
+ }
343
+
344
+ // Capture phase: commit before the press reaches selection / tools, so the
345
+ // drop click can't also select whatever lands under the cursor.
346
+ // The placement gesture: the press over a tracked surface is claimed in
347
+ // capture phase and the commit runs on ITS pointerup, also claimed — the
348
+ // canvas never sees either, so `use-node-events` cannot synthesize a
349
+ // selection click from the release (which would re-select whatever sits
350
+ // under the cursor and break the multi-selection).
351
+ let commitPointerId: number | null = null
352
+
353
+ const onPointerDown = (e: PointerEvent) => {
354
+ if (e.button === 2) {
355
+ e.preventDefault()
356
+ e.stopPropagation()
357
+ cancel()
358
+ return
359
+ }
360
+ if (e.button !== 0) return
361
+ // Only a press over a tracked surface commits; a click on side panels or
362
+ // the toolbar keeps the pick-up alive.
363
+ if (!resolvePlanPoint(e)) return
364
+ e.preventDefault()
365
+ e.stopPropagation()
366
+ commitPointerId = e.pointerId
367
+ }
368
+
369
+ const onPointerUp = (e: PointerEvent) => {
370
+ if (commitPointerId === null || e.pointerId !== commitPointerId) return
371
+ commitPointerId = null
372
+ // Raise `inputDragging` through this event's dispatch so the canvas's
373
+ // use-node-events suppresses the selection click it synthesizes on every
374
+ // pointerup (stopPropagation would also break the window bubble
375
+ // listeners that clear the box-select pointer suppression).
376
+ useViewer.getState().setInputDragging(true)
377
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
378
+ commit()
379
+ }
380
+
381
+ const onKeyDown = (e: KeyboardEvent) => {
382
+ const key = e.key.toLowerCase()
383
+ if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
384
+ e.preventDefault()
385
+ e.stopPropagation()
386
+ rotateCarried(key === 'r' ? 1 : -1)
387
+ return
388
+ }
389
+ if (e.key === 'Delete' || e.key === 'Backspace') {
390
+ // Deleting mid-carry: put the group down first (revert), then let the
391
+ // global Delete arm remove the selection — no dangling carry. The
392
+ // duplicate flow's onCancel already discards the clones instead.
393
+ cancel()
394
+ return
395
+ }
396
+ if (e.key !== 'Escape') return
397
+ e.preventDefault()
398
+ e.stopPropagation()
399
+ cancel()
400
+ }
401
+
402
+ const onContextMenu = (e: Event) => {
403
+ e.preventDefault()
404
+ e.stopPropagation()
405
+ }
406
+
407
+ window.addEventListener('pointermove', onMove)
408
+ window.addEventListener('pointerdown', onPointerDown, true)
409
+ window.addEventListener('pointerup', onPointerUp, true)
410
+ window.addEventListener('keydown', onKeyDown, true)
411
+ window.addEventListener('contextmenu', onContextMenu, true)
412
+ return true
413
+ }
414
+
415
+ /**
416
+ * Duplicate the whole selection (subtrees + id remap via the clipboard
417
+ * pipeline, without touching the clipboard), select the clones, and pick
418
+ * them up so the next click places them. Cancelling the pick-up removes the
419
+ * clones again.
420
+ */
421
+ export function duplicateSelectionAndPickUp(): boolean {
422
+ const { selectedIds, levelId } = useViewer.getState().selection
423
+ if (selectedIds.length < 2) return false
424
+ const result = duplicateNodesToLevel(
425
+ selectedIds as AnyNodeId[],
426
+ (levelId ?? undefined) as AnyNodeId | undefined,
427
+ )
428
+ if (!result || result.pastedIds.length === 0) return false
429
+ sfxEmitter.emit('sfx:item-pick')
430
+ startGroupPickUp({
431
+ scopeToSelection: true,
432
+ onCancel: () => {
433
+ useScene.getState().deleteNodes(result.pastedIds)
434
+ useViewer.getState().setSelection({ selectedIds: [] })
435
+ },
436
+ })
437
+ return true
438
+ }
439
+
440
+ /**
441
+ * Delete every selected node — same semantics as the keyboard Delete arm,
442
+ * including the accidental-bulk-delete confirm.
443
+ */
444
+ export function deleteSelection(): boolean {
445
+ const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
446
+ if (selectedIds.length === 0) return false
447
+ if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
448
+ const confirmed = window.confirm(
449
+ `Delete ${selectedIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
450
+ )
451
+ if (!confirmed) return false
452
+ }
453
+ sfxEmitter.emit('sfx:structure-delete')
454
+ useScene.getState().deleteNodes(selectedIds)
455
+ useViewer.getState().setSelection({ selectedIds: [] })
456
+ return true
457
+ }
@@ -0,0 +1,130 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { Html } from '@react-three/drei'
6
+ import { useFrame } from '@react-three/fiber'
7
+ import { useCallback, useMemo, useRef } from 'react'
8
+ import * as THREE from 'three'
9
+ import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
10
+ import useEditor from '../../store/use-editor'
11
+ import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
12
+ import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from './group-actions'
13
+ import { classifyParticipant, computeGroupBox, expandToComponent } from './group-transform-shared'
14
+ import { NodeActionMenu } from './node-action-menu'
15
+ import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
16
+
17
+ // Matches the single-node FloatingActionMenu's zoom-compensation constants.
18
+ const REF_ORTHO_ZOOM = 50
19
+ const REF_CAMERA_DISTANCE = 12
20
+ const MIN_MENU_SCALE = 0.6
21
+ const MAX_MENU_SCALE = 1.4
22
+ // Clearance above the group's bbox top so the pill doesn't sit on the meshes.
23
+ const MENU_Y_OFFSET = 0.42
24
+
25
+ /**
26
+ * Floating Move / Duplicate / Delete pill for a MULTI-selection in the 3D
27
+ * view — the group sibling of the single-node `FloatingActionMenu` (which is
28
+ * sole-selection only). Anchored above the selection's bounding-box center;
29
+ * every action targets the whole selection: Move picks the group up (it rides
30
+ * the cursor until a click places it), Duplicate clones the selection and
31
+ * picks the clones up, Delete removes everything selected.
32
+ */
33
+ export function GroupFloatingActionMenu() {
34
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
35
+ const levelId = useViewer((s) => s.selection.levelId)
36
+ const mode = useEditor((s) => s.mode)
37
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
38
+ const movingNode = useMovingNode()
39
+ const nodes = useScene((s) => s.nodes)
40
+ // Hard-hidden during any active interaction (drag, pick-up, reshape) so the
41
+ // pill never competes with the live action — same policy as the 1-node menu.
42
+ const scope = useInteractionScope((s) => s.scope)
43
+ const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
44
+
45
+ const groupRef = useRef<THREE.Group>(null)
46
+ const menuScaleRef = useRef<HTMLDivElement>(null)
47
+
48
+ const participantIds = useMemo(
49
+ () =>
50
+ selectedIds.length > 1
51
+ ? selectedIds.filter(
52
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
53
+ )
54
+ : [],
55
+ [selectedIds, levelId, nodes],
56
+ )
57
+
58
+ // World anchor above the group bbox. Depends on the scene (post-commit
59
+ // positions), not the camera, and the menu hides during drags — so a
60
+ // memo keyed on selection + nodes is enough, no per-frame box traversal.
61
+ const meshEpoch = useMeshSettleEpoch(nodes)
62
+ const anchor = useMemo(() => {
63
+ if (participantIds.length === 0) return null
64
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
65
+ const box = computeGroupBox(fullIds)
66
+ if (!box) return null
67
+ return new THREE.Vector3(
68
+ (box.min.x + box.max.x) / 2,
69
+ box.max.y + MENU_Y_OFFSET,
70
+ (box.min.z + box.max.z) / 2,
71
+ )
72
+ // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
73
+ }, [participantIds, nodes, levelId, meshEpoch])
74
+
75
+ useFrame((state) => {
76
+ // Scale the HTML pill with camera zoom / distance so it feels anchored to
77
+ // the world — mirrors the single-node menu.
78
+ if (!(menuScaleRef.current && groupRef.current)) return
79
+ const raw =
80
+ state.camera instanceof THREE.OrthographicCamera
81
+ ? state.camera.zoom / REF_ORTHO_ZOOM
82
+ : REF_CAMERA_DISTANCE /
83
+ Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001)
84
+ const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
85
+ menuScaleRef.current.style.transform = `scale(${scale})`
86
+ })
87
+
88
+ const stopPointer = useCallback((event: React.PointerEvent) => {
89
+ event.stopPropagation()
90
+ }, [])
91
+ const handleMove = useCallback((event: React.MouseEvent) => {
92
+ event.stopPropagation()
93
+ startGroupPickUp()
94
+ }, [])
95
+ const handleDuplicate = useCallback((event: React.MouseEvent) => {
96
+ event.stopPropagation()
97
+ duplicateSelectionAndPickUp()
98
+ }, [])
99
+ const handleDelete = useCallback((event: React.MouseEvent) => {
100
+ event.stopPropagation()
101
+ deleteSelection()
102
+ }, [])
103
+
104
+ if (!anchor || mode === 'delete' || isFloorplanHovered || movingNode || menuStepBack) {
105
+ return null
106
+ }
107
+
108
+ return (
109
+ <group position={anchor} ref={groupRef}>
110
+ <Html
111
+ center
112
+ style={{
113
+ pointerEvents: 'auto',
114
+ touchAction: 'none',
115
+ }}
116
+ zIndexRange={[25, 0]}
117
+ >
118
+ <div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
119
+ <NodeActionMenu
120
+ onDelete={handleDelete}
121
+ onDuplicate={handleDuplicate}
122
+ onMove={handleMove}
123
+ onPointerDown={stopPointer}
124
+ onPointerUp={stopPointer}
125
+ />
126
+ </div>
127
+ </Html>
128
+ </group>
129
+ )
130
+ }