@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
@@ -36,7 +36,7 @@ function useLevelChildren<TNode extends AnyNode>(
36
36
  }
37
37
 
38
38
  const levelNode = state.nodes[levelId]
39
- if (!levelNode || levelNode.type !== 'level') {
39
+ if (levelNode?.type !== 'level') {
40
40
  return [] as TNode[]
41
41
  }
42
42
 
@@ -105,7 +105,7 @@ export function useFloorplanSceneData({
105
105
  }
106
106
 
107
107
  const buildingNode = state.nodes[currentBuildingId]
108
- if (!buildingNode || buildingNode.type !== 'building') {
108
+ if (buildingNode?.type !== 'building') {
109
109
  return [] as LevelNode[]
110
110
  }
111
111
 
@@ -133,7 +133,7 @@ export function useFloorplanSceneData({
133
133
  }
134
134
 
135
135
  const nextLevelNode = state.nodes[levelId]
136
- if (!nextLevelNode || nextLevelNode.type !== 'level') {
136
+ if (nextLevelNode?.type !== 'level') {
137
137
  return [] as RoofNode[]
138
138
  }
139
139
 
@@ -149,7 +149,7 @@ export function useFloorplanSceneData({
149
149
  }
150
150
 
151
151
  const nextLevelNode = state.nodes[levelId]
152
- if (!nextLevelNode || nextLevelNode.type !== 'level') {
152
+ if (nextLevelNode?.type !== 'level') {
153
153
  return [] as OpeningNode[]
154
154
  }
155
155
 
@@ -171,7 +171,7 @@ export function useFloorplanSceneData({
171
171
  }
172
172
 
173
173
  const nextLevelNode = state.nodes[levelId]
174
- if (!nextLevelNode || nextLevelNode.type !== 'level') {
174
+ if (nextLevelNode?.type !== 'level') {
175
175
  return [] as AnyNode[]
176
176
  }
177
177
 
@@ -0,0 +1,26 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /**
4
+ * Bumps after the 3D meshes have had a chance to settle following a scene
5
+ * change. `computeGroupBox` reads mesh WORLD bounds, but a scene commit
6
+ * (undo/redo included) reaches the meshes asynchronously — renderers
7
+ * reconcile on the next React commit and the geometry systems rebuild in the
8
+ * next frame — so anything that derives geometry from the meshes in the same
9
+ * render as the `nodes` change reads STALE positions. Depend on this epoch
10
+ * (alongside `nodes`) to recompute once more after two animation frames,
11
+ * when transforms and rebuilt geometry are in place.
12
+ */
13
+ export function useMeshSettleEpoch(nodes: unknown): number {
14
+ const [epoch, setEpoch] = useState(0)
15
+ useEffect(() => {
16
+ let raf2 = 0
17
+ const raf1 = requestAnimationFrame(() => {
18
+ raf2 = requestAnimationFrame(() => setEpoch((e) => e + 1))
19
+ })
20
+ return () => {
21
+ cancelAnimationFrame(raf1)
22
+ cancelAnimationFrame(raf2)
23
+ }
24
+ }, [nodes])
25
+ return epoch
26
+ }
@@ -23,6 +23,7 @@ import { Html } from '@react-three/drei'
23
23
  import { createPortal, useFrame } from '@react-three/fiber'
24
24
  import { useMemo, useState } from 'react'
25
25
  import * as THREE from 'three'
26
+ import { formatLinearMeasurement } from '../../lib/measurements'
26
27
 
27
28
  const GUIDE_Y_OFFSET = 0.08
28
29
  const LABEL_LIFT = 0.08
@@ -62,17 +63,6 @@ type WallFaceLine = {
62
63
  end: Point2D
63
64
  }
64
65
 
65
- function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
66
- if (unit === 'imperial') {
67
- const feet = value * 3.280_84
68
- const wholeFeet = Math.floor(feet)
69
- const inches = Math.round((feet - wholeFeet) * 12)
70
- if (inches === 12) return `${wholeFeet + 1}'0"`
71
- return `${wholeFeet}'${inches}"`
72
- }
73
- return `${Number.parseFloat(value.toFixed(2))}m`
74
- }
75
-
76
66
  export function WallMeasurementLabel() {
77
67
  const selectedIds = useViewer((state) => state.selection.selectedIds)
78
68
  const nodes = useScene((state) => state.nodes)
@@ -547,8 +537,8 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
547
537
  }
548
538
  return total
549
539
  }, [guide, wall])
550
- const label = formatMeasurement(length, unit)
551
- const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
540
+ const label = formatLinearMeasurement(length, unit)
541
+ const heightLabel = `H ${formatLinearMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
552
542
 
553
543
  if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
554
544
 
@@ -32,8 +32,15 @@ import {
32
32
  } from 'three'
33
33
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
34
34
  import { MeshBasicNodeMaterial } from 'three/webgpu'
35
+ import { isHistoryShortcut } from '../../lib/history'
36
+ import { endpointReshapeScope } from '../../lib/interaction/scope'
35
37
  import { sfxEmitter } from '../../lib/sfx-bus'
36
38
  import useEditor from '../../store/use-editor'
39
+ import useInteractionScope, {
40
+ useEndpointReshape,
41
+ useIsCurveReshape,
42
+ useMovingNode,
43
+ } from '../../store/use-interaction-scope'
37
44
  import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
38
45
  import {
39
46
  createArrowHitAreaGeometry,
@@ -54,7 +61,7 @@ const ARROW_HOVER_COLOR = '#a5b4fc'
54
61
  // Match the door arrows: scale the rendered chevron down to ~two-thirds
55
62
  // so the in-world handles read as a single UI family.
56
63
  const ARROW_SCALE = 0.65
57
- const CORNER_HEX_RADIUS = 0.16
64
+ const CORNER_HEX_RADIUS = 0.11
58
65
  const CORNER_DASH_SIZE = 0.1
59
66
  const CORNER_GAP_SIZE = 0.07
60
67
  const CORNER_DASH_THICKNESS = 0.006
@@ -118,11 +125,9 @@ export function WallMoveSideHandles() {
118
125
  const selectedIds = useViewer((state) => state.selection.selectedIds)
119
126
  const mode = useEditor((state) => state.mode)
120
127
  const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
121
- const movingNode = useEditor((state) => state.movingNode)
122
- const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
123
- const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
124
- const curvingWall = useEditor((state) => state.curvingWall)
125
- const curvingFence = useEditor((state) => state.curvingFence)
128
+ const movingNode = useMovingNode()
129
+ const endpointReshape = useEndpointReshape()
130
+ const isCurveReshape = useIsCurveReshape()
126
131
 
127
132
  const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
128
133
  // Fence side-move / height / corner-pickers now flow through the
@@ -140,10 +145,8 @@ export function WallMoveSideHandles() {
140
145
  !isFloorplanHovered &&
141
146
  mode !== 'delete' &&
142
147
  !movingNode &&
143
- !movingWallEndpoint &&
144
- !movingFenceEndpoint &&
145
- !curvingWall &&
146
- !curvingFence
148
+ !endpointReshape &&
149
+ !isCurveReshape
147
150
 
148
151
  if (!shouldRender || !selectedNode) return null
149
152
 
@@ -329,11 +332,12 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
329
332
  }, [])
330
333
 
331
334
  const activateEndpointMove = (event: ThreeEvent<PointerEvent>) => {
335
+ if (event.button !== 0) return
332
336
  event.stopPropagation()
333
337
  suppressBoxSelectForPointer(event)
334
338
  sfxEmitter.emit('sfx:item-pick')
335
339
  document.body.style.cursor = 'grabbing'
336
- useEditor.getState().setMovingWallEndpoint({ wall, endpoint })
340
+ useInteractionScope.getState().begin(endpointReshapeScope(wall.id, endpoint))
337
341
  }
338
342
 
339
343
  return (
@@ -433,6 +437,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
433
437
  const handleY = wallHeight + HEIGHT_HANDLE_OFFSET
434
438
 
435
439
  const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
440
+ if (event.button !== 0) return
436
441
  event.stopPropagation()
437
442
  suppressBoxSelectForPointer(event)
438
443
  const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null
@@ -468,7 +473,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
468
473
 
469
474
  document.body.style.cursor = 'ns-resize'
470
475
  sfxEmitter.emit('sfx:item-pick')
471
- useEditor.getState().setActiveHandleDrag({ nodeId: wallId, label: 'height' })
476
+ useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: wallId, handle: 'height' })
472
477
  // Suppress R3F node pointer events until pointerup completes so the
473
478
  // synthesized click doesn't reroute selection to whatever mesh sits
474
479
  // under the cursor at release.
@@ -494,11 +499,12 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
494
499
  window.removeEventListener('pointermove', onMove)
495
500
  window.removeEventListener('pointerup', onUp)
496
501
  window.removeEventListener('pointercancel', onCancel)
502
+ window.removeEventListener('keydown', onKeyDown, true)
497
503
  if (document.body.style.cursor === 'ns-resize') {
498
504
  document.body.style.cursor = ''
499
505
  }
500
506
  useScene.temporal.getState().resume()
501
- useEditor.getState().setActiveHandleDrag(null)
507
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
502
508
  useViewer.getState().setInputDragging(false)
503
509
  dragCleanupRef.current = null
504
510
  }
@@ -523,10 +529,21 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
523
529
  cleanup()
524
530
  }
525
531
 
532
+ // Escape / ⌘Z abort the drag — capture phase so they win over the global
533
+ // use-keyboard arms (⌘Z must never history-jump under a live pointer).
534
+ const onKeyDown = (e: KeyboardEvent) => {
535
+ if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
536
+ e.preventDefault()
537
+ e.stopPropagation()
538
+ swallowNextClick()
539
+ onCancel()
540
+ }
541
+
526
542
  dragCleanupRef.current = cleanup
527
543
  window.addEventListener('pointermove', onMove)
528
544
  window.addEventListener('pointerup', onUp)
529
545
  window.addEventListener('pointercancel', onCancel)
546
+ window.addEventListener('keydown', onKeyDown, true)
530
547
  }
531
548
 
532
549
  return (
@@ -605,16 +622,15 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
605
622
  useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
606
623
 
607
624
  const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
625
+ if (event.button !== 0) return
608
626
  event.stopPropagation()
609
627
  suppressBoxSelectForPointer(event)
610
628
  document.body.style.cursor = 'grabbing'
611
629
 
612
630
  sfxEmitter.emit('sfx:item-pick')
613
631
  useEditor.getState().setMovingNode(wall)
614
- useEditor.getState().setMovingWallEndpoint(null)
615
- useEditor.getState().setMovingFenceEndpoint(null)
616
- useEditor.getState().setCurvingWall(null)
617
- useEditor.getState().setCurvingFence(null)
632
+ useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint')
633
+ useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve')
618
634
  // Keep the wall selected so it stays the active item once the move
619
635
  // commits; the `!movingNode` guard on the handles hides them mid-drag.
620
636
  }
@@ -696,16 +712,15 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
696
712
  useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
697
713
 
698
714
  const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
715
+ if (event.button !== 0) return
699
716
  event.stopPropagation()
700
717
  suppressBoxSelectForPointer(event)
701
718
  document.body.style.cursor = 'grabbing'
702
719
 
703
720
  sfxEmitter.emit('sfx:item-pick')
704
721
  useEditor.getState().setMovingNode(fence)
705
- useEditor.getState().setMovingWallEndpoint(null)
706
- useEditor.getState().setMovingFenceEndpoint(null)
707
- useEditor.getState().setCurvingWall(null)
708
- useEditor.getState().setCurvingFence(null)
722
+ useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint')
723
+ useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve')
709
724
  // Keep the fence selected so it stays active once the move commits.
710
725
  }
711
726
 
@@ -1,10 +1,23 @@
1
1
  'use client'
2
2
 
3
- import { sceneRegistry } from '@pascal-app/core'
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ DEFAULT_WALL_HEIGHT,
7
+ getWallCurveFrameAt,
8
+ getWallCurveLength,
9
+ getWallThickness,
10
+ isCurvedWall,
11
+ resolveLevelId,
12
+ sceneRegistry,
13
+ spatialGridManager,
14
+ useScene,
15
+ type WallNode,
16
+ } from '@pascal-app/core'
4
17
  import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
5
18
  import { useViewer } from '@pascal-app/viewer'
6
19
  import { useFrame } from '@react-three/fiber'
7
- import { memo, useRef } from 'react'
20
+ import { memo, useMemo, useRef } from 'react'
8
21
  import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three'
9
22
  import { MeshBasicNodeMaterial } from 'three/webgpu'
10
23
  import { EDITOR_LAYER } from '../../lib/constants'
@@ -20,8 +33,8 @@ import { EDITOR_LAYER } from '../../lib/constants'
20
33
  * intersection → ✕ cross wall body (edge) → circle
21
34
  *
22
35
  * Subscribes to the shared `useWallSnapIndicator` store (published by the wall
23
- * draft + endpoint-move tools). Shares the indigo accent of
24
- * `Alignment3DGuideLayer` for visual consistency.
36
+ * draft + endpoint-move tools). The vertical mouse pillar and the corner
37
+ * (endpoint) square are green; the other floor glyphs are indigo.
25
38
  *
26
39
  * The point carries only XZ (building-local plan coords); like the alignment
27
40
  * guides it's lifted to the active level's building-local Y each frame so it
@@ -30,10 +43,19 @@ import { EDITOR_LAYER } from '../../lib/constants'
30
43
  */
31
44
 
32
45
  const BEACON_COLOR = 0x81_8c_f8 // indigo-400 — matches the alignment guide accent
46
+ const MARKER_GREEN = 0x22_c5_5e // green-500 — vertical mouse marker + corner (endpoint) glyph
33
47
  const BEACON_HEIGHT = 2.5 // world-meter height of the pillar
34
48
  const BEACON_RADIUS = 0.018 // world-meter radius of the pillar
35
49
  const MARKER = 0.13 // world-meter base size of the floor glyph
36
50
  const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid
51
+ const WALL_TOP_HIGHLIGHT_LIFT = 0.035
52
+ const WALL_TOP_HIGHLIGHT_HEIGHT = 0.018
53
+ const WALL_TOP_HIGHLIGHT_OVERHANG = 0.14
54
+ const WALL_TOP_GLOW_HEIGHT = 0.026
55
+ const WALL_TOP_GLOW_OVERHANG = 0.36
56
+ const WALL_TOP_END_OVERHANG = 0.08
57
+ const CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH = 0.45
58
+ const NO_RAYCAST = () => null
37
59
 
38
60
  // Shared resources — one material + unit geometries, so snap churn during a
39
61
  // drag doesn't rebuild GPU buffers (mirrors the alignment guide layer).
@@ -45,17 +67,49 @@ const beaconMaterial = new MeshBasicNodeMaterial({
45
67
  transparent: true,
46
68
  opacity: 0.9,
47
69
  })
70
+ const greenMarkerMaterial = new MeshBasicNodeMaterial({
71
+ color: MARKER_GREEN,
72
+ depthTest: false,
73
+ depthWrite: false,
74
+ toneMapped: false,
75
+ transparent: true,
76
+ opacity: 0.9,
77
+ })
78
+ const wallTopHighlightMaterial = new MeshBasicNodeMaterial({
79
+ color: BEACON_COLOR,
80
+ depthTest: false,
81
+ depthWrite: false,
82
+ toneMapped: false,
83
+ transparent: true,
84
+ opacity: 0.88,
85
+ })
86
+ const wallTopHighlightGlowMaterial = new MeshBasicNodeMaterial({
87
+ color: BEACON_COLOR,
88
+ depthTest: false,
89
+ depthWrite: false,
90
+ toneMapped: false,
91
+ transparent: true,
92
+ opacity: 0.26,
93
+ })
48
94
  const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8)
49
95
  // Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as
50
96
  // a flat plate; circles/triangles lie flat via an X rotation at the mesh.
51
97
  const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1)
98
+ const WALL_TOP_HIGHLIGHT_GEOMETRY = new BoxGeometry(1, 1, 1)
52
99
  const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3)
53
100
  const CIRCLE_GEOMETRY = new CircleGeometry(1, 28)
54
101
 
55
102
  export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
56
103
  const point = useWallSnapIndicator((s) => s.point)
57
104
  const levelId = useViewer((s) => s.selection.levelId)
105
+ const nodes = useScene((s) => s.nodes)
58
106
  const groupRef = useRef<Group>(null)
107
+ const highlightedWalls = useMemo(() => {
108
+ if (!point?.wallIds?.length) return []
109
+ return point.wallIds
110
+ .map((wallId) => nodes[wallId as AnyNodeId])
111
+ .filter((node): node is WallNode => node?.type === 'wall' && node.visible !== false)
112
+ }, [nodes, point?.wallIds])
59
113
 
60
114
  // Track the active level's building-local Y each frame so the beacon stands
61
115
  // on the floor being edited, not the building base — same source the
@@ -70,10 +124,13 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
70
124
  if (!point) return null
71
125
  return (
72
126
  <group ref={groupRef}>
127
+ {highlightedWalls.map((wall) => (
128
+ <WallTopHighlight key={wall.id} nodes={nodes} wall={wall} />
129
+ ))}
73
130
  <mesh
74
131
  geometry={PILLAR_GEOMETRY}
75
132
  layers={EDITOR_LAYER}
76
- material={beaconMaterial}
133
+ material={greenMarkerMaterial}
77
134
  position={[point.x, BEACON_HEIGHT / 2, point.z]}
78
135
  renderOrder={1002}
79
136
  />
@@ -82,6 +139,103 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
82
139
  )
83
140
  })
84
141
 
142
+ type WallTopHighlightSegment = {
143
+ angle: number
144
+ center: [number, number]
145
+ length: number
146
+ }
147
+
148
+ function getWallTopY(wall: WallNode, nodes: Readonly<Record<string, AnyNode>>) {
149
+ const levelId = resolveLevelId(wall, nodes as Record<string, AnyNode>)
150
+ const slabElevation = spatialGridManager.getSlabElevationForWall(
151
+ levelId,
152
+ wall.start,
153
+ wall.end,
154
+ wall.curveOffset ?? 0,
155
+ wall.thickness,
156
+ )
157
+ const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
158
+ return (slabElevation > 0 ? slabElevation + wallHeight : wallHeight) + WALL_TOP_HIGHLIGHT_LIFT
159
+ }
160
+
161
+ function buildHighlightSegment(start: [number, number], end: [number, number]) {
162
+ const dx = end[0] - start[0]
163
+ const dz = end[1] - start[1]
164
+ const length = Math.hypot(dx, dz)
165
+ if (length < 1e-6) return null
166
+
167
+ return {
168
+ angle: -Math.atan2(dz, dx),
169
+ center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number],
170
+ length,
171
+ }
172
+ }
173
+
174
+ function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] {
175
+ if (!isCurvedWall(wall)) {
176
+ const segment = buildHighlightSegment(wall.start, wall.end)
177
+ return segment ? [segment] : []
178
+ }
179
+
180
+ const sampleCount = Math.max(
181
+ 8,
182
+ Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH),
183
+ )
184
+ const segments: WallTopHighlightSegment[] = []
185
+ let previous = getWallCurveFrameAt(wall, 0).point
186
+ for (let index = 1; index <= sampleCount; index += 1) {
187
+ const current = getWallCurveFrameAt(wall, index / sampleCount).point
188
+ const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y])
189
+ if (segment) segments.push(segment)
190
+ previous = current
191
+ }
192
+ return segments
193
+ }
194
+
195
+ function WallTopHighlight({
196
+ nodes,
197
+ wall,
198
+ }: {
199
+ nodes: Readonly<Record<string, AnyNode>>
200
+ wall: WallNode
201
+ }) {
202
+ const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall])
203
+ const y = getWallTopY(wall, nodes)
204
+ const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24)
205
+ const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42)
206
+
207
+ return (
208
+ <>
209
+ {segments.map((segment, index) => (
210
+ <group key={`${wall.id}:${index}`}>
211
+ <mesh
212
+ frustumCulled={false}
213
+ geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
214
+ layers={EDITOR_LAYER}
215
+ material={wallTopHighlightGlowMaterial}
216
+ position={[segment.center[0], y - 0.003, segment.center[1]]}
217
+ raycast={NO_RAYCAST}
218
+ renderOrder={1003}
219
+ rotation={[0, segment.angle, 0]}
220
+ scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_GLOW_HEIGHT, glowWidth]}
221
+ />
222
+ <mesh
223
+ frustumCulled={false}
224
+ geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
225
+ layers={EDITOR_LAYER}
226
+ material={wallTopHighlightMaterial}
227
+ position={[segment.center[0], y + 0.002, segment.center[1]]}
228
+ raycast={NO_RAYCAST}
229
+ renderOrder={1004}
230
+ rotation={[0, segment.angle, 0]}
231
+ scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_HIGHLIGHT_HEIGHT, width]}
232
+ />
233
+ </group>
234
+ ))}
235
+ </>
236
+ )
237
+ }
238
+
85
239
  /** Floor glyph whose shape encodes which kind of geometry the point snapped to. */
86
240
  function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) {
87
241
  const y = FLOOR_LIFT
@@ -90,7 +244,7 @@ function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }
90
244
  <mesh
91
245
  geometry={FLAT_BOX_GEOMETRY}
92
246
  layers={EDITOR_LAYER}
93
- material={beaconMaterial}
247
+ material={greenMarkerMaterial}
94
248
  position={[x, y, z]}
95
249
  renderOrder={1001}
96
250
  scale={[MARKER * 2, 1, MARKER * 2]}
@@ -2,6 +2,11 @@
2
2
 
3
3
  import { memo, type MouseEvent as ReactMouseEvent } from 'react'
4
4
  import useEditor from '../../store/use-editor'
5
+ import {
6
+ useEndpointReshape,
7
+ useIsCurveReshape,
8
+ useMovingNode,
9
+ } from '../../store/use-interaction-scope'
5
10
  import { NodeActionMenu } from '../editor/node-action-menu'
6
11
 
7
12
  type SvgPoint = {
@@ -48,12 +53,11 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
48
53
  offsetY = 10,
49
54
  }: FloorplanActionMenuLayerProps) {
50
55
  const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
51
- const movingNode = useEditor((state) => state.movingNode)
52
- const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
53
- const curvingWall = useEditor((state) => state.curvingWall)
54
- const curvingFence = useEditor((state) => state.curvingFence)
56
+ const movingNode = useMovingNode()
57
+ const endpointReshape = useEndpointReshape()
58
+ const isCurveReshape = useIsCurveReshape()
55
59
 
56
- if (!isFloorplanHovered || movingNode || movingFenceEndpoint || curvingWall || curvingFence) {
60
+ if (!isFloorplanHovered || movingNode || endpointReshape || isCurveReshape) {
57
61
  return null
58
62
  }
59
63
 
@@ -77,7 +77,7 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
77
77
  }
78
78
 
79
79
  if (mode === 'material-paint') {
80
- return { kind: 'asset', iconSrc: '/icons/paint.png' }
80
+ return { kind: 'asset', iconSrc: '/icons/paint.webp' }
81
81
  }
82
82
 
83
83
  return null
@@ -0,0 +1,87 @@
1
+ 'use client'
2
+
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import { useEffect, useState } from 'react'
5
+ import { createPortal } from 'react-dom'
6
+ import { isActive } from '../../lib/interaction/scope'
7
+ import useEditor from '../../store/use-editor'
8
+ import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
9
+ import {
10
+ deleteSelection,
11
+ duplicateSelectionAndPickUp,
12
+ startGroupPickUp,
13
+ } from '../editor/group-actions'
14
+ import { NodeActionMenu } from '../editor/node-action-menu'
15
+
16
+ /**
17
+ * Floating Move / Duplicate / Delete pill for a MULTI-selection in the 2D
18
+ * floor plan — the group sibling of `FloorplanRegistryActionMenu` (which is
19
+ * sole-selection only). Anchored above the dashed group selection box; every
20
+ * action targets the whole selection: Move picks the group up (it rides the
21
+ * cursor until a click places it), Duplicate clones the selection and picks
22
+ * the clones up, Delete removes everything selected.
23
+ *
24
+ * Gated on floorplan hover so it never coexists with the 3D group menu in
25
+ * split view (that one hides while the floor plan is hovered), and hidden
26
+ * during any active interaction so it never competes with a live drag.
27
+ */
28
+ export function FloorplanGroupActionMenu() {
29
+ const isMultiSelect = useViewer((s) => s.selection.selectedIds.length > 1)
30
+ const movingNode = useMovingNode()
31
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
32
+ const scopeActive = useInteractionScope((s) => isActive(s.scope))
33
+
34
+ const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
35
+
36
+ const isVisible = isMultiSelect && !movingNode && isFloorplanHovered && !scopeActive
37
+
38
+ useEffect(() => {
39
+ if (!isVisible) {
40
+ setPosition(null)
41
+ return
42
+ }
43
+ let raf = 0
44
+ const tick = () => {
45
+ raf = requestAnimationFrame(tick)
46
+ // The dashed group box exists exactly while the multi-selection has
47
+ // transformable participants — anchor to its top edge. Only publish
48
+ // actual changes so the idle poll doesn't re-render every frame.
49
+ const box = document.querySelector('[data-group-selection-box]') as SVGGElement | null
50
+ if (!box) {
51
+ setPosition((prev) => (prev === null ? prev : null))
52
+ return
53
+ }
54
+ const rect = box.getBoundingClientRect()
55
+ const next = { left: rect.left + rect.width / 2, top: rect.top }
56
+ setPosition((prev) =>
57
+ prev && Math.abs(prev.left - next.left) < 0.5 && Math.abs(prev.top - next.top) < 0.5
58
+ ? prev
59
+ : next,
60
+ )
61
+ }
62
+ raf = requestAnimationFrame(tick)
63
+ return () => cancelAnimationFrame(raf)
64
+ }, [isVisible])
65
+
66
+ if (!(isVisible && position)) return null
67
+
68
+ return createPortal(
69
+ <div
70
+ className="pointer-events-none fixed z-30"
71
+ style={{
72
+ left: position.left,
73
+ top: position.top,
74
+ transform: 'translate(-50%, calc(-100% - 12px))',
75
+ }}
76
+ >
77
+ <NodeActionMenu
78
+ onDelete={() => deleteSelection()}
79
+ onDuplicate={() => duplicateSelectionAndPickUp()}
80
+ onMove={() => startGroupPickUp()}
81
+ onPointerDown={(event) => event.stopPropagation()}
82
+ onPointerUp={(event) => event.stopPropagation()}
83
+ />
84
+ </div>,
85
+ document.body,
86
+ )
87
+ }