@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
@@ -2,27 +2,34 @@
2
2
 
3
3
  import {
4
4
  type AnyNode,
5
+ type AnyNodeDefinition,
5
6
  type AnyNodeId,
6
- type FloorplanAffordancePoint,
7
+ createSceneApi,
8
+ emitter,
7
9
  type FloorplanAffordanceSession,
8
10
  type FloorplanGeometry,
9
11
  type FloorplanPalette,
10
12
  type FloorplanPoint,
11
13
  type GeometryContext,
14
+ isNodeKindEnabled,
15
+ isRegistryMovable,
12
16
  kindsWithFloorplanScope,
17
+ type LiveNodeOverrides,
18
+ type LiveTransform,
13
19
  nodeRegistry,
14
20
  pauseSceneHistory,
15
21
  resolveBuildingForLevel,
22
+ resolveSelectionProxyId,
16
23
  resumeSceneHistory,
17
24
  useInteractive,
18
25
  useLiveNodeOverrides,
19
26
  useLiveTransforms,
20
27
  useScene,
21
28
  } from '@pascal-app/core'
22
- import { useAlignmentGuides } from '@pascal-app/editor'
23
29
  import { useViewer } from '@pascal-app/viewer'
24
30
  import {
25
31
  memo,
32
+ type MouseEvent as ReactMouseEvent,
26
33
  type PointerEvent as ReactPointerEvent,
27
34
  useCallback,
28
35
  useEffect,
@@ -30,10 +37,45 @@ import {
30
37
  useRef,
31
38
  useState,
32
39
  } from 'react'
40
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
41
+ import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
42
+ import {
43
+ canDirectRotateNode,
44
+ resolveDirectManipulationNode,
45
+ resolveDirectRotationDragDelta,
46
+ resolveDirectRotationPatch,
47
+ snapDirectRotationDelta,
48
+ } from '../../../lib/direct-manipulation'
49
+ import { createEditorApi } from '../../../lib/editor-api'
50
+ import { clientToPlan } from '../../../lib/floorplan/plan-coords'
51
+ import {
52
+ type ActiveInteractionScope,
53
+ boundaryReshapeScope,
54
+ controlPointReshapeScope,
55
+ curveReshapeScope,
56
+ endpointReshapeScope,
57
+ holeEditScope,
58
+ tangentReshapeScope,
59
+ } from '../../../lib/interaction/scope'
33
60
  import { sfxEmitter } from '../../../lib/sfx-bus'
61
+ import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
62
+ import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
34
63
  import useEditor from '../../../store/use-editor'
64
+ import useInteractionScope, {
65
+ useEndpointReshape,
66
+ useMovingNode,
67
+ } from '../../../store/use-interaction-scope'
68
+ import { startGroupPickUp } from '../../editor/group-actions'
69
+ import { classifyParticipant } from '../../editor/group-transform-shared'
70
+ import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
71
+ import {
72
+ FloorplanGroupSelectionBox,
73
+ startFloorplanGroupMove,
74
+ startFloorplanGroupRotate,
75
+ } from '../floorplan-group-move'
35
76
  import { useFloorplanRender } from '../floorplan-render-context'
36
77
  import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
78
+ import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
37
79
 
38
80
  /**
39
81
  * Registry-driven floor-plan layer.
@@ -70,6 +112,9 @@ const ENDPOINT_HIT_STROKE_WIDTH_PX = 18
70
112
  const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16
71
113
  const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7
72
114
  const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
115
+ const DIRECT_DRAG_THRESHOLD_PX = 4
116
+ const DIRECT_ROTATE_EPSILON = 1e-6
117
+ const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
73
118
 
74
119
  /**
75
120
  * Snapshot of node fields captured at drag-start, used by the single-undo
@@ -82,11 +127,18 @@ type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
82
127
 
83
128
  type ActiveDrag = {
84
129
  pointerId: number
130
+ captureTarget: Element
85
131
  /** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */
86
132
  handleId: string
87
133
  session: FloorplanAffordanceSession
88
134
  snapshots: NodeSnapshot[]
89
135
  historyPaused: boolean
136
+ /**
137
+ * Last plan point handed to `session.apply` (the grab point until the first
138
+ * move). Lets the modifier-key listeners re-run the session immediately on
139
+ * an Alt/Shift flip instead of waiting for the next pointer move.
140
+ */
141
+ lastPlanPoint: FloorplanPoint
90
142
  /**
91
143
  * Set only for rotate-arrow drags (handles that carry a `pivot`). Drives
92
144
  * the live angle wedge + degree readout — the 2D twin of the 3D rotate
@@ -94,6 +146,110 @@ type ActiveDrag = {
94
146
  * rotate affordance measures it: `atan2(pointer − pivot)`.
95
147
  */
96
148
  rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number }
149
+ /**
150
+ * Node id of the reshaping scope this drag began (boundary / curve / endpoint
151
+ * edits), so the matching `endIf` on release/cancel tears down exactly this
152
+ * scope. Unset for affordances that drive no snapping scope (resize / rotate).
153
+ */
154
+ reshapeScopeNodeId?: string
155
+ }
156
+
157
+ type FloorplanAffordanceCancelEffects = {
158
+ restoreSnapshots: (snapshots: NodeSnapshot[]) => void
159
+ resumeHistory: () => void
160
+ clearPreview: (id: AnyNodeId) => void
161
+ clearSnapFeedback: () => void
162
+ endReshapeScope: (drag: ActiveDrag) => void
163
+ clearDragFeedback?: () => void
164
+ }
165
+
166
+ export function cancelFloorplanAffordanceDrag(
167
+ dragRef: { current: ActiveDrag | null },
168
+ effects: FloorplanAffordanceCancelEffects,
169
+ pointerId?: number,
170
+ ): boolean {
171
+ const drag = dragRef.current
172
+ if (!drag || (pointerId !== undefined && pointerId !== drag.pointerId)) return false
173
+
174
+ // Clear ownership before cleanup so a queued pointer-up cannot commit the
175
+ // session while cancellation side effects are still running.
176
+ dragRef.current = null
177
+
178
+ if (drag.captureTarget.hasPointerCapture?.(drag.pointerId)) {
179
+ drag.captureTarget.releasePointerCapture?.(drag.pointerId)
180
+ }
181
+
182
+ effects.restoreSnapshots(drag.snapshots)
183
+ if (drag.historyPaused) {
184
+ effects.resumeHistory()
185
+ drag.historyPaused = false
186
+ }
187
+ effects.clearSnapFeedback()
188
+ for (const id of drag.session.affectedIds) effects.clearPreview(id)
189
+ effects.endReshapeScope(drag)
190
+ effects.clearDragFeedback?.()
191
+ return true
192
+ }
193
+
194
+ export function subscribeFloorplanAffordanceToolCancel(
195
+ cancelActiveDrag: () => boolean,
196
+ consumeToolCancel: () => void,
197
+ ): () => void {
198
+ const onToolCancel = () => {
199
+ if (cancelActiveDrag()) consumeToolCancel()
200
+ }
201
+ emitter.on('tool:cancel', onToolCancel)
202
+ return () => emitter.off('tool:cancel', onToolCancel)
203
+ }
204
+
205
+ // Map a floor-plan affordance to the reshaping scope it represents, so the
206
+ // dispatcher can drive the contextual snapping HUD (the chip) AND make
207
+ // `getActiveSnapContext()` resolve the right mode-set during the edit. Geometry
208
+ // edits that set a direction/shape map to a scope; resize / rotate / body-move
209
+ // affordances return `null` (no polygon/wall snapping chip). Keyed off the
210
+ // affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex`
211
+ // / `curve` / `move-endpoint`).
212
+ function affordanceReshapeScope(
213
+ affordance: string,
214
+ nodeId: string,
215
+ payload: unknown,
216
+ ): ActiveInteractionScope | null {
217
+ if (affordance.includes('vertex') || affordance.includes('edge')) {
218
+ const holeIndex = (payload as { holeIndex?: number } | undefined)?.holeIndex
219
+ return holeIndex !== undefined
220
+ ? holeEditScope({ nodeId, holeIndex })
221
+ : boundaryReshapeScope(nodeId)
222
+ }
223
+ if (affordance.includes('curve')) {
224
+ return curveReshapeScope(nodeId)
225
+ }
226
+ if (affordance.includes('control-point')) {
227
+ const index = (payload as { index?: number } | undefined)?.index ?? 0
228
+ return controlPointReshapeScope(nodeId, index)
229
+ }
230
+ if (affordance.includes('tangent')) {
231
+ const target = payload as { index?: number; side?: 'in' | 'out' } | undefined
232
+ return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out')
233
+ }
234
+ if (affordance.includes('endpoint')) {
235
+ const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
236
+ return endpointReshapeScope(nodeId, endpoint)
237
+ }
238
+ // Roof-segment width/depth resize — a no-angle dimension edit, so the
239
+ // no-angle 'polygon' snap set (grid / lines / off) via a boundary scope.
240
+ // Matched exactly so a still-legacy `*-resize` affordance on another kind
241
+ // doesn't get a chip its snap math can't honour yet.
242
+ if (affordance === 'roof-segment-resize') {
243
+ return boundaryReshapeScope(nodeId)
244
+ }
245
+ // 2D corner rotate-arrow (column / elevator / roof-segment / shelf / spawn /
246
+ // stair). Begin the same handle-drag scope the 3D rotate gizmo uses, label-
247
+ // matched, so the contextual HUD shows the "Shift = rotate freely" hint over
248
+ // the drag. The affordance applies the 15° angle step itself.
249
+ if (affordance.includes('rotate')) {
250
+ return { kind: 'handle-drag', nodeId, handle: ROTATE_HANDLE_DRAG_LABEL }
251
+ }
252
+ return null
97
253
  }
98
254
 
99
255
  /**
@@ -109,6 +265,61 @@ type RotationOverlayState = {
109
265
  sweep: number
110
266
  }
111
267
 
268
+ type FloorplanEntryDescriptor = {
269
+ id: AnyNodeId
270
+ node: AnyNode
271
+ dependsOnSiblingInputs: boolean
272
+ ctxOverrides?: FloorplanContextOverrides
273
+ }
274
+
275
+ type NodeDeps = {
276
+ node: AnyNode
277
+ live: LiveTransform | undefined
278
+ unit: 'metric' | 'imperial'
279
+ selected: boolean
280
+ highlighted: boolean
281
+ hovered: boolean
282
+ moving: boolean
283
+ liveOverride: LiveNodeOverrides | undefined
284
+ palette: FloorplanPalette | undefined
285
+ siblingEpoch: number
286
+ committedNodes: Record<string, AnyNode> | null
287
+ dependencyNodes: AnyNode[]
288
+ interactiveElevators: unknown
289
+ }
290
+
291
+ type CacheEntry = {
292
+ deps: NodeDeps
293
+ base: FloorplanGeometry | null
294
+ overlay: FloorplanGeometry | null
295
+ node: AnyNode
296
+ }
297
+
298
+ type LevelDataCacheEntry = {
299
+ nodes: Record<string, AnyNode>
300
+ liveOverrides: Map<string, LiveNodeOverrides>
301
+ ids: readonly AnyNodeId[]
302
+ value: unknown
303
+ }
304
+
305
+ type FloorplanContextOverrides = {
306
+ children: AnyNode[]
307
+ siblings: AnyNode[]
308
+ parent: AnyNode | null
309
+ }
310
+
311
+ type FloorplanLevelDataHook = (args: {
312
+ siblings: ReadonlyArray<AnyNode>
313
+ nodes: Record<string, AnyNode>
314
+ }) => unknown
315
+
316
+ type FloorplanRenderPass = 'base' | 'overlay'
317
+
318
+ const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const
319
+ // Group members advertise the drag-to-move-the-selection gesture.
320
+ const MOVE_CURSOR_STYLE = { cursor: 'move' } as const
321
+ const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
322
+
112
323
  function snapshotNode(node: AnyNode): NodeSnapshot {
113
324
  // Shallow-clone every non-id, non-type field. Arrays / vec tuples are
114
325
  // deep-cloned to detach from the live store reference.
@@ -124,25 +335,32 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
124
335
  return snapshots.map((s) => ({ id: s.id, data: s.data }))
125
336
  }
126
337
 
338
+ // Stable empty sentinel used by per-entry builders while the floor plan is
339
+ // hidden; committed scene edits still flow through `useScene`.
340
+ const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
341
+
127
342
  export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
128
343
  const selectedLevelId = useViewer((s) => s.selection.levelId)
129
344
  const selectedBuildingId = useViewer((s) => s.selection.buildingId)
345
+ const unit = useViewer((s) => s.unit)
346
+ const showMeasurements = useViewer((s) => s.showMeasurements)
130
347
  const selectedIds = useViewer((s) => s.selection.selectedIds)
131
348
  const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
132
349
  const hoveredId = useViewer((s) => s.hoveredId)
350
+ const activeRotateNodeId = useDirectManipulationFeedback((s) => s.activeRotateNodeId)
133
351
  const setHoveredId = useViewer((s) => s.setHoveredId)
134
352
  const setSelection = useViewer((s) => s.setSelection)
135
353
  const nodes = useScene((s) => s.nodes)
354
+ const installedPlugins = useScene((s) => s.installedPlugins)
355
+ const movingNode = useMovingNode()
136
356
  // When a building is being moved, its explicit selection may be
137
357
  // cleared as part of the move handoff. Fall back to the
138
358
  // mid-drag building id so the dimmed floor keeps rendering
139
359
  // throughout the gesture.
140
- const movingBuildingId = useEditor((state) => {
141
- const moving = state.movingNode
142
- if (!moving) return null
143
- const def = nodeRegistry.get(moving.type)
144
- return def?.capabilities?.floorplanLevelContainer ? moving.id : null
145
- })
360
+ const movingBuildingId =
361
+ movingNode && nodeRegistry.get(movingNode.type)?.capabilities?.floorplanLevelContainer
362
+ ? movingNode.id
363
+ : null
146
364
  const ambientBuildingSourceId = selectedBuildingId ?? movingBuildingId
147
365
 
148
366
  // When only a building is in scope (no specific level), fall back to
@@ -152,7 +370,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
152
370
  const ambientLevelId = useMemo<AnyNodeId | null>(() => {
153
371
  if (selectedLevelId || !ambientBuildingSourceId) return null
154
372
  const building = nodes[ambientBuildingSourceId]
155
- if (!building || building.type !== 'building') return null
373
+ if (building?.type !== 'building') return null
156
374
  let zero: AnyNodeId | null = null
157
375
  let lowestId: AnyNodeId | null = null
158
376
  let lowestIdx = Number.POSITIVE_INFINITY
@@ -175,8 +393,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
175
393
  const levelId = selectedLevelId ?? ambientLevelId
176
394
  const isAmbient = !selectedLevelId && !!ambientLevelId
177
395
  const renderCtx = useFloorplanRender()
178
- const movingNode = useEditor((s) => s.movingNode)
179
396
  const setMovingNode = useEditor((s) => s.setMovingNode)
397
+ const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
180
398
  // Door / window placement (both build and move) needs the SVG's
181
399
  // background click handler to run — it finds the closest wall via
182
400
  // `findClosestWallPoint` and emits `wall:click` for the door / window
@@ -189,7 +407,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
189
407
  const editorTool = useEditor((s) => s.tool)
190
408
  const structureLayer = useEditor((s) => s.structureLayer)
191
409
  const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
192
- const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
410
+ const endpointReshape = useEndpointReshape()
193
411
  const isOpeningPlacementActive =
194
412
  (editorPhase === 'structure' &&
195
413
  editorMode === 'build' &&
@@ -200,24 +418,30 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
200
418
  floorplanSelectionTool === 'marquee' &&
201
419
  structureLayer !== 'zones' &&
202
420
  !movingNode &&
203
- !movingFenceEndpoint
204
- // Subscribe to the live-transforms map ref so the layer re-renders
205
- // whenever a 3D mover publishes a per-frame position (see
206
- // `usePlacementCoordinator`). Without this the 2D floor plan only
207
- // updates after 3D commit the 3D drag would look frozen in 2D.
208
- const liveTransforms = useLiveTransforms((s) => s.transforms)
209
- // Same reactivity hook for elevator runtime state — `useInteractive`
210
- // tracks the current / fallback level + cab travel, `useLiveNode
211
- // Overrides` carries live-edit overrides from the inspector. Builders
212
- // read both via `getState()` inside `def.floorplan`; subscribing here
213
- // is what forces the layer to re-render when they change.
214
- const liveOverrides = useLiveNodeOverrides((s) => s.overrides)
421
+ !endpointReshape
422
+ // While the floor plan is not on screen (pure 3D view), per-entry live
423
+ // selectors freeze to `undefined` so drag publishes do not re-render the
424
+ // hidden floor-plan tree.
425
+ const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
426
+ // Elevator builders read runtime state imperatively, so entries include this
427
+ // rare-changing ref in their cache deps.
215
428
  const interactiveElevators = useInteractive((s) => s.elevators)
216
429
 
217
430
  const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
218
431
  // Marquee preview selection — matches the legacy `highlightedIdSet` use
219
432
  // (filter-while-marquee), surfaces selection chrome without keyboard focus.
220
433
  const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds])
434
+ // Multi-selection: members show highlight only (per-node edit chrome hidden)
435
+ // and transformable members advertise the drag-to-move gesture.
436
+ const isMultiSelect = selectedIds.length > 1
437
+ const groupParticipantIdSet = useMemo(() => {
438
+ if (selectedIds.length < 2 || !levelId) return null
439
+ return new Set(
440
+ selectedIds.filter(
441
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
442
+ ),
443
+ )
444
+ }, [selectedIds, levelId, nodes])
221
445
 
222
446
  // Interactive state lives in refs; only the visible feedback bits go
223
447
  // into React state to keep re-renders cheap during drag.
@@ -225,12 +449,89 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
225
449
  const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
226
450
  const [activeDragId, setActiveDragId] = useState<string | null>(null)
227
451
  const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
452
+ const geometryCacheRef = useRef<Map<string, CacheEntry>>(new Map())
453
+ const levelDataCacheRef = useRef<Map<string, LevelDataCacheEntry>>(new Map())
454
+ const nodesRef = useRef(nodes)
455
+ const [siblingEpochs, setSiblingEpochs] = useState<Map<AnyNodeId, number>>(() => new Map())
456
+ // Per-node sibling epoch (replaces a single global epoch). Bumped only for the
457
+ // nodes affected by this frame's live drags, so an unaffected wall/opening
458
+ // keeps its epoch and stays cached. `prevLiveFlaggedIdsRef` remembers which
459
+ // sibling-dependent nodes were live last frame, so a node that just STOPPED
460
+ // being dragged (override cleared, no commit) still gets one final rebuild to
461
+ // revert — its dependents (host wall, junction neighbours) don't carry its
462
+ // override in their own deps.
463
+ const nodeSiblingEpochRef = useRef<Map<AnyNodeId, number>>(new Map())
464
+ const prevLiveFlaggedIdsRef = useRef<AnyNodeId[]>([])
228
465
 
229
- const handleSelect = useCallback(
230
- (id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
231
- if (event.button !== 0) return
232
- event.stopPropagation()
233
- setSelection({ selectedIds: [id] })
466
+ useEffect(() => {
467
+ nodesRef.current = nodes
468
+ }, [nodes])
469
+
470
+ const bumpAffectedSiblingEpochs = useCallback(() => {
471
+ if (!floorplanVisible) return
472
+
473
+ const sceneNodes = nodesRef.current
474
+ const liveTransforms = useLiveTransforms.getState().transforms
475
+ const liveOverrides = useLiveNodeOverrides.getState().overrides
476
+ const liveFlaggedIds: AnyNodeId[] = []
477
+
478
+ for (const [id] of liveTransforms) {
479
+ const node = sceneNodes[id as AnyNodeId]
480
+ const def = node ? nodeRegistry.get(node.type) : null
481
+ if (
482
+ node &&
483
+ (def?.floorplanDependsOnSiblings ||
484
+ def?.floorplanSiblingOverrides ||
485
+ def?.floorplanAffectedIds)
486
+ ) {
487
+ liveFlaggedIds.push(id as AnyNodeId)
488
+ }
489
+ }
490
+ for (const [id] of liveOverrides) {
491
+ const node = sceneNodes[id as AnyNodeId]
492
+ const def = node ? nodeRegistry.get(node.type) : null
493
+ if (
494
+ node &&
495
+ (def?.floorplanDependsOnSiblings ||
496
+ def?.floorplanSiblingOverrides ||
497
+ def?.floorplanAffectedIds)
498
+ ) {
499
+ liveFlaggedIds.push(id as AnyNodeId)
500
+ }
501
+ }
502
+
503
+ const expandFrom = Array.from(new Set([...liveFlaggedIds, ...prevLiveFlaggedIdsRef.current]))
504
+ const affectedSiblingIds = computeAffectedSiblingIds(expandFrom, sceneNodes, liveOverrides)
505
+ const nodeSiblingEpochs = nodeSiblingEpochRef.current
506
+ for (const id of affectedSiblingIds) {
507
+ nodeSiblingEpochs.set(id, (nodeSiblingEpochs.get(id) ?? 0) + 1)
508
+ }
509
+ prevLiveFlaggedIdsRef.current = liveFlaggedIds
510
+ if (affectedSiblingIds.size > 0) {
511
+ setSiblingEpochs(new Map(nodeSiblingEpochs))
512
+ }
513
+ }, [floorplanVisible])
514
+
515
+ useEffect(() => {
516
+ bumpAffectedSiblingEpochs()
517
+ const unsubscribeTransforms = useLiveTransforms.subscribe(bumpAffectedSiblingEpochs)
518
+ const unsubscribeOverrides = useLiveNodeOverrides.subscribe(bumpAffectedSiblingEpochs)
519
+ return () => {
520
+ unsubscribeTransforms()
521
+ unsubscribeOverrides()
522
+ }
523
+ }, [bumpAffectedSiblingEpochs])
524
+
525
+ const applyEntrySelection = useCallback(
526
+ (id: AnyNodeId, shouldToggle: boolean) => {
527
+ const currentSelectedIds = useViewer.getState().selection.selectedIds
528
+ setSelection({
529
+ selectedIds: shouldToggle
530
+ ? currentSelectedIds.includes(id)
531
+ ? currentSelectedIds.filter((selectedId) => selectedId !== id)
532
+ : [...currentSelectedIds, id]
533
+ : [id],
534
+ })
234
535
  // Setting selection re-renders the entry — the overlay pass mounts
235
536
  // (endpoint handles, etc.), reshuffling DOM under the cursor between
236
537
  // pointerdown and click. If the click target ends up on the SVG
@@ -239,129 +540,322 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
239
540
  // selection we just set. Swallow the next click globally to break
240
541
  // that race; the listener removes itself after firing (or after a
241
542
  // safety timeout if no click follows).
242
- const swallowClick = (ev: Event) => {
243
- ev.stopPropagation()
244
- ev.preventDefault()
245
- window.removeEventListener('click', swallowClick, true)
246
- }
247
- window.addEventListener('click', swallowClick, true)
248
- setTimeout(() => window.removeEventListener('click', swallowClick, true), 200)
543
+ swallowNextClick(200)
249
544
  },
250
545
  [setSelection],
251
546
  )
252
547
 
548
+ const handleSelect = useCallback(
549
+ (id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
550
+ if (event.button !== 0) return
551
+ event.stopPropagation()
552
+ applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey)
553
+ },
554
+ [applyEntrySelection],
555
+ )
556
+
253
557
  const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
254
558
  event.stopPropagation()
255
559
  }, [])
256
560
 
257
- // Build the geometry list. `viewState` flows into ctx so kinds can
258
- // theme their output and conditionally emit selection chrome.
259
- //
260
- // Each entry carries TWO trees:
261
- // - `base`: filled shapes, strokes, polygons, hatches — anything
262
- // that should respect the kind's z-order bucket.
263
- // - `overlay`: interactive handles (vertex / midpoint / edge / move)
264
- // and labels (text / dimension). These always render on top of
265
- // every base entry so selection chrome and node names stay visible
266
- // above walls, items, etc.
267
- //
268
- // The split is computed by `splitFloorplanOverlay` from the single
269
- // tree the builder returns. Builders don't need to know about the
270
- // partition.
271
- const entries = useMemo(() => {
272
- // Some builders read elevator runtime state imperatively; this keeps the memo subscribed.
273
- void interactiveElevators
274
-
275
- if (!levelId) return []
276
- const out: {
277
- id: AnyNodeId
278
- node: AnyNode
279
- base: FloorplanGeometry | null
280
- overlay: FloorplanGeometry | null
281
- selected: boolean
282
- highlighted: boolean
283
- }[] = []
561
+ const startDirectMoveDrag = useCallback(
562
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
563
+ if (event.button !== 0 || !(event.metaKey || event.ctrlKey)) return false
284
564
 
285
- const visit = (id: AnyNodeId) => {
286
- const node = nodes[id]
287
- if (!node) return
288
- if ((node as { visible?: boolean }).visible === false) return
289
- const def = nodeRegistry.get(node.type)
290
- const builder = def?.floorplan
291
- if (builder) {
292
- const selected = selectedIdSet.has(id)
293
- const highlighted = highlightedIdSet.has(id)
294
- const hovered = hoveredId === id
295
- const moving = movingNode?.id === id
296
- // Live-transform override — when a mover is publishing per-frame
297
- // position/rotation, render that here instead of the committed
298
- // scene state. Without this the 2D floor plan would only update
299
- // after commit, making the drag look frozen.
300
- //
301
- // The live-transform contract varies per kind (see
302
- // wiki/architecture/tools.md "useLiveTransforms contract is
303
- // per-kind, not generic"); position-carrying floor-placed kinds
304
- // publish canonical X/Z, while slab / ceiling publish a polygon
305
- // translation delta.
306
- const live = liveTransforms.get(id)
307
- let effectiveNode: AnyNode = node
308
- if (live) {
309
- const floorPlaced = def?.capabilities?.floorPlaced
310
- const hasPosition = Array.isArray((node as { position?: unknown }).position)
311
- if (floorPlaced && hasPosition) {
312
- effectiveNode = applyPositionLiveTransform(node, live)
313
- } else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
314
- const dx = live.position[0]
315
- const dz = live.position[2]
316
- if (dx !== 0 || dz !== 0) {
317
- const surface = node as {
318
- polygon: Array<[number, number]>
319
- holes?: Array<Array<[number, number]>>
320
- }
321
- effectiveNode = {
322
- ...node,
323
- polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
324
- holes: (surface.holes ?? []).map((h) =>
325
- h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
326
- ),
327
- } as AnyNode
328
- }
329
- }
330
- }
331
- // Live-edit overrides: kinds whose `def.floorplan` builder
332
- // reads cross-sibling data (wall miters, …) declare a
333
- // `def.floorplanSiblingOverrides` hook that projects the
334
- // override map into a merged `nodes` snapshot. The merged
335
- // copy feeds `buildContext` so `ctx.siblings` reflects the
336
- // live cursor positions, and replaces `effectiveNode` so the
337
- // kind's own override lands too (covers the case where the
338
- // node being rendered is itself the dragged one). Kinds
339
- // without the hook hand the raw `nodes` through — most
340
- // previews are self-contained.
341
- const contextNodes = def?.floorplanSiblingOverrides
342
- ? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
343
- : nodes
344
- if (contextNodes !== nodes) {
345
- const merged = contextNodes[id]
346
- if (merged) effectiveNode = merged
565
+ const node = useScene.getState().nodes[id]
566
+ if (!node || !isRegistryMovable(node.type)) return false
567
+ // Sole selection only: per-node direct manipulation stands down for a
568
+ // multi-selection (the group session owns plain drags there, and Cmd is
569
+ // the selection-toggle key — a wobbly Cmd+click must not yank one
570
+ // member out of the group).
571
+ const currentSelectedIds = useViewer.getState().selection.selectedIds
572
+ if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== id) return false
573
+
574
+ event.preventDefault()
575
+ event.stopPropagation()
576
+
577
+ const startX = event.clientX
578
+ const startY = event.clientY
579
+ const pointerId = event.pointerId
580
+ let engaged = false
581
+
582
+ const cleanup = () => {
583
+ window.removeEventListener('pointermove', onMove)
584
+ window.removeEventListener('pointerup', onEnd)
585
+ window.removeEventListener('pointercancel', onEnd)
586
+ if (engaged) {
587
+ useViewer.getState().setInputDragging(false)
347
588
  }
348
- const ctx = buildContext(effectiveNode, contextNodes, {
349
- selected,
350
- highlighted,
351
- hovered,
352
- moving,
353
- palette: renderCtx?.palette,
589
+ }
590
+
591
+ const onMove = (moveEvent: PointerEvent) => {
592
+ if (moveEvent.pointerId !== pointerId) return
593
+ if (engaged) return
594
+ const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY)
595
+ if (distance < DIRECT_DRAG_THRESHOLD_PX) return
596
+
597
+ engaged = true
598
+ useViewer.getState().setInputDragging(true)
599
+ swallowNextClick(300)
600
+ createEditorApi().engageMoveDrag(node)
601
+
602
+ requestAnimationFrame(() => {
603
+ window.dispatchEvent(
604
+ new PointerEvent('pointermove', {
605
+ altKey: moveEvent.altKey,
606
+ bubbles: true,
607
+ buttons: moveEvent.buttons,
608
+ clientX: moveEvent.clientX,
609
+ clientY: moveEvent.clientY,
610
+ ctrlKey: moveEvent.ctrlKey,
611
+ metaKey: moveEvent.metaKey,
612
+ pointerId,
613
+ pointerType: moveEvent.pointerType,
614
+ shiftKey: moveEvent.shiftKey,
615
+ }),
616
+ )
354
617
  })
355
- const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
356
- effectiveNode,
357
- ctx,
618
+ }
619
+
620
+ const onEnd = (endEvent: PointerEvent) => {
621
+ if (endEvent.pointerId !== pointerId) return
622
+ cleanup()
623
+ if (!engaged) {
624
+ applyEntrySelection(id, true)
625
+ }
626
+ }
627
+
628
+ window.addEventListener('pointermove', onMove)
629
+ window.addEventListener('pointerup', onEnd)
630
+ window.addEventListener('pointercancel', onEnd)
631
+ return true
632
+ },
633
+ [applyEntrySelection],
634
+ )
635
+
636
+ const startDirectRotateDrag = useCallback(
637
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
638
+ if (event.button !== 2 || !(event.metaKey || event.ctrlKey)) return false
639
+
640
+ const sceneNodes = useScene.getState().nodes
641
+ const selectedNode = sceneNodes[id]
642
+ const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
643
+ if (!node || !canDirectRotateNode(node)) return false
644
+ // Sole selection only — same stand-down as the direct move above.
645
+ const selectedIds = useViewer.getState().selection.selectedIds
646
+ if (selectedIds.length !== 1 || selectedIds[0] !== id) return false
647
+ event.preventDefault()
648
+ event.stopPropagation()
649
+
650
+ const nodeId = node.id as AnyNodeId
651
+ const pointerId = event.pointerId
652
+ const startX = event.clientX
653
+ const sceneApi = createSceneApi(useScene)
654
+ let lastPatch: Partial<AnyNode> | null = null
655
+
656
+ const applyDelta = (pointerEvent: PointerEvent | ReactPointerEvent<SVGGElement>) => {
657
+ const delta = resolveDirectRotationDragDelta(
658
+ startX,
659
+ pointerEvent.clientX,
660
+ DIRECT_ROTATE_RADIANS_PER_PIXEL,
661
+ pointerEvent.shiftKey,
358
662
  )
359
- if (geometry) {
360
- const { base, overlay } = splitFloorplanOverlay(geometry)
361
- out.push({ id, node: effectiveNode, base, overlay, selected, highlighted })
663
+ if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
664
+ lastPatch = null
665
+ useLiveNodeOverrides.getState().clear(nodeId)
666
+ useScene.getState().markDirty(nodeId)
667
+ return
668
+ }
669
+ const patch = resolveDirectRotationPatch(node, delta, sceneApi)
670
+ if (!patch) return
671
+ lastPatch = patch
672
+ useLiveNodeOverrides.getState().set(nodeId, patch as Record<string, unknown>)
673
+ useScene.getState().markDirty(nodeId)
674
+ }
675
+
676
+ const cleanup = () => {
677
+ window.removeEventListener('pointermove', onMove, true)
678
+ window.removeEventListener('pointerup', onUp, true)
679
+ window.removeEventListener('pointercancel', onCancel, true)
680
+ window.removeEventListener('contextmenu', preventContextMenu, true)
681
+ useLiveNodeOverrides.getState().clear(nodeId)
682
+ useScene.getState().markDirty(nodeId)
683
+ resumeSceneHistory(useScene)
684
+ useDirectManipulationFeedback.getState().clearActiveRotateNodeId(nodeId)
685
+ useViewer.getState().setInputDragging(false)
686
+ if (document.body.style.cursor === 'ew-resize') {
687
+ document.body.style.cursor = ''
688
+ }
689
+ }
690
+
691
+ const onMove = (moveEvent: PointerEvent) => {
692
+ if (moveEvent.pointerId !== pointerId) return
693
+ moveEvent.preventDefault()
694
+ moveEvent.stopPropagation()
695
+ applyDelta(moveEvent)
696
+ }
697
+
698
+ const onUp = (upEvent: PointerEvent) => {
699
+ if (upEvent.pointerId !== pointerId) return
700
+ upEvent.preventDefault()
701
+ upEvent.stopPropagation()
702
+ swallowNextClick(300)
703
+ if (lastPatch) {
704
+ sceneApi.update(nodeId, lastPatch)
705
+ sfxEmitter.emit('sfx:item-place')
362
706
  }
707
+ cleanup()
708
+ }
709
+
710
+ const onCancel = (cancelEvent: PointerEvent) => {
711
+ if (cancelEvent.pointerId !== pointerId) return
712
+ cleanup()
713
+ }
714
+
715
+ const preventContextMenu = (contextEvent: Event) => {
716
+ contextEvent.preventDefault()
717
+ contextEvent.stopPropagation()
718
+ }
719
+
720
+ pauseSceneHistory(useScene)
721
+ useViewer.getState().setInputDragging(true)
722
+ useDirectManipulationFeedback.getState().setActiveRotateNodeId(nodeId)
723
+ document.body.style.cursor = 'ew-resize'
724
+ sfxEmitter.emit('sfx:item-pick')
725
+ applyDelta(event)
726
+
727
+ window.addEventListener('pointermove', onMove, true)
728
+ window.addEventListener('pointerup', onUp, true)
729
+ window.addEventListener('pointercancel', onCancel, true)
730
+ window.addEventListener('contextmenu', preventContextMenu, true)
731
+ return true
732
+ },
733
+ [],
734
+ )
735
+
736
+ // Photoshop-style group drag: plain pointer-down on a transformable member
737
+ // of a multi-selection slides the whole selection rigidly; a plain click
738
+ // (no drag) enters the group pick-up instead — parity with the single-item
739
+ // click-to-move (clicking outside still deselects). Modified clicks
740
+ // (selection toggle) and Cmd-drag / direct-rotate keep their existing
741
+ // paths. `immediate` engages without the drag threshold — the move-handle
742
+ // dot's pick-up semantics.
743
+ const startGroupMoveDrag = useCallback(
744
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>, immediate = false): boolean => {
745
+ if (event.button !== 0) return false
746
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
747
+ if (movingNode) return false
748
+ if (useEditor.getState().mode === 'delete') return false
749
+ const started = startFloorplanGroupMove(id, event, {
750
+ immediate,
751
+ onClickFallthrough: immediate ? undefined : () => startGroupPickUp(),
752
+ })
753
+ if (!started) return false
754
+ event.preventDefault()
755
+ event.stopPropagation()
756
+ suppressBoxSelectForPointer(event)
757
+ return true
758
+ },
759
+ [movingNode],
760
+ )
761
+
762
+ // Move-handle dot variant — routes the dot through the group session when
763
+ // the owning node is part of a multi-selection.
764
+ const handleGroupMoveHandlePointerDown = useCallback(
765
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => startGroupMoveDrag(id, event, true),
766
+ [startGroupMoveDrag],
767
+ )
768
+
769
+ // The dashed selection box is itself the group's drag handle: a press
770
+ // anywhere inside it slides the group (or picks it up on a plain click),
771
+ // anchored on the first transformable member.
772
+ const handleGroupBoxPointerDown = useCallback(
773
+ (event: ReactPointerEvent<SVGGElement>) => {
774
+ const { selectedIds: currentIds, levelId: currentLevelId } = useViewer.getState().selection
775
+ const sceneNodes = useScene.getState().nodes
776
+ const anchor = currentIds.find(
777
+ (id) =>
778
+ classifyParticipant(sceneNodes[id as AnyNodeId], currentLevelId, sceneNodes) !== null,
779
+ )
780
+ if (!anchor) return
781
+ startGroupMoveDrag(anchor as AnyNodeId, event)
782
+ },
783
+ [startGroupMoveDrag],
784
+ )
785
+
786
+ // Corner rotate handles on the dashed selection box — 15° steps, Shift
787
+ // free, mirroring the 3D group rotate gizmo.
788
+ const handleGroupBoxRotatePointerDown = useCallback((event: ReactPointerEvent<SVGGElement>) => {
789
+ if (event.button !== 0) return
790
+ if (event.metaKey || event.ctrlKey || event.altKey) return
791
+ if (useEditor.getState().mode === 'delete') return
792
+ if (startFloorplanGroupRotate(event)) {
793
+ event.preventDefault()
794
+ suppressBoxSelectForPointer(event)
795
+ }
796
+ }, [])
797
+
798
+ const handleEntryPointerDown = useCallback(
799
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
800
+ if (startDirectMoveDrag(id, event)) return
801
+ if (startDirectRotateDrag(id, event)) return
802
+ if (startGroupMoveDrag(id, event)) return
803
+ handleSelect(id, event)
804
+ },
805
+ [handleSelect, startDirectMoveDrag, startDirectRotateDrag, startGroupMoveDrag],
806
+ )
807
+
808
+ const floorplanData = useMemo(() => {
809
+ if (!levelId) {
810
+ geometryCacheRef.current.clear()
811
+ levelDataCacheRef.current.clear()
812
+ return {
813
+ entries: [] as FloorplanEntryDescriptor[],
814
+ levelNodeIdsByType: new Map<string, AnyNodeId[]>(),
815
+ }
816
+ }
817
+
818
+ const out: FloorplanEntryDescriptor[] = []
819
+ const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
820
+
821
+ const collectLevelDataKind = (id: AnyNodeId) => {
822
+ const node = nodes[id]
823
+ if (!node) return
824
+ if (!isNodeKindEnabled(node.type, installedPlugins)) return
825
+ const def = nodeRegistry.get(node.type)
826
+ if (def?.computeFloorplanLevelData) {
827
+ const ids = levelNodeIdsByType.get(node.type)
828
+ if (ids) ids.push(id)
829
+ else levelNodeIdsByType.set(node.type, [id])
363
830
  }
364
831
  const childIds = (node as unknown as { children?: AnyNodeId[] }).children
832
+ if (Array.isArray(childIds)) {
833
+ for (const cid of childIds) collectLevelDataKind(cid)
834
+ }
835
+ }
836
+
837
+ collectLevelDataKind(levelId as AnyNodeId)
838
+
839
+ const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
840
+ if (!isNodeKindEnabled(node.type, installedPlugins)) return
841
+ const def = nodeRegistry.get(node.type)
842
+ if (!def?.floorplan) return
843
+ if (node.type === 'measurement' && !showMeasurements) return
844
+ const dependsOnSiblingInputs = !!(
845
+ def.floorplanDependsOnSiblings ||
846
+ def.floorplanSiblingOverrides ||
847
+ def.floorplanAffectedIds
848
+ )
849
+ const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs }
850
+ if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
851
+ out.push(descriptor)
852
+ }
853
+
854
+ const visit = (id: AnyNodeId) => {
855
+ const node = nodes[id]
856
+ if (!node) return
857
+ pushEntry(id, node)
858
+ const childIds = (node as unknown as { children?: AnyNodeId[] }).children
365
859
  if (Array.isArray(childIds)) {
366
860
  for (const cid of childIds) visit(cid)
367
861
  }
@@ -386,54 +880,14 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
386
880
  const buildingScopedKindSet = new Set(buildingScopedKinds)
387
881
  for (const [id, node] of Object.entries(nodes)) {
388
882
  if (!node || !buildingScopedKindSet.has(node.type)) continue
389
- if ((node as { visible?: boolean }).visible === false) continue
390
883
  const parentId = (node as { parentId?: AnyNodeId | null }).parentId
391
884
  if (parentId !== activeBuildingId) continue
392
885
  const cid = id as AnyNodeId
393
- const def = nodeRegistry.get(node.type)
394
- const builder = def?.floorplan
395
- if (!builder) continue
396
- const selected = selectedIdSet.has(cid)
397
- const highlighted = highlightedIdSet.has(cid)
398
- const hovered = hoveredId === cid
399
- const moving = movingNode?.id === cid
400
- const live = liveTransforms.get(cid)
401
- const hasPosition = Array.isArray((node as { position?: unknown }).position)
402
- let effectiveNode: AnyNode =
403
- live && hasPosition ? applyPositionLiveTransform(node, live) : node
404
- const contextNodes = def?.floorplanSiblingOverrides
405
- ? def.floorplanSiblingOverrides({ nodeId: cid, nodes, liveOverrides })
406
- : nodes
407
- if (contextNodes !== nodes) {
408
- const merged = contextNodes[cid]
409
- if (merged) {
410
- effectiveNode = live && hasPosition ? applyPositionLiveTransform(merged, live) : merged
411
- }
412
- }
413
- const ctx: GeometryContext = {
414
- resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
415
- contextNodes[rid] as N | undefined,
886
+ pushEntry(cid, node, {
416
887
  children: [],
417
888
  siblings: [],
418
889
  parent: activeLevelNode,
419
- viewState: renderCtx?.palette
420
- ? {
421
- selected,
422
- highlighted,
423
- hovered,
424
- moving,
425
- palette: renderCtx.palette,
426
- }
427
- : undefined,
428
- }
429
- const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
430
- effectiveNode,
431
- ctx,
432
- )
433
- if (geometry) {
434
- const { base, overlay } = splitFloorplanOverlay(geometry)
435
- out.push({ id: cid, node: effectiveNode, base, overlay, selected, highlighted })
436
- }
890
+ })
437
891
  }
438
892
  }
439
893
 
@@ -445,19 +899,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
445
899
  // DFS visit order (stable sort) so siblings keep their relative
446
900
  // priority.
447
901
  out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
448
- return out
449
- }, [
450
- levelId,
451
- nodes,
452
- liveTransforms,
453
- liveOverrides,
454
- selectedIdSet,
455
- highlightedIdSet,
456
- hoveredId,
457
- movingNode?.id,
458
- renderCtx?.palette,
459
- interactiveElevators,
460
- ])
902
+ const entryIds = new Set(out.map((entry) => entry.id))
903
+ for (const id of geometryCacheRef.current.keys()) {
904
+ if (!entryIds.has(id as AnyNodeId)) geometryCacheRef.current.delete(id)
905
+ }
906
+ for (const type of levelDataCacheRef.current.keys()) {
907
+ if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
908
+ }
909
+ return { entries: out, levelNodeIdsByType }
910
+ }, [installedPlugins, levelId, nodes, showMeasurements])
461
911
 
462
912
  // ── Generic 2D affordance dispatch ─────────────────────────────────
463
913
  //
@@ -466,6 +916,37 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
466
916
  // dispatcher then owns: history pause/resume, snapshot capture,
467
917
  // pointer-move/up/cancel routing, and the single-undo dance on
468
918
  // commit. Each kind owns the actual mutation logic inside `apply`.
919
+ const commitAffordanceAction = useCallback(
920
+ (
921
+ nodeId: AnyNodeId,
922
+ affordance: string,
923
+ payload: unknown,
924
+ event: ReactMouseEvent<SVGElement>,
925
+ ) => {
926
+ if (event.button !== 0 || movingNode || dragRef.current) return
927
+
928
+ const sceneNodes = useScene.getState().nodes
929
+ const node = sceneNodes[nodeId]
930
+ if (!node) return
931
+ const handler = nodeRegistry.get(node.type)?.floorplanAffordances?.[affordance]
932
+ if (!handler) return
933
+ const initialPlanPoint = clientToPlan(event.clientX, event.clientY)
934
+ if (!initialPlanPoint) return
935
+
936
+ const session = handler.start({
937
+ node,
938
+ payload,
939
+ nodes: sceneNodes,
940
+ initialPlanPoint,
941
+ gridSnapStep: useEditor.getState().gridSnapStep,
942
+ })
943
+ if (!(session.commit && session.canCommit())) return
944
+ session.commit()
945
+ sfxEmitter.emit('sfx:structure-build')
946
+ },
947
+ [movingNode],
948
+ )
949
+
469
950
  const startAffordanceDrag = useCallback(
470
951
  (
471
952
  nodeId: AnyNodeId,
@@ -493,6 +974,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
493
974
 
494
975
  event.preventDefault()
495
976
  event.stopPropagation()
977
+ suppressBoxSelectForPointer(event)
496
978
 
497
979
  const session = handler.start({
498
980
  node,
@@ -525,22 +1007,73 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
525
1007
  }
526
1008
  }
527
1009
 
1010
+ // Begin the matching reshaping scope so the contextual snapping HUD shows
1011
+ // the right chip during the edit AND `getActiveSnapContext()` resolves the
1012
+ // polygon / wall mode-set the affordance's snap math reads. Torn down on
1013
+ // release / cancel below. `null` for resize / rotate (no snapping chip).
1014
+ const reshapeScope = affordanceReshapeScope(affordance, nodeId, payload)
1015
+ if (reshapeScope) {
1016
+ useInteractionScope.getState().begin(reshapeScope)
1017
+ }
1018
+
1019
+ const captureTarget = event.currentTarget as Element
528
1020
  dragRef.current = {
529
1021
  pointerId: event.pointerId,
1022
+ captureTarget,
530
1023
  handleId,
531
1024
  session,
532
1025
  snapshots,
533
1026
  historyPaused: true,
1027
+ lastPlanPoint: initialPlanPoint,
534
1028
  rotation,
1029
+ reshapeScopeNodeId: reshapeScope ? nodeId : undefined,
535
1030
  }
536
1031
  setActiveDragId(handleId)
537
1032
  setSelection({ selectedIds: [nodeId] })
538
- ;(event.currentTarget as Element).setPointerCapture?.(event.pointerId)
1033
+ captureTarget.setPointerCapture?.(event.pointerId)
539
1034
  },
540
1035
  [movingNode, setSelection],
541
1036
  )
542
1037
 
543
1038
  useEffect(() => {
1039
+ // Tear down the scope this drag opened (if any) — a reshaping scope for an
1040
+ // edit affordance, or a handle-drag scope for a rotate-arrow — matched by
1041
+ // node id so a concurrent scope from another path is never ended by mistake.
1042
+ const endReshapeScope = (drag: ActiveDrag) => {
1043
+ if (drag.reshapeScopeNodeId) {
1044
+ useInteractionScope
1045
+ .getState()
1046
+ .endIf(
1047
+ (s) =>
1048
+ (s.kind === 'reshaping' || s.kind === 'handle-drag') &&
1049
+ s.nodeId === drag.reshapeScopeNodeId,
1050
+ )
1051
+ }
1052
+ }
1053
+
1054
+ const cancelActiveDrag = (pointerId?: number, clearDragFeedback = true) =>
1055
+ cancelFloorplanAffordanceDrag(
1056
+ dragRef,
1057
+ {
1058
+ restoreSnapshots: (snapshots) =>
1059
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots)),
1060
+ resumeHistory: () => resumeSceneHistory(useScene),
1061
+ clearPreview: (id) => {
1062
+ useLiveNodeOverrides.getState().clear(id)
1063
+ useLiveTransforms.getState().clear(id)
1064
+ },
1065
+ clearSnapFeedback: clearSurfacePlanSnapFeedback,
1066
+ endReshapeScope,
1067
+ clearDragFeedback: clearDragFeedback
1068
+ ? () => {
1069
+ setActiveDragId(null)
1070
+ setRotationOverlay(null)
1071
+ }
1072
+ : undefined,
1073
+ },
1074
+ pointerId,
1075
+ )
1076
+
544
1077
  const onPointerMove = (event: PointerEvent) => {
545
1078
  const drag = dragRef.current
546
1079
  if (!drag || event.pointerId !== drag.pointerId) return
@@ -548,6 +1081,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
548
1081
  const planPoint = clientToPlan(event.clientX, event.clientY)
549
1082
  if (!planPoint) return
550
1083
 
1084
+ drag.lastPlanPoint = planPoint
551
1085
  drag.session.apply({
552
1086
  planPoint,
553
1087
  modifiers: {
@@ -569,6 +1103,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
569
1103
  let delta = current - rot.initialAngle
570
1104
  while (delta > Math.PI) delta -= 2 * Math.PI
571
1105
  while (delta < -Math.PI) delta += 2 * Math.PI
1106
+ // Match the affordance's 15° angle step (Shift = free) so the wedge +
1107
+ // degree chip read the committed rotation, not the raw pointer bearing.
1108
+ delta = snapDirectRotationDelta(delta, event.shiftKey)
572
1109
  if (Math.abs(delta) < 0.0087) {
573
1110
  setRotationOverlay(null)
574
1111
  } else {
@@ -603,14 +1140,18 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
603
1140
  }
604
1141
  drag.session.commit()
605
1142
  sfxEmitter.emit('sfx:structure-build')
1143
+ clearSurfacePlanSnapFeedback()
1144
+ endReshapeScope(drag)
606
1145
  dragRef.current = null
607
1146
  setActiveDragId(null)
608
1147
  setRotationOverlay(null)
609
1148
  return
610
1149
  }
611
1150
 
612
- // Capture the final state BEFORE the revert so we know what to
613
- // re-apply post-resume.
1151
+ // Legacy compatibility for sessions that still wrote preview state into
1152
+ // `useScene` during `apply()`: capture the final state BEFORE the revert
1153
+ // so we know what to re-apply post-resume. New sessions should provide a
1154
+ // `commit()` hook and preview through live overrides/transforms instead.
614
1155
  const sceneNodes = useScene.getState().nodes
615
1156
  const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
616
1157
  for (const snap of drag.snapshots) {
@@ -629,7 +1170,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
629
1170
  }
630
1171
 
631
1172
  if (commitValid && finalUpdates.length > 0) {
632
- // Single-undo dance (mirrors the 3D move-endpoint-tool):
1173
+ // Legacy single-undo dance (mirrors the old 3D move-endpoint-tool):
633
1174
  // 1. Revert to baseline while history is still paused (untracked).
634
1175
  // 2. Resume history.
635
1176
  // 3. Re-apply the final state — recorded as one tracked change.
@@ -654,129 +1195,71 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
654
1195
  for (const id of drag.session.affectedIds) overrides.clear(id)
655
1196
  }
656
1197
 
1198
+ clearSurfacePlanSnapFeedback()
1199
+ endReshapeScope(drag)
657
1200
  dragRef.current = null
658
1201
  setActiveDragId(null)
659
1202
  setRotationOverlay(null)
660
1203
  }
661
1204
 
662
1205
  const onPointerCancel = (event: PointerEvent) => {
663
- const drag = dragRef.current
664
- if (!drag || event.pointerId !== drag.pointerId) return
1206
+ cancelActiveDrag(event.pointerId)
1207
+ }
665
1208
 
666
- // Revert untracked, then resume no history entry is recorded.
667
- useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
668
- if (drag.historyPaused) {
669
- resumeSceneHistory(useScene)
670
- drag.historyPaused = false
1209
+ // Re-run the active session the moment a modifier key flips so behaviors
1210
+ // like the wall endpoint's Alt-detach / re-attach take effect immediately
1211
+ // instead of waiting for the next pointer move. `event.altKey` & co
1212
+ // already reflect the post-transition state on both keydown and keyup.
1213
+ const onModifierKeyChange = (event: KeyboardEvent) => {
1214
+ const drag = dragRef.current
1215
+ if (!drag || event.repeat) return
1216
+ if (
1217
+ event.key !== 'Alt' &&
1218
+ event.key !== 'Shift' &&
1219
+ event.key !== 'Control' &&
1220
+ event.key !== 'Meta'
1221
+ ) {
1222
+ return
671
1223
  }
672
- // Affordances that publish Figma alignment guides during `apply`
673
- // (fence endpoint) leave them in the store on cancel — `canCommit`
674
- // (the pointer-up clear) never runs on a cancel.
675
- useAlignmentGuides.getState().clear()
676
- // Drop any live overrides the session may have published. No-op
677
- // for affordances whose `apply()` writes straight to scene; the
678
- // override-routed sessions (wall endpoint, wall curve) rely on
679
- // this to revert cleanly.
680
- const overrides = useLiveNodeOverrides.getState()
681
- for (const id of drag.session.affectedIds) overrides.clear(id)
682
-
683
- dragRef.current = null
684
- setActiveDragId(null)
685
- setRotationOverlay(null)
1224
+ drag.session.apply({
1225
+ planPoint: drag.lastPlanPoint,
1226
+ modifiers: {
1227
+ shiftKey: event.shiftKey,
1228
+ altKey: event.altKey,
1229
+ ctrlKey: event.ctrlKey,
1230
+ metaKey: event.metaKey,
1231
+ },
1232
+ })
686
1233
  }
687
1234
 
688
1235
  window.addEventListener('pointermove', onPointerMove)
689
1236
  window.addEventListener('pointerup', onPointerUp)
690
1237
  window.addEventListener('pointercancel', onPointerCancel)
1238
+ window.addEventListener('keydown', onModifierKeyChange)
1239
+ window.addEventListener('keyup', onModifierKeyChange)
1240
+ const unsubscribeToolCancel = subscribeFloorplanAffordanceToolCancel(
1241
+ () => cancelActiveDrag(),
1242
+ markToolCancelConsumed,
1243
+ )
691
1244
  return () => {
692
1245
  window.removeEventListener('pointermove', onPointerMove)
693
1246
  window.removeEventListener('pointerup', onPointerUp)
694
1247
  window.removeEventListener('pointercancel', onPointerCancel)
695
- // Component unmounted mid-drag — restore the baseline and unpause
696
- // history so we don't leak a paused store across mounts. Also
697
- // drop any live overrides the session published so the next
698
- // mount doesn't render at the cancelled position.
699
- const drag = dragRef.current
700
- if (drag) {
701
- useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
702
- if (drag.historyPaused) {
703
- resumeSceneHistory(useScene)
704
- }
705
- const overrides = useLiveNodeOverrides.getState()
706
- for (const id of drag.session.affectedIds) overrides.clear(id)
707
- dragRef.current = null
1248
+ window.removeEventListener('keydown', onModifierKeyChange)
1249
+ window.removeEventListener('keyup', onModifierKeyChange)
1250
+ unsubscribeToolCancel()
1251
+ if (!cancelActiveDrag(undefined, false)) {
1252
+ clearSurfacePlanSnapFeedback()
708
1253
  }
709
- // Clear any alignment guide a session left behind on mid-drag unmount.
710
- useAlignmentGuides.getState().clear()
711
1254
  }
712
1255
  }, [])
713
1256
 
1257
+ const entries = floorplanData.entries
714
1258
  if (entries.length === 0) return null
715
1259
 
716
1260
  const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1
717
1261
  const palette = renderCtx?.palette
718
1262
 
719
- const renderEntry = (id: AnyNodeId, geometry: FloorplanGeometry, key: string) => (
720
- <g
721
- className="floorplan-registry-entry"
722
- data-node-id={id}
723
- key={key}
724
- onClick={isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handleClickStop}
725
- onPointerDown={
726
- isOpeningPlacementActive || isMarqueeSelectionActive
727
- ? undefined
728
- : (e) => handleSelect(id, e)
729
- }
730
- // Mirror the sidebar tree nodes' hover wiring — `useViewer.
731
- // hoveredId` drives the highlight halo in 3D as well as the
732
- // wall / fence floor-plan hover stroke. Setting it on
733
- // pointer-enter and clearing on leave keeps the two views in
734
- // sync. Without this the registry-driven kinds had hover
735
- // visuals defined but never reached because the entry `<g>`
736
- // never updated the store.
737
- onPointerEnter={() => setHoveredId(id)}
738
- onPointerLeave={() => {
739
- // Only clear when this entry is the one we last set —
740
- // avoids racing with sibling entries during fast-moving
741
- // pointer scans.
742
- if (useViewer.getState().hoveredId === id) setHoveredId(null)
743
- }}
744
- style={{ cursor: 'pointer' }}
745
- >
746
- <InteractiveGeometry
747
- activeDragId={activeDragId}
748
- geometry={geometry}
749
- hatchPatternId={renderCtx?.hatchPatternId}
750
- hoveredHandleId={hoveredHandleId}
751
- isMarqueeSelectionActive={isMarqueeSelectionActive}
752
- nodeId={id}
753
- onHandleHoverChange={setHoveredHandleId}
754
- onHandlePointerDown={(affordance, payload, event, rotationPivot) =>
755
- startAffordanceDrag(
756
- id,
757
- makeHandleId(id, payload),
758
- affordance,
759
- payload,
760
- event,
761
- rotationPivot,
762
- )
763
- }
764
- onMoveHandlePointerDown={(event) => {
765
- if (event.button !== 0) return
766
- const node = useScene.getState().nodes[id]
767
- if (!node) return
768
- event.preventDefault()
769
- event.stopPropagation()
770
- sfxEmitter.emit('sfx:item-pick')
771
- setMovingNode(node as never)
772
- }}
773
- palette={palette}
774
- sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
775
- unitsPerPixel={unitsPerPixel}
776
- />
777
- </g>
778
- )
779
-
780
1263
  return (
781
1264
  // The outer wrapper stops `click` events that escape an entry's
782
1265
  // `onClick={handleClickStop}`. The base+overlay split means
@@ -796,14 +1279,55 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
796
1279
  className="floorplan-registry-layer"
797
1280
  onClick={isOpeningPlacementActive ? undefined : handleClickStop}
798
1281
  opacity={isAmbient ? 0.3 : undefined}
799
- style={isAmbient ? { pointerEvents: 'none' } : undefined}
1282
+ style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
800
1283
  >
801
1284
  {/* Base pass — rank-sorted body geometry (polygons, paths, fills,
802
1285
  strokes, hatches). Lower-rank kinds (zones) paint first so
803
1286
  higher-rank kinds (slabs, then walls / items / shelves) layer
804
1287
  on top in the expected document-order z-stack. */}
805
1288
  <g className="floorplan-registry-base">
806
- {entries.map(({ id, base }) => (base ? renderEntry(id, base, `base-${id}`) : null))}
1289
+ {entries.map((entry) => (
1290
+ <FloorplanRegistryEntry
1291
+ activeDragId={handleIdForNode(activeDragId, entry.id)}
1292
+ activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
1293
+ floorplanVisible={floorplanVisible}
1294
+ geometryCacheRef={geometryCacheRef}
1295
+ hatchPatternId={renderCtx?.hatchPatternId}
1296
+ highlighted={highlightedIdSet.has(entry.id)}
1297
+ hovered={hoveredId === entry.id}
1298
+ hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1299
+ interactiveElevators={interactiveElevators}
1300
+ isMarqueeSelectionActive={isMarqueeSelectionActive}
1301
+ isOpeningPlacementActive={isOpeningPlacementActive}
1302
+ key={`base-${entry.id}`}
1303
+ levelDataCacheRef={levelDataCacheRef}
1304
+ levelNodeIdsByType={floorplanData.levelNodeIdsByType}
1305
+ moving={movingNode?.id === entry.id}
1306
+ node={entry.node}
1307
+ nodeId={entry.id}
1308
+ nodes={nodes}
1309
+ onClickStop={handleClickStop}
1310
+ onEntryPointerDown={handleEntryPointerDown}
1311
+ onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
1312
+ onHandleHoverChange={setHoveredHandleId}
1313
+ onHandleDoubleClick={commitAffordanceAction}
1314
+ onHandlePointerDown={startAffordanceDrag}
1315
+ onHoveredIdChange={setHoveredId}
1316
+ palette={palette}
1317
+ pass="base"
1318
+ sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
1319
+ selected={selectedIdSet.has(entry.id)}
1320
+ suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
1321
+ groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
1322
+ setMovingNode={setMovingNode}
1323
+ setMovingNodeOrigin={setMovingNodeOrigin}
1324
+ siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1325
+ unit={unit}
1326
+ unitsPerPixel={unitsPerPixel}
1327
+ visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
1328
+ ctxOverrides={entry.ctxOverrides}
1329
+ />
1330
+ ))}
807
1331
  </g>
808
1332
  {/* Overlay pass — interactive handles (vertex / midpoint / edge /
809
1333
  move) and labels (text / dimensions). Painted after every base
@@ -813,10 +1337,58 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
813
1337
  still routes through the same selection-handling `<g>` so a
814
1338
  click on a zone's name selects the zone. */}
815
1339
  <g className="floorplan-registry-overlay">
816
- {entries.map(({ id, overlay }) =>
817
- overlay ? renderEntry(id, overlay, `overlay-${id}`) : null,
818
- )}
1340
+ {entries.map((entry) => (
1341
+ <FloorplanRegistryEntry
1342
+ activeDragId={handleIdForNode(activeDragId, entry.id)}
1343
+ activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
1344
+ floorplanVisible={floorplanVisible}
1345
+ geometryCacheRef={geometryCacheRef}
1346
+ hatchPatternId={renderCtx?.hatchPatternId}
1347
+ highlighted={highlightedIdSet.has(entry.id)}
1348
+ hovered={hoveredId === entry.id}
1349
+ hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1350
+ interactiveElevators={interactiveElevators}
1351
+ isMarqueeSelectionActive={isMarqueeSelectionActive}
1352
+ isOpeningPlacementActive={isOpeningPlacementActive}
1353
+ key={`overlay-${entry.id}`}
1354
+ levelDataCacheRef={levelDataCacheRef}
1355
+ levelNodeIdsByType={floorplanData.levelNodeIdsByType}
1356
+ moving={movingNode?.id === entry.id}
1357
+ node={entry.node}
1358
+ nodeId={entry.id}
1359
+ nodes={nodes}
1360
+ onClickStop={handleClickStop}
1361
+ onEntryPointerDown={handleEntryPointerDown}
1362
+ onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
1363
+ onHandleHoverChange={setHoveredHandleId}
1364
+ onHandleDoubleClick={commitAffordanceAction}
1365
+ onHandlePointerDown={startAffordanceDrag}
1366
+ onHoveredIdChange={setHoveredId}
1367
+ palette={palette}
1368
+ pass="overlay"
1369
+ sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
1370
+ selected={selectedIdSet.has(entry.id)}
1371
+ suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
1372
+ groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
1373
+ setMovingNode={setMovingNode}
1374
+ setMovingNodeOrigin={setMovingNodeOrigin}
1375
+ siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1376
+ unit={unit}
1377
+ unitsPerPixel={unitsPerPixel}
1378
+ visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
1379
+ ctxOverrides={entry.ctxOverrides}
1380
+ />
1381
+ ))}
819
1382
  </g>
1383
+ {/* Dashed group bbox — shows what a group drag carries along while a
1384
+ multi-selection exists, rides the live delta mid-drag, and doubles
1385
+ as the group's whole-area drag handle. */}
1386
+ <FloorplanGroupSelectionBox
1387
+ onPointerDown={handleGroupBoxPointerDown}
1388
+ onRotatePointerDown={handleGroupBoxRotatePointerDown}
1389
+ palette={palette}
1390
+ unitsPerPixel={unitsPerPixel}
1391
+ />
820
1392
  {/* Transient live-rotation readout — drawn last so the wedge + degree
821
1393
  chip sit above all handle chrome while a rotate-arrow is dragged. */}
822
1394
  {rotationOverlay && palette ? (
@@ -831,32 +1403,517 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
831
1403
  )
832
1404
  })
833
1405
 
834
- // ── Interactive geometry walker ──────────────────────────────────────
1406
+ type FloorplanRegistryEntryProps = {
1407
+ activeDragId: string | null
1408
+ activeRotateNodeId: AnyNodeId | null
1409
+ ctxOverrides: FloorplanContextOverrides | undefined
1410
+ floorplanVisible: boolean
1411
+ geometryCacheRef: { current: Map<string, CacheEntry> }
1412
+ hatchPatternId: string | undefined
1413
+ highlighted: boolean
1414
+ hovered: boolean
1415
+ hoveredHandleId: string | null
1416
+ interactiveElevators: unknown
1417
+ isMarqueeSelectionActive: boolean
1418
+ isOpeningPlacementActive: boolean
1419
+ levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> }
1420
+ levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
1421
+ moving: boolean
1422
+ node: AnyNode
1423
+ nodeId: AnyNodeId
1424
+ nodes: Record<string, AnyNode>
1425
+ /** Selected member of a multi-selection: hide its per-node edit chrome. */
1426
+ suppressHandles: boolean
1427
+ /** Transformable member of a multi-selection: advertise drag-to-move. */
1428
+ groupMoveCursor: boolean
1429
+ onClickStop: (event: React.MouseEvent<SVGGElement>) => void
1430
+ onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
1431
+ onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
1432
+ onHandleHoverChange: (id: string | null) => void
1433
+ onHandleDoubleClick: (
1434
+ nodeId: AnyNodeId,
1435
+ affordance: string,
1436
+ payload: unknown,
1437
+ event: ReactMouseEvent<SVGElement>,
1438
+ ) => void
1439
+ onHandlePointerDown: (
1440
+ nodeId: AnyNodeId,
1441
+ handleId: string,
1442
+ affordance: string,
1443
+ payload: unknown,
1444
+ event: ReactPointerEvent<SVGGElement>,
1445
+ rotationPivot?: FloorplanPoint,
1446
+ ) => void
1447
+ onHoveredIdChange: (id: AnyNodeId | null) => void
1448
+ palette: FloorplanPalette | undefined
1449
+ pass: FloorplanRenderPass
1450
+ sceneRotationDeg: number
1451
+ selected: boolean
1452
+ setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
1453
+ setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
1454
+ siblingEpoch: number
1455
+ unit: 'metric' | 'imperial'
1456
+ unitsPerPixel: number
1457
+ visibilityRootId: AnyNodeId | undefined
1458
+ }
835
1459
 
836
- function InteractiveGeometry({
837
- geometry,
838
- unitsPerPixel,
839
- palette,
1460
+ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1461
+ activeDragId,
1462
+ activeRotateNodeId,
1463
+ ctxOverrides,
1464
+ floorplanVisible,
1465
+ geometryCacheRef,
840
1466
  hatchPatternId,
1467
+ highlighted,
1468
+ hovered,
841
1469
  hoveredHandleId,
842
- activeDragId,
1470
+ interactiveElevators,
843
1471
  isMarqueeSelectionActive,
1472
+ isOpeningPlacementActive,
1473
+ levelDataCacheRef,
1474
+ levelNodeIdsByType,
1475
+ moving,
1476
+ node,
844
1477
  nodeId,
845
- sceneRotationDeg,
1478
+ nodes,
1479
+ suppressHandles,
1480
+ groupMoveCursor,
1481
+ onClickStop,
1482
+ onEntryPointerDown,
1483
+ onGroupMovePointerDown,
846
1484
  onHandleHoverChange,
1485
+ onHandleDoubleClick,
847
1486
  onHandlePointerDown,
848
- onMoveHandlePointerDown,
849
- }: {
1487
+ onHoveredIdChange,
1488
+ palette,
1489
+ pass,
1490
+ sceneRotationDeg,
1491
+ selected,
1492
+ setMovingNode,
1493
+ setMovingNodeOrigin,
1494
+ siblingEpoch,
1495
+ unit,
1496
+ unitsPerPixel,
1497
+ visibilityRootId,
1498
+ }: FloorplanRegistryEntryProps): React.ReactElement | null {
1499
+ const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
1500
+ const liveOverride = useLiveNodeOverrides((s) =>
1501
+ floorplanVisible ? s.overrides.get(nodeId) : undefined,
1502
+ )
1503
+ const liveOverrides = floorplanVisible
1504
+ ? useLiveNodeOverrides.getState().overrides
1505
+ : EMPTY_LIVE_OVERRIDES
1506
+
1507
+ const handlePointerDown = useCallback(
1508
+ (event: ReactPointerEvent<SVGGElement>) => onEntryPointerDown(nodeId, event),
1509
+ [nodeId, onEntryPointerDown],
1510
+ )
1511
+
1512
+ // Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
1513
+ // the highlight halo in 3D as well as registry floor-plan hover strokes.
1514
+ const handlePointerEnter = useCallback(() => {
1515
+ const node = useScene.getState().nodes[nodeId]
1516
+ onHoveredIdChange(
1517
+ node
1518
+ ? resolveSelectionProxyId(
1519
+ node,
1520
+ useScene.getState().nodes as Record<string, AnyNode | undefined>,
1521
+ )
1522
+ : nodeId,
1523
+ )
1524
+ }, [nodeId, onHoveredIdChange])
1525
+
1526
+ const handlePointerLeave = useCallback(() => {
1527
+ const node = useScene.getState().nodes[nodeId]
1528
+ const targetId = node
1529
+ ? resolveSelectionProxyId(
1530
+ node,
1531
+ useScene.getState().nodes as Record<string, AnyNode | undefined>,
1532
+ )
1533
+ : nodeId
1534
+ if (useViewer.getState().hoveredId === targetId) onHoveredIdChange(null)
1535
+ }, [nodeId, onHoveredIdChange])
1536
+
1537
+ const handleHandlePointerDown = useCallback(
1538
+ (
1539
+ affordance: string,
1540
+ payload: unknown,
1541
+ event: ReactPointerEvent<SVGGElement>,
1542
+ rotationPivot?: FloorplanPoint,
1543
+ ) => {
1544
+ onHandlePointerDown(
1545
+ nodeId,
1546
+ makeHandleId(nodeId, payload),
1547
+ affordance,
1548
+ payload,
1549
+ event,
1550
+ rotationPivot,
1551
+ )
1552
+ },
1553
+ [nodeId, onHandlePointerDown],
1554
+ )
1555
+
1556
+ const handleHandleDoubleClick = useCallback(
1557
+ (affordance: string, payload: unknown, event: ReactMouseEvent<SVGElement>) => {
1558
+ onHandleDoubleClick(nodeId, affordance, payload, event)
1559
+ },
1560
+ [nodeId, onHandleDoubleClick],
1561
+ )
1562
+
1563
+ const handleMoveHandlePointerDown = useCallback(
1564
+ (event: ReactPointerEvent<SVGGElement>) => {
1565
+ if (event.button !== 0) return
1566
+ const currentNode = useScene.getState().nodes[nodeId]
1567
+ if (!currentNode) return
1568
+ event.preventDefault()
1569
+ event.stopPropagation()
1570
+ suppressBoxSelectForPointer(event)
1571
+ // In a multi-selection the move dot drives the group session, matching
1572
+ // the body-drag gesture — the whole selection slides, not one member.
1573
+ if (onGroupMovePointerDown(nodeId, event)) return
1574
+ sfxEmitter.emit('sfx:item-pick')
1575
+ setMovingNode(currentNode as never)
1576
+ // Claim 2D ownership of this move at the source. `setMovingNode`
1577
+ // resets the origin to null, so this must follow it.
1578
+ setMovingNodeOrigin('2d')
1579
+ },
1580
+ [nodeId, onGroupMovePointerDown, setMovingNode, setMovingNodeOrigin],
1581
+ )
1582
+
1583
+ const cacheEntry = buildFloorplanEntryGeometry({
1584
+ ctxOverrides,
1585
+ geometryCache: geometryCacheRef.current,
1586
+ highlighted,
1587
+ hovered,
1588
+ interactiveElevators,
1589
+ levelDataCache: levelDataCacheRef.current,
1590
+ levelNodeIdsByType,
1591
+ live,
1592
+ liveOverride,
1593
+ liveOverrides,
1594
+ moving,
1595
+ node,
1596
+ nodeId,
1597
+ nodes,
1598
+ palette,
1599
+ selected,
1600
+ siblingEpoch,
1601
+ unit,
1602
+ visibilityRootId,
1603
+ })
1604
+ const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
1605
+ // Multi-selection shows highlight only: strip this member's edit handles /
1606
+ // dimension chrome (all of which live in the overlay pass) while keeping
1607
+ // its highlighted body geometry.
1608
+ const geometry =
1609
+ rawGeometry && suppressHandles && pass === 'overlay'
1610
+ ? stripHandleChrome(rawGeometry)
1611
+ : rawGeometry
1612
+ if (!geometry) return null
1613
+
1614
+ const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
1615
+ const entryPointerDown =
1616
+ isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handlePointerDown
1617
+
1618
+ return (
1619
+ <g
1620
+ className="floorplan-registry-entry"
1621
+ data-node-id={nodeId}
1622
+ onClick={entryClick}
1623
+ onPointerDown={entryPointerDown}
1624
+ onPointerEnter={handlePointerEnter}
1625
+ onPointerLeave={handlePointerLeave}
1626
+ style={groupMoveCursor ? MOVE_CURSOR_STYLE : POINTER_CURSOR_STYLE}
1627
+ >
1628
+ <InteractiveGeometry
1629
+ activeDragId={activeDragId}
1630
+ activeRotateNodeId={activeRotateNodeId}
1631
+ geometry={geometry}
1632
+ hatchPatternId={hatchPatternId}
1633
+ hoveredHandleId={hoveredHandleId}
1634
+ isMarqueeSelectionActive={isMarqueeSelectionActive}
1635
+ nodeId={nodeId}
1636
+ onHandleDoubleClick={handleHandleDoubleClick}
1637
+ onHandleHoverChange={onHandleHoverChange}
1638
+ onHandlePointerDown={handleHandlePointerDown}
1639
+ onMoveHandlePointerDown={handleMoveHandlePointerDown}
1640
+ palette={palette}
1641
+ sceneRotationDeg={sceneRotationDeg}
1642
+ unitsPerPixel={unitsPerPixel}
1643
+ />
1644
+ </g>
1645
+ )
1646
+ }, shallowPropsAreEqual)
1647
+
1648
+ type BuildFloorplanEntryGeometryArgs = {
1649
+ ctxOverrides: FloorplanContextOverrides | undefined
1650
+ geometryCache: Map<string, CacheEntry>
1651
+ highlighted: boolean
1652
+ hovered: boolean
1653
+ interactiveElevators: unknown
1654
+ levelDataCache: Map<string, LevelDataCacheEntry>
1655
+ levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
1656
+ live: LiveTransform | undefined
1657
+ liveOverride: LiveNodeOverrides | undefined
1658
+ liveOverrides: Map<string, LiveNodeOverrides>
1659
+ moving: boolean
1660
+ node: AnyNode
1661
+ nodeId: AnyNodeId
1662
+ nodes: Record<string, AnyNode>
1663
+ palette: FloorplanPalette | undefined
1664
+ selected: boolean
1665
+ siblingEpoch: number
1666
+ unit: 'metric' | 'imperial'
1667
+ visibilityRootId: AnyNodeId | undefined
1668
+ }
1669
+
1670
+ export function collectFloorplanDependencyNodes(
1671
+ def: AnyNodeDefinition,
1672
+ node: AnyNode,
1673
+ nodes: Record<string, AnyNode>,
1674
+ liveOverrides?: Map<string, LiveNodeOverrides>,
1675
+ ): AnyNode[] {
1676
+ return (def.floorplanDependencies?.(node) ?? []).flatMap((id) => {
1677
+ const dependency = nodes[id]
1678
+ if (!dependency) return []
1679
+ const dependencyOverride = liveOverrides?.get(dependency.id)
1680
+ const effectiveDependency = dependencyOverride
1681
+ ? ({ ...dependency, ...dependencyOverride } as AnyNode)
1682
+ : dependency
1683
+ const parent = dependency.parentId ? nodes[dependency.parentId] : undefined
1684
+ if (!parent) return [effectiveDependency]
1685
+ const parentOverride = liveOverrides?.get(parent.id)
1686
+ const effectiveParent = parentOverride ? ({ ...parent, ...parentOverride } as AnyNode) : parent
1687
+ return [effectiveDependency, effectiveParent]
1688
+ })
1689
+ }
1690
+
1691
+ function buildFloorplanEntryGeometry({
1692
+ ctxOverrides,
1693
+ geometryCache,
1694
+ highlighted,
1695
+ hovered,
1696
+ interactiveElevators,
1697
+ levelDataCache,
1698
+ levelNodeIdsByType,
1699
+ live,
1700
+ liveOverride,
1701
+ liveOverrides,
1702
+ moving,
1703
+ node,
1704
+ nodeId,
1705
+ nodes,
1706
+ palette,
1707
+ selected,
1708
+ siblingEpoch,
1709
+ unit,
1710
+ visibilityRootId,
1711
+ }: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
1712
+ const def = nodeRegistry.get(node.type)
1713
+ const builder = def?.floorplan
1714
+ if (!builder) return null
1715
+
1716
+ const visible = visibilityRootId
1717
+ ? isFloorplanHierarchyVisible(node, nodes, liveOverrides, visibilityRootId)
1718
+ : isFloorplanNodeVisible(node, liveOverride)
1719
+ if (!visible) {
1720
+ geometryCache.delete(nodeId)
1721
+ return null
1722
+ }
1723
+
1724
+ const dependsOnSiblingInputs = !!(
1725
+ def.floorplanDependsOnSiblings ||
1726
+ def.floorplanSiblingOverrides ||
1727
+ def.floorplanAffectedIds
1728
+ )
1729
+ const dependencyNodes = collectFloorplanDependencyNodes(def, node, nodes, liveOverrides)
1730
+ const deps: NodeDeps = {
1731
+ node,
1732
+ live,
1733
+ unit,
1734
+ selected,
1735
+ highlighted,
1736
+ hovered,
1737
+ moving,
1738
+ liveOverride,
1739
+ palette,
1740
+ siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
1741
+ // Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
1742
+ // committed state via `ctx`, so committed sibling edits still invalidate.
1743
+ committedNodes: dependsOnSiblingInputs ? nodes : null,
1744
+ dependencyNodes,
1745
+ interactiveElevators,
1746
+ }
1747
+ const cached = geometryCache.get(nodeId)
1748
+ if (cached && nodeDepsEqual(cached.deps, deps)) return cached
1749
+
1750
+ const applyLiveTransform = (sourceNode: AnyNode): AnyNode => {
1751
+ if (!live) return sourceNode
1752
+ const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position)
1753
+ const parentFrameProjection = nodeRegistry.get(sourceNode.type)?.capabilities?.movable
1754
+ ?.parentFrame?.floorplanLiveTransform
1755
+ if (parentFrameProjection) {
1756
+ return parentFrameProjection({ node: sourceNode, live })
1757
+ }
1758
+ if (sourceNode.type === 'door' || sourceNode.type === 'window') {
1759
+ const r = (sourceNode as { rotation?: unknown }).rotation
1760
+ return {
1761
+ ...sourceNode,
1762
+ position: live.position,
1763
+ rotation: Array.isArray(r)
1764
+ ? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
1765
+ : r,
1766
+ } as AnyNode
1767
+ }
1768
+ if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) {
1769
+ return applyPositionLiveTransform(sourceNode, live)
1770
+ }
1771
+ if (sourceNode.type === 'slab' || sourceNode.type === 'ceiling' || sourceNode.type === 'zone') {
1772
+ const dx = live.position[0]
1773
+ const dz = live.position[2]
1774
+ if (dx === 0 && dz === 0) return sourceNode
1775
+ const surface = sourceNode as {
1776
+ polygon: Array<[number, number]>
1777
+ holes?: Array<Array<[number, number]>>
1778
+ }
1779
+ return {
1780
+ ...sourceNode,
1781
+ polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
1782
+ holes: (surface.holes ?? []).map((h) =>
1783
+ h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
1784
+ ),
1785
+ } as AnyNode
1786
+ }
1787
+ return sourceNode
1788
+ }
1789
+
1790
+ const contextNodes = def.floorplanSiblingOverrides
1791
+ ? def.floorplanSiblingOverrides({
1792
+ nodeId,
1793
+ nodes,
1794
+ liveTransforms: useLiveTransforms.getState().transforms,
1795
+ liveOverrides,
1796
+ })
1797
+ : nodes
1798
+ const sourceNode = contextNodes !== nodes ? (contextNodes[nodeId] ?? node) : node
1799
+ const overrideNode = liveOverride ? ({ ...sourceNode, ...liveOverride } as AnyNode) : sourceNode
1800
+ const effectiveNode = applyLiveTransform(overrideNode)
1801
+ const levelData = getFloorplanLevelData(
1802
+ node.type,
1803
+ nodes,
1804
+ liveOverrides,
1805
+ levelNodeIdsByType,
1806
+ levelDataCache,
1807
+ )
1808
+ const viewState = {
1809
+ selected,
1810
+ unit,
1811
+ highlighted,
1812
+ hovered,
1813
+ moving,
1814
+ palette,
1815
+ }
1816
+ const resolveContextNode = <N = AnyNode>(rid: AnyNodeId): N | undefined => {
1817
+ const contextNode = contextNodes[rid]
1818
+ if (!contextNode) return undefined
1819
+ const contextOverride = liveOverrides.get(contextNode.id)
1820
+ return (contextOverride ? { ...contextNode, ...contextOverride } : contextNode) as N
1821
+ }
1822
+ const ctx: GeometryContext = ctxOverrides
1823
+ ? {
1824
+ resolve: resolveContextNode,
1825
+ children: ctxOverrides.children,
1826
+ siblings: ctxOverrides.siblings,
1827
+ parent: ctxOverrides.parent,
1828
+ levelData,
1829
+ viewState: palette
1830
+ ? {
1831
+ selected,
1832
+ unit,
1833
+ highlighted,
1834
+ hovered,
1835
+ moving,
1836
+ palette,
1837
+ }
1838
+ : undefined,
1839
+ }
1840
+ : {
1841
+ ...buildContext(effectiveNode, contextNodes, viewState, levelData),
1842
+ resolve: resolveContextNode,
1843
+ }
1844
+ const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
1845
+ effectiveNode,
1846
+ ctx,
1847
+ )
1848
+ const { base, overlay } = geometry
1849
+ ? splitFloorplanOverlay(geometry)
1850
+ : { base: null, overlay: null }
1851
+ const entry: CacheEntry = { deps, base, overlay, node: effectiveNode }
1852
+ geometryCache.set(nodeId, entry)
1853
+ return entry
1854
+ }
1855
+
1856
+ export function getFloorplanLevelData(
1857
+ type: string,
1858
+ nodes: Record<string, AnyNode>,
1859
+ liveOverrides: Map<string, LiveNodeOverrides>,
1860
+ levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>,
1861
+ levelDataCache: Map<string, LevelDataCacheEntry>,
1862
+ ): unknown {
1863
+ const def = nodeRegistry.get(type)
1864
+ if (!def?.computeFloorplanLevelData) return undefined
1865
+ const ids = levelNodeIdsByType.get(type)
1866
+ const sampleId = ids?.[0]
1867
+ if (!ids || !sampleId) return undefined
1868
+
1869
+ const cached = levelDataCache.get(type)
1870
+ if (
1871
+ cached &&
1872
+ cached.nodes === nodes &&
1873
+ cached.liveOverrides === liveOverrides &&
1874
+ cached.ids === ids
1875
+ ) {
1876
+ return cached.value
1877
+ }
1878
+
1879
+ const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook
1880
+ const contextNodes = def.floorplanSiblingOverrides
1881
+ ? def.floorplanSiblingOverrides({
1882
+ nodeId: sampleId,
1883
+ nodes,
1884
+ liveTransforms: useLiveTransforms.getState().transforms,
1885
+ liveOverrides,
1886
+ })
1887
+ : nodes
1888
+ const siblings: AnyNode[] = []
1889
+ for (const id of ids) {
1890
+ const sibling = contextNodes[id]
1891
+ if (sibling?.type === type) siblings.push(sibling)
1892
+ }
1893
+ const value = computeLevelData({ siblings, nodes: contextNodes })
1894
+ levelDataCache.set(type, { nodes, liveOverrides, ids, value })
1895
+ return value
1896
+ }
1897
+
1898
+ // ── Interactive geometry walker ──────────────────────────────────────
1899
+
1900
+ type InteractiveGeometryProps = {
850
1901
  geometry: FloorplanGeometry
851
1902
  unitsPerPixel: number
852
1903
  palette: FloorplanPalette | undefined
853
1904
  hatchPatternId: string | undefined
854
1905
  hoveredHandleId: string | null
855
1906
  activeDragId: string | null
1907
+ activeRotateNodeId: AnyNodeId | null
856
1908
  isMarqueeSelectionActive: boolean
857
1909
  nodeId: AnyNodeId
858
1910
  sceneRotationDeg: number
859
1911
  onHandleHoverChange: (id: string | null) => void
1912
+ onHandleDoubleClick: (
1913
+ affordance: string,
1914
+ payload: unknown,
1915
+ event: ReactMouseEvent<SVGElement>,
1916
+ ) => void
860
1917
  onHandlePointerDown: (
861
1918
  affordance: string,
862
1919
  payload: unknown,
@@ -866,7 +1923,24 @@ function InteractiveGeometry({
866
1923
  rotationPivot?: FloorplanPoint,
867
1924
  ) => void
868
1925
  onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
869
- }): React.ReactElement {
1926
+ }
1927
+
1928
+ const InteractiveGeometry = memo(function InteractiveGeometry({
1929
+ geometry,
1930
+ unitsPerPixel,
1931
+ palette,
1932
+ hatchPatternId,
1933
+ hoveredHandleId,
1934
+ activeDragId,
1935
+ activeRotateNodeId,
1936
+ isMarqueeSelectionActive,
1937
+ nodeId,
1938
+ sceneRotationDeg,
1939
+ onHandleDoubleClick,
1940
+ onHandleHoverChange,
1941
+ onHandlePointerDown,
1942
+ onMoveHandlePointerDown,
1943
+ }: InteractiveGeometryProps): React.ReactElement {
870
1944
  return renderInteractive(geometry, 0)
871
1945
 
872
1946
  function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement {
@@ -911,6 +1985,7 @@ function InteractiveGeometry({
911
1985
  case 'endpoint-handle': {
912
1986
  if (!palette) return <></>
913
1987
  const handleId = makeHandleId(nodeId, g.payload)
1988
+ const doubleClickAffordance = floorplanHandleDoubleClickAffordance(g)
914
1989
  const isHovered = hoveredHandleId === handleId
915
1990
  const isActive = activeDragId === handleId
916
1991
  // Variant picks the colour-set. Endpoint dots use the orange
@@ -992,6 +2067,15 @@ function InteractiveGeometry({
992
2067
  cx={g.point[0]}
993
2068
  cy={g.point[1]}
994
2069
  fill="transparent"
2070
+ onDoubleClick={
2071
+ doubleClickAffordance
2072
+ ? (event) => {
2073
+ event.preventDefault()
2074
+ event.stopPropagation()
2075
+ onHandleDoubleClick(doubleClickAffordance, g.payload, event)
2076
+ }
2077
+ : undefined
2078
+ }
995
2079
  onPointerDown={(e) =>
996
2080
  onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
997
2081
  }
@@ -1095,7 +2179,7 @@ function InteractiveGeometry({
1095
2179
  // each end pointing tangentially in opposite directions —
1096
2180
  // "rotate either way."
1097
2181
  const handleId = makeHandleId(nodeId, g.payload)
1098
- const isHovered = hoveredHandleId === handleId
2182
+ const isHovered = hoveredHandleId === handleId || activeRotateNodeId === nodeId
1099
2183
  // Arc geometry (all values precomputed for a 72° arc of
1100
2184
  // radius 0.13 — comparable footprint to `move-arrow`).
1101
2185
  const R = 0.13
@@ -1402,35 +2486,96 @@ function InteractiveGeometry({
1402
2486
  // and flip by 180° if it falls outside (-90, 90] — that keeps
1403
2487
  // text reading left-to-right, top-to-bottom regardless of the
1404
2488
  // building's orientation.
2489
+ const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
2490
+
2491
+ const labelUnitsPerPixel = Math.max(unitsPerPixel, 1e-6)
2492
+ const outlined = g.appearance === 'outlined'
2493
+ const padX = labelUnitsPerPixel * 6
2494
+ const padY = labelUnitsPerPixel * 3
2495
+ const fontSize = labelUnitsPerPixel * (outlined ? 12 : 10)
2496
+ // Rough text width approximation — SVG can't measure text without
2497
+ // the DOM. 6.2px per char at 10px font keeps the plate visually
2498
+ // balanced for the short length strings ("3.24m", "1'2\"", etc.).
2499
+ const textWidth = g.text.length * labelUnitsPerPixel * 6.2
2500
+ const plateW = textWidth + padX * 2
2501
+ const plateH = fontSize + padY * 2
2502
+ return (
2503
+ <g
2504
+ key={keyHint}
2505
+ pointerEvents="none"
2506
+ transform={`translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`}
2507
+ >
2508
+ {outlined ? null : (
2509
+ <rect
2510
+ fill={palette.measurementLabelBackground}
2511
+ height={plateH}
2512
+ opacity={0.92}
2513
+ rx={labelUnitsPerPixel * 3}
2514
+ ry={labelUnitsPerPixel * 3}
2515
+ stroke={palette.measurementStroke}
2516
+ strokeWidth={labelUnitsPerPixel * 0.5}
2517
+ vectorEffect="non-scaling-stroke"
2518
+ width={plateW}
2519
+ x={-plateW / 2}
2520
+ y={-plateH / 2}
2521
+ />
2522
+ )}
2523
+ <text
2524
+ dominantBaseline="middle"
2525
+ fill={outlined ? '#ffffff' : palette.measurementLabelText}
2526
+ fontFamily={
2527
+ outlined
2528
+ ? 'system-ui, -apple-system, sans-serif'
2529
+ : 'ui-monospace, SFMono-Regular, Menlo, monospace'
2530
+ }
2531
+ fontSize={fontSize}
2532
+ fontWeight={outlined ? 500 : 600}
2533
+ paintOrder={outlined ? 'stroke' : undefined}
2534
+ stroke={outlined ? palette.measurementStroke : undefined}
2535
+ strokeLinecap={outlined ? 'round' : undefined}
2536
+ strokeLinejoin={outlined ? 'round' : undefined}
2537
+ strokeWidth={outlined ? fontSize * 0.35 : undefined}
2538
+ textAnchor="middle"
2539
+ x={0}
2540
+ y={0}
2541
+ >
2542
+ {g.text}
2543
+ </text>
2544
+ </g>
2545
+ )
2546
+ }
2547
+ case 'equal-spacing-badge': {
2548
+ // A distinct accent (Figma-style "=" rhythm) so equal spacing reads
2549
+ // apart from the orange placement dimensions. Same screen-upright flip
2550
+ // as the dimension-label case above.
2551
+ const accent = '#ec4899'
1405
2552
  let degrees = (g.angle * 180) / Math.PI
1406
2553
  let screenDegrees = degrees + sceneRotationDeg
1407
2554
  screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
1408
2555
  if (screenDegrees > 90) degrees -= 180
1409
2556
  else if (screenDegrees <= -90) degrees += 180
1410
2557
 
2558
+ const label = `= ${g.text}`
1411
2559
  const padX = unitsPerPixel * 6
1412
2560
  const padY = unitsPerPixel * 3
1413
2561
  const fontSize = Math.max(unitsPerPixel * 10, 0.08)
1414
- // Rough text width approximation SVG can't measure text without
1415
- // the DOM. 6.2px per char at 10px font keeps the plate visually
1416
- // balanced for the short length strings ("3.24m", "1'2\"", etc.).
1417
- const textWidth = g.text.length * unitsPerPixel * 6.2
2562
+ const textWidth = label.length * unitsPerPixel * 6.2
1418
2563
  const plateW = textWidth + padX * 2
1419
2564
  const plateH = fontSize + padY * 2
1420
2565
  return (
1421
2566
  <g
1422
2567
  key={keyHint}
1423
2568
  pointerEvents="none"
1424
- transform={`translate(${g.cx} ${g.cy}) rotate(${degrees})`}
2569
+ transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${degrees})`}
1425
2570
  >
1426
2571
  <rect
1427
- fill={palette.measurementLabelBackground}
2572
+ fill="#ffffff"
1428
2573
  height={plateH}
1429
- opacity={0.92}
2574
+ opacity={0.95}
1430
2575
  rx={unitsPerPixel * 3}
1431
2576
  ry={unitsPerPixel * 3}
1432
- stroke={palette.measurementStroke}
1433
- strokeWidth={unitsPerPixel * 0.5}
2577
+ stroke={accent}
2578
+ strokeWidth={unitsPerPixel * 0.75}
1434
2579
  vectorEffect="non-scaling-stroke"
1435
2580
  width={plateW}
1436
2581
  x={-plateW / 2}
@@ -1438,15 +2583,15 @@ function InteractiveGeometry({
1438
2583
  />
1439
2584
  <text
1440
2585
  dominantBaseline="middle"
1441
- fill={palette.measurementLabelText}
2586
+ fill={accent}
1442
2587
  fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
1443
2588
  fontSize={fontSize}
1444
- fontWeight={600}
2589
+ fontWeight={700}
1445
2590
  textAnchor="middle"
1446
2591
  x={0}
1447
2592
  y={0}
1448
2593
  >
1449
- {g.text}
2594
+ {label}
1450
2595
  </text>
1451
2596
  </g>
1452
2597
  )
@@ -1634,10 +2779,25 @@ function InteractiveGeometry({
1634
2779
  )
1635
2780
  }
1636
2781
  }
1637
- }
2782
+ }, shallowPropsAreEqual)
1638
2783
 
1639
2784
  // ── Helpers ──────────────────────────────────────────────────────────
1640
2785
 
2786
+ function shallowPropsAreEqual<T extends object>(a: T, b: T): boolean {
2787
+ const aKeys = Object.keys(a) as Array<keyof T>
2788
+ const bKeys = Object.keys(b) as Array<keyof T>
2789
+ if (aKeys.length !== bKeys.length) return false
2790
+ for (const key of aKeys) {
2791
+ if (!Object.is(a[key], b[key])) return false
2792
+ }
2793
+ return true
2794
+ }
2795
+
2796
+ function handleIdForNode(handleId: string | null, nodeId: AnyNodeId): string | null {
2797
+ if (!handleId) return null
2798
+ return handleId === nodeId || handleId.startsWith(`${nodeId}:`) ? handleId : null
2799
+ }
2800
+
1641
2801
  function applyPositionLiveTransform(
1642
2802
  node: AnyNode,
1643
2803
  live: { position: [number, number, number]; rotation: number },
@@ -1661,16 +2821,44 @@ function applyPositionLiveTransform(
1661
2821
  } as AnyNode
1662
2822
  }
1663
2823
 
1664
- function buildContext(
2824
+ export function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean {
2825
+ const overrideVisible = liveOverride?.visible
2826
+ if (typeof overrideVisible === 'boolean') return overrideVisible
2827
+ return (node as { visible?: boolean }).visible !== false
2828
+ }
2829
+
2830
+ function isFloorplanHierarchyVisible(
2831
+ node: AnyNode,
2832
+ nodes: Record<string, AnyNode>,
2833
+ liveOverrides: Map<string, LiveNodeOverrides>,
2834
+ rootId: AnyNodeId,
2835
+ ): boolean {
2836
+ let current: AnyNode | undefined = node
2837
+ const seen = new Set<AnyNodeId>()
2838
+ while (current) {
2839
+ if (seen.has(current.id)) return true
2840
+ seen.add(current.id)
2841
+ if (!isFloorplanNodeVisible(current, liveOverrides.get(current.id))) return false
2842
+ if (current.id === rootId) return true
2843
+ const parentId = current.parentId as AnyNodeId | null
2844
+ if (!parentId) return true
2845
+ current = nodes[parentId]
2846
+ }
2847
+ return true
2848
+ }
2849
+
2850
+ export function buildContext(
1665
2851
  node: AnyNode,
1666
2852
  nodes: Record<string, AnyNode>,
1667
2853
  viewState: {
1668
2854
  selected: boolean
2855
+ unit: 'metric' | 'imperial'
1669
2856
  highlighted: boolean
1670
2857
  hovered: boolean
1671
2858
  moving: boolean
1672
2859
  palette: FloorplanPalette | undefined
1673
2860
  },
2861
+ levelData?: unknown,
1674
2862
  ): GeometryContext {
1675
2863
  const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
1676
2864
 
@@ -1703,9 +2891,11 @@ function buildContext(
1703
2891
  children,
1704
2892
  siblings,
1705
2893
  parent,
2894
+ levelData,
1706
2895
  viewState: viewState.palette
1707
2896
  ? {
1708
2897
  selected: viewState.selected,
2898
+ unit: viewState.unit,
1709
2899
  highlighted: viewState.highlighted,
1710
2900
  hovered: viewState.hovered,
1711
2901
  moving: viewState.moving,
@@ -1735,6 +2925,14 @@ function makeHandleId(nodeId: AnyNodeId, payload: unknown): string {
1735
2925
  return `${nodeId}:${String(payload)}`
1736
2926
  }
1737
2927
 
2928
+ export function floorplanHandleDoubleClickAffordance(
2929
+ geometry: FloorplanGeometry,
2930
+ ): 'delete-vertex' | null {
2931
+ return geometry.kind === 'endpoint-handle' && geometry.affordance === 'move-vertex'
2932
+ ? 'delete-vertex'
2933
+ : null
2934
+ }
2935
+
1738
2936
  /**
1739
2937
  * Geometry kinds that always render in the overlay pass — interactive
1740
2938
  * handles and node labels. These need to sit above every kind's base
@@ -1754,6 +2952,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
1754
2952
  'rotate-arrow',
1755
2953
  'dimension',
1756
2954
  'dimension-label',
2955
+ 'equal-spacing-badge',
1757
2956
  ])
1758
2957
 
1759
2958
  /**
@@ -1766,7 +2965,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
1766
2965
  * / translations apply in both passes. Empty groups collapse to `null`
1767
2966
  * so the caller can skip emitting an `<g>` when there's nothing to draw.
1768
2967
  */
1769
- function splitFloorplanOverlay(g: FloorplanGeometry): {
2968
+ export function splitFloorplanOverlay(g: FloorplanGeometry): {
1770
2969
  base: FloorplanGeometry | null
1771
2970
  overlay: FloorplanGeometry | null
1772
2971
  } {
@@ -1794,6 +2993,172 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
1794
2993
  return { base: g, overlay: null }
1795
2994
  }
1796
2995
 
2996
+ /**
2997
+ * Per-node edit chrome hidden while a multi-selection is active: the group is
2998
+ * manipulated as one rigid piece (drag to move, R/T to rotate), so individual
2999
+ * handles / dimension labels on each member would mislead. `text` stays —
3000
+ * zone names are identification, not editing chrome.
3001
+ */
3002
+ const HANDLE_CHROME_KINDS = new Set<FloorplanGeometry['kind']>([
3003
+ 'endpoint-handle',
3004
+ 'midpoint-handle',
3005
+ 'edge-handle',
3006
+ 'move-handle',
3007
+ 'move-arrow',
3008
+ 'rotate-arrow',
3009
+ 'dimension',
3010
+ 'dimension-label',
3011
+ 'equal-spacing-badge',
3012
+ ])
3013
+
3014
+ function stripHandleChrome(g: FloorplanGeometry): FloorplanGeometry | null {
3015
+ if (HANDLE_CHROME_KINDS.has(g.kind)) return null
3016
+ if (g.kind === 'group') {
3017
+ const children = g.children
3018
+ .map(stripHandleChrome)
3019
+ .filter((c): c is FloorplanGeometry => c !== null)
3020
+ if (children.length === 0) return null
3021
+ return { kind: 'group', children, transform: g.transform }
3022
+ }
3023
+ return g
3024
+ }
3025
+
3026
+ // Stable string key for a wall endpoint, rounded to 1 mm so floating-point
3027
+ // drift collapses while distinct corners stay distinct.
3028
+ function endpointKey(x: number, y: number): string {
3029
+ return `${Math.round(x * 1000)},${Math.round(y * 1000)}`
3030
+ }
3031
+
3032
+ // Given the sibling-dependent nodes with a live drag in flight, the set of
3033
+ // floor-plan geometries that must rebuild this frame. A node's geometry depends
3034
+ // on more than its own data:
3035
+ // - a wall's miters depend on the walls meeting at each of its endpoints, so a
3036
+ // dragged wall invalidates the walls at its old AND new junctions, plus its
3037
+ // own door/window children (their cuts are drawn into it);
3038
+ // - a door/window cut is drawn into its host wall, so it invalidates that wall;
3039
+ // - a gutter join depends on sibling gutters under the same roof.
3040
+ // Everything else stays cached, so dragging one wall/opening rebuilds a handful
3041
+ // of geometries rather than every wall + opening on the level.
3042
+ export function computeAffectedSiblingIds(
3043
+ liveFlaggedIds: readonly AnyNodeId[],
3044
+ nodes: Record<string, AnyNode>,
3045
+ liveOverrides: Map<string, Record<string, unknown>>,
3046
+ ): Set<AnyNodeId> {
3047
+ const affected = new Set<AnyNodeId>()
3048
+ if (liveFlaggedIds.length === 0) return affected
3049
+
3050
+ // Junction map (committed wall endpoint → wall ids), built lazily on first use.
3051
+ let junctions: Map<string, AnyNodeId[]> | null = null
3052
+ const wallsAtPoint = (x: number, y: number): AnyNodeId[] => {
3053
+ if (!junctions) {
3054
+ junctions = new Map()
3055
+ for (const id in nodes) {
3056
+ const n = nodes[id]
3057
+ if (n?.type !== 'wall') continue
3058
+ const w = n as unknown as { start: [number, number]; end: [number, number] }
3059
+ for (const [px, py] of [w.start, w.end]) {
3060
+ const key = endpointKey(px, py)
3061
+ const arr = junctions.get(key)
3062
+ if (arr) arr.push(id as AnyNodeId)
3063
+ else junctions.set(key, [id as AnyNodeId])
3064
+ }
3065
+ }
3066
+ }
3067
+ return junctions.get(endpointKey(x, y)) ?? []
3068
+ }
3069
+
3070
+ for (const id of liveFlaggedIds) {
3071
+ const node = nodes[id]
3072
+ if (!node) continue
3073
+ affected.add(id)
3074
+ const def = nodeRegistry.get(node.type)
3075
+ const extraAffectedIds = def?.floorplanAffectedIds?.({
3076
+ nodeId: id,
3077
+ node,
3078
+ nodes: nodes as Record<AnyNodeId, AnyNode>,
3079
+ liveTransforms: useLiveTransforms.getState().transforms,
3080
+ liveOverrides,
3081
+ })
3082
+ if (extraAffectedIds) {
3083
+ for (const extraId of extraAffectedIds) affected.add(extraId)
3084
+ }
3085
+ if (node.type === 'wall') {
3086
+ const w = node as unknown as {
3087
+ start: [number, number]
3088
+ end: [number, number]
3089
+ children?: AnyNodeId[]
3090
+ }
3091
+ // Use the live (override-merged) endpoints as well as the committed ones,
3092
+ // so walls at both the wall's old and new junctions get fresh miters.
3093
+ const ov = liveOverrides.get(id) as
3094
+ | { start?: [number, number]; end?: [number, number] }
3095
+ | undefined
3096
+ const points: [number, number][] = [w.start, w.end]
3097
+ if (ov?.start) points.push(ov.start)
3098
+ if (ov?.end) points.push(ov.end)
3099
+ for (const [px, py] of points) {
3100
+ for (const wid of wallsAtPoint(px, py)) affected.add(wid)
3101
+ }
3102
+ if (Array.isArray(w.children)) {
3103
+ for (const cid of w.children) {
3104
+ const child = nodes[cid]
3105
+ if (child?.type === 'door' || child?.type === 'window') affected.add(cid)
3106
+ }
3107
+ }
3108
+ } else if (node.type === 'door' || node.type === 'window') {
3109
+ const hostId = (node as { parentId?: string }).parentId
3110
+ if (hostId) affected.add(hostId as AnyNodeId)
3111
+ const liveHostId = (liveOverrides.get(id) as { parentId?: string } | undefined)?.parentId
3112
+ if (liveHostId) affected.add(liveHostId as AnyNodeId)
3113
+ } else if (node.type === 'gutter') {
3114
+ const roofId = (node as { parentId?: string }).parentId
3115
+ if (roofId) {
3116
+ for (const sid in nodes) {
3117
+ const s = nodes[sid]
3118
+ if (s?.type === 'gutter' && (s as { parentId?: string }).parentId === roofId) {
3119
+ affected.add(sid as AnyNodeId)
3120
+ }
3121
+ }
3122
+ }
3123
+ }
3124
+ }
3125
+ return affected
3126
+ }
3127
+
3128
+ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
3129
+ const keys: Array<keyof NodeDeps> = [
3130
+ 'node',
3131
+ 'live',
3132
+ 'unit',
3133
+ 'selected',
3134
+ 'highlighted',
3135
+ 'hovered',
3136
+ 'moving',
3137
+ 'liveOverride',
3138
+ 'palette',
3139
+ 'siblingEpoch',
3140
+ 'committedNodes',
3141
+ 'dependencyNodes',
3142
+ 'interactiveElevators',
3143
+ ]
3144
+ for (const key of keys) {
3145
+ if (!depsValueEqual(a[key], b[key])) return false
3146
+ }
3147
+ return true
3148
+ }
3149
+
3150
+ function depsValueEqual(a: unknown, b: unknown): boolean {
3151
+ if (Array.isArray(a) || Array.isArray(b)) {
3152
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
3153
+ if (a.length !== b.length) return false
3154
+ for (let i = 0; i < a.length; i++) {
3155
+ if (!Object.is(a[i], b[i])) return false
3156
+ }
3157
+ return true
3158
+ }
3159
+ return Object.is(a, b)
3160
+ }
3161
+
1797
3162
  /**
1798
3163
  * Z-order bucket for floor-plan rendering. Lower rank = painted first =
1799
3164
  * sits under everything with a higher rank. SVG renders in document
@@ -1809,7 +3174,7 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
1809
3174
  * Sort is stable in modern JS engines, so siblings within the same
1810
3175
  * bucket keep their DFS order (= scene tree order).
1811
3176
  */
1812
- function floorplanLayerRank(type: string): number {
3177
+ export function floorplanLayerRank(type: string): number {
1813
3178
  switch (type) {
1814
3179
  case 'zone':
1815
3180
  return 0
@@ -1855,14 +3220,17 @@ const ROTATION_WEDGE_SEGMENTS = 48
1855
3220
  * is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads
1856
3221
  * horizontally regardless of the building's on-screen orientation.
1857
3222
  */
1858
- function RotationAngleOverlay({
3223
+ export function RotationAngleOverlay({
1859
3224
  overlay,
1860
3225
  palette,
1861
3226
  unitsPerPixel,
1862
3227
  sceneRotationDeg,
1863
3228
  }: {
1864
3229
  overlay: RotationOverlayState
1865
- palette: FloorplanPalette
3230
+ palette: Pick<
3231
+ FloorplanPalette,
3232
+ 'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke'
3233
+ >
1866
3234
  unitsPerPixel: number
1867
3235
  sceneRotationDeg: number
1868
3236
  }): React.ReactElement {
@@ -1944,20 +3312,14 @@ function formatGroupTransform(t?: {
1944
3312
  return parts.length > 0 ? parts.join(' ') : undefined
1945
3313
  }
1946
3314
 
1947
- function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null {
1948
- // The registry layer lives under the floor-plan scene `<g>`. The
1949
- // legacy panel computes the same conversion via floorplanSceneRef +
1950
- // getScreenCTM; we replicate it by walking up to the SVG owner.
1951
- const target = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
1952
- const svg = target?.ownerSVGElement
1953
- if (!(svg && target)) return null
1954
- const ctm = target.getScreenCTM()
1955
- if (!ctm) return null
1956
- const point = svg.createSVGPoint()
1957
- point.x = clientX
1958
- point.y = clientY
1959
- const transformed = point.matrixTransform(ctm.inverse())
1960
- // The floor-plan `<g>` maps plan X/Z directly to SVG x/y (Z stored as
1961
- // the Y axis on screen — same convention as `toSvgPlanPoint`).
1962
- return [transformed.x, transformed.y]
3315
+ function swallowNextClick(timeoutMs = 0) {
3316
+ const swallowClick = (event: MouseEvent) => {
3317
+ event.stopPropagation()
3318
+ event.preventDefault()
3319
+ window.removeEventListener('click', swallowClick, true)
3320
+ }
3321
+ window.addEventListener('click', swallowClick, true)
3322
+ setTimeout(() => {
3323
+ window.removeEventListener('click', swallowClick, true)
3324
+ }, timeoutMs)
1963
3325
  }