@pascal-app/editor 0.9.1 → 1.0.0-beta.1

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 (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -2,38 +2,121 @@
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 {
31
+ type ComponentProps,
25
32
  memo,
33
+ type MouseEvent as ReactMouseEvent,
26
34
  type PointerEvent as ReactPointerEvent,
27
35
  useCallback,
28
36
  useEffect,
37
+ useLayoutEffect,
29
38
  useMemo,
30
39
  useRef,
31
40
  useState,
32
41
  } from 'react'
42
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
43
+ import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
44
+ import {
45
+ canDirectRotateNode,
46
+ resolveDirectManipulationNode,
47
+ resolveDirectRotationDragDelta,
48
+ resolveDirectRotationPatch,
49
+ snapDirectRotationDelta,
50
+ } from '../../../lib/direct-manipulation'
51
+ import { createEditorApi } from '../../../lib/editor-api'
52
+ import {
53
+ type FloorplanAnnotationVisibility,
54
+ filterFloorplanAnnotationGeometry,
55
+ } from '../../../lib/floorplan/annotation-visibility'
56
+ import { resolveNodeForDrawingType } from '../../../lib/floorplan/drawing-coordination'
57
+ import {
58
+ createFloorplanContextExtensions,
59
+ type FloorplanAnnotationRole,
60
+ type FloorplanWallDimensionReference,
61
+ getFloorplanNodeExtension,
62
+ readFloorplanGeometryMetadata,
63
+ withFloorplanGeometryMetadata,
64
+ } from '../../../lib/floorplan/floorplan-extension'
65
+ import {
66
+ type FloorplanMode,
67
+ resolveFloorplanAnnotationVisibility,
68
+ resolveFloorplanWallDimensionReference,
69
+ } from '../../../lib/floorplan/floorplan-mode'
70
+ import { clientToPlan } from '../../../lib/floorplan/plan-coords'
71
+ import {
72
+ type ActiveInteractionScope,
73
+ boundaryReshapeScope,
74
+ controlPointReshapeScope,
75
+ curveReshapeScope,
76
+ endpointReshapeScope,
77
+ holeEditScope,
78
+ isIdle,
79
+ tangentReshapeScope,
80
+ } from '../../../lib/interaction/scope'
81
+ import { emitCanvasNodeSelection } from '../../../lib/selection-routing'
33
82
  import { sfxEmitter } from '../../../lib/sfx-bus'
34
- import useEditor from '../../../store/use-editor'
35
- import { useFloorplanRender } from '../floorplan-render-context'
83
+ import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
84
+ import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
85
+ import useDrawingView from '../../../store/use-drawing-view'
86
+ import useEditor, { isAngleSnapActive } from '../../../store/use-editor'
87
+ import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
88
+ import useFloorplanMode from '../../../store/use-floorplan-mode'
89
+ import useInteractionScope, {
90
+ getMovingNode,
91
+ useEndpointReshape,
92
+ useMovingNode,
93
+ } from '../../../store/use-interaction-scope'
94
+ import { startGroupPickUp } from '../../editor/group-actions'
95
+ import { classifyParticipant } from '../../editor/group-transform-shared'
96
+ import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
97
+ import {
98
+ FloorplanGroupSelectionBox,
99
+ startFloorplanGroupMove,
100
+ startFloorplanGroupRotate,
101
+ } from '../floorplan-group-move'
102
+ import {
103
+ useFloorplanSceneRotation,
104
+ useFloorplanStaticRender,
105
+ useFloorplanStaticUnitsPerPixel,
106
+ } from '../floorplan-render-context'
107
+ import {
108
+ floorplanAnnotationObstacleMode,
109
+ isFloorplanAnnotationObstacleGeometry,
110
+ resolveSvgAnnotationCollisions,
111
+ svgAnnotationLabelId,
112
+ } from './floorplan-annotation-layout'
113
+ import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
36
114
  import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
115
+ import {
116
+ resolveFloorplanAnnotationUpdate,
117
+ resolveFloorplanLabelAngle,
118
+ updateSvgFloorplanLabelOrientations,
119
+ } from './floorplan-label-angle'
37
120
 
38
121
  /**
39
122
  * Registry-driven floor-plan layer.
@@ -60,8 +143,8 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
60
143
  */
61
144
  // Handle / hit-area sizes mirror the legacy `FLOORPLAN_ENDPOINT_HANDLE_*`
62
145
  // constants in floorplan-panel.tsx. Sizes are in screen pixels — the
63
- // dispatcher multiplies by `unitsPerPixel` so handles stay the same on-
64
- // screen size at any zoom.
146
+ // dispatcher multiplies by `unitsPerPixel` so handles stay screen-sized
147
+ // until the world-space ceiling takes over at extreme zoom-out.
65
148
  const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8
66
149
  const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9
67
150
  const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3
@@ -70,6 +153,21 @@ const ENDPOINT_HIT_STROKE_WIDTH_PX = 18
70
153
  const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16
71
154
  const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7
72
155
  const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
156
+ const DIRECT_DRAG_THRESHOLD_PX = 4
157
+ const DIRECT_ROTATE_EPSILON = 1e-6
158
+ const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
159
+ const MAX_HANDLE_UNITS_PER_PIXEL = 0.015
160
+
161
+ export function resolveFloorplanHandleUnitsPerPixel(unitsPerPixel: number): number {
162
+ return Math.min(unitsPerPixel, MAX_HANDLE_UNITS_PER_PIXEL)
163
+ }
164
+
165
+ const ScaleAwareFloorplanGroupSelectionBox = memo(function ScaleAwareFloorplanGroupSelectionBox(
166
+ props: Omit<ComponentProps<typeof FloorplanGroupSelectionBox>, 'unitsPerPixel'>,
167
+ ) {
168
+ const unitsPerPixel = useFloorplanStaticUnitsPerPixel()
169
+ return <FloorplanGroupSelectionBox {...props} unitsPerPixel={unitsPerPixel} />
170
+ })
73
171
 
74
172
  /**
75
173
  * Snapshot of node fields captured at drag-start, used by the single-undo
@@ -82,11 +180,18 @@ type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
82
180
 
83
181
  type ActiveDrag = {
84
182
  pointerId: number
183
+ captureTarget: Element
85
184
  /** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */
86
185
  handleId: string
87
186
  session: FloorplanAffordanceSession
88
187
  snapshots: NodeSnapshot[]
89
188
  historyPaused: boolean
189
+ /**
190
+ * Last plan point handed to `session.apply` (the grab point until the first
191
+ * move). Lets the modifier-key listeners re-run the session immediately on
192
+ * an Alt/Shift flip instead of waiting for the next pointer move.
193
+ */
194
+ lastPlanPoint: FloorplanPoint
90
195
  /**
91
196
  * Set only for rotate-arrow drags (handles that carry a `pivot`). Drives
92
197
  * the live angle wedge + degree readout — the 2D twin of the 3D rotate
@@ -94,6 +199,110 @@ type ActiveDrag = {
94
199
  * rotate affordance measures it: `atan2(pointer − pivot)`.
95
200
  */
96
201
  rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number }
202
+ /**
203
+ * Node id of the reshaping scope this drag began (boundary / curve / endpoint
204
+ * edits), so the matching `endIf` on release/cancel tears down exactly this
205
+ * scope. Unset for affordances that drive no snapping scope (resize / rotate).
206
+ */
207
+ reshapeScopeNodeId?: string
208
+ }
209
+
210
+ type FloorplanAffordanceCancelEffects = {
211
+ restoreSnapshots: (snapshots: NodeSnapshot[]) => void
212
+ resumeHistory: () => void
213
+ clearPreview: (id: AnyNodeId) => void
214
+ clearSnapFeedback: () => void
215
+ endReshapeScope: (drag: ActiveDrag) => void
216
+ clearDragFeedback?: () => void
217
+ }
218
+
219
+ export function cancelFloorplanAffordanceDrag(
220
+ dragRef: { current: ActiveDrag | null },
221
+ effects: FloorplanAffordanceCancelEffects,
222
+ pointerId?: number,
223
+ ): boolean {
224
+ const drag = dragRef.current
225
+ if (!drag || (pointerId !== undefined && pointerId !== drag.pointerId)) return false
226
+
227
+ // Clear ownership before cleanup so a queued pointer-up cannot commit the
228
+ // session while cancellation side effects are still running.
229
+ dragRef.current = null
230
+
231
+ if (drag.captureTarget.hasPointerCapture?.(drag.pointerId)) {
232
+ drag.captureTarget.releasePointerCapture?.(drag.pointerId)
233
+ }
234
+
235
+ effects.restoreSnapshots(drag.snapshots)
236
+ if (drag.historyPaused) {
237
+ effects.resumeHistory()
238
+ drag.historyPaused = false
239
+ }
240
+ effects.clearSnapFeedback()
241
+ for (const id of drag.session.affectedIds) effects.clearPreview(id)
242
+ effects.endReshapeScope(drag)
243
+ effects.clearDragFeedback?.()
244
+ return true
245
+ }
246
+
247
+ export function subscribeFloorplanAffordanceToolCancel(
248
+ cancelActiveDrag: () => boolean,
249
+ consumeToolCancel: () => void,
250
+ ): () => void {
251
+ const onToolCancel = () => {
252
+ if (cancelActiveDrag()) consumeToolCancel()
253
+ }
254
+ emitter.on('tool:cancel', onToolCancel)
255
+ return () => emitter.off('tool:cancel', onToolCancel)
256
+ }
257
+
258
+ // Map a floor-plan affordance to the reshaping scope it represents, so the
259
+ // dispatcher can drive the contextual snapping HUD (the chip) AND make
260
+ // `getActiveSnapContext()` resolve the right mode-set during the edit. Geometry
261
+ // edits that set a direction/shape map to a scope; resize / rotate / body-move
262
+ // affordances return `null` (no polygon/wall snapping chip). Keyed off the
263
+ // affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex`
264
+ // / `curve` / `move-endpoint`).
265
+ export function floorplanAffordanceReshapeScope(
266
+ affordance: string,
267
+ nodeId: string,
268
+ payload: unknown,
269
+ ): ActiveInteractionScope | null {
270
+ if (affordance.includes('vertex') || affordance.includes('edge')) {
271
+ const holeIndex = (payload as { holeIndex?: number } | undefined)?.holeIndex
272
+ return holeIndex !== undefined
273
+ ? holeEditScope({ nodeId, holeIndex, driver: 'floorplan' })
274
+ : boundaryReshapeScope(nodeId, 'floorplan')
275
+ }
276
+ if (affordance.includes('curve')) {
277
+ return curveReshapeScope(nodeId, 'floorplan')
278
+ }
279
+ if (affordance.includes('control-point')) {
280
+ const index = (payload as { index?: number } | undefined)?.index ?? 0
281
+ return controlPointReshapeScope(nodeId, index, 'floorplan')
282
+ }
283
+ if (affordance.includes('tangent')) {
284
+ const target = payload as { index?: number; side?: 'in' | 'out' } | undefined
285
+ return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out', 'floorplan')
286
+ }
287
+ if (affordance.includes('endpoint')) {
288
+ const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
289
+ return endpointReshapeScope(nodeId, endpoint, 'floorplan')
290
+ }
291
+ // Roof-segment width/depth resize — a no-angle dimension edit, so the
292
+ // no-angle 'polygon' snap set (grid / lines / off) via a boundary scope.
293
+ // Matched exactly so a still-legacy `*-resize` affordance on another kind
294
+ // doesn't get a chip its snap math can't honour yet.
295
+ if (affordance === 'roof-segment-resize') {
296
+ return boundaryReshapeScope(nodeId, 'floorplan')
297
+ }
298
+ // 2D corner rotate-arrow (column / elevator / roof-segment / shelf / spawn /
299
+ // stair). Begin the same handle-drag scope the 3D rotate gizmo uses, label-
300
+ // matched, so the contextual HUD shows the "Shift = rotate freely" hint over
301
+ // the drag. The affordance applies the 15° angle step itself.
302
+ if (affordance.includes('rotate')) {
303
+ return { kind: 'handle-drag', nodeId, handle: ROTATE_HANDLE_DRAG_LABEL }
304
+ }
305
+ return null
97
306
  }
98
307
 
99
308
  /**
@@ -109,6 +318,93 @@ type RotationOverlayState = {
109
318
  sweep: number
110
319
  }
111
320
 
321
+ type FloorplanEntryDescriptor = {
322
+ id: AnyNodeId
323
+ node: AnyNode
324
+ dependsOnSiblingInputs: boolean
325
+ ctxOverrides?: FloorplanContextOverrides
326
+ }
327
+
328
+ type NodeDeps = {
329
+ automaticDimensions: boolean
330
+ node: AnyNode
331
+ live: LiveTransform | undefined
332
+ unit: 'metric' | 'imperial'
333
+ metricNotation: 'meters' | 'millimeters'
334
+ wallDimensionReference: FloorplanWallDimensionReference
335
+ selected: boolean
336
+ highlighted: boolean
337
+ hovered: boolean
338
+ moving: boolean
339
+ liveOverride: LiveNodeOverrides | undefined
340
+ palette: FloorplanPalette | undefined
341
+ siblingEpoch: number
342
+ committedNodes: Record<string, AnyNode> | null
343
+ dependencyNodes: AnyNode[]
344
+ interactiveElevators: unknown
345
+ }
346
+
347
+ type CacheEntry = {
348
+ deps: NodeDeps
349
+ base: FloorplanGeometry | null
350
+ overlay: FloorplanGeometry | null
351
+ node: AnyNode
352
+ }
353
+
354
+ type LevelDataCacheEntry = {
355
+ nodes: Record<string, AnyNode>
356
+ liveOverrides: Map<string, LiveNodeOverrides>
357
+ ids: readonly AnyNodeId[]
358
+ value: unknown
359
+ }
360
+
361
+ type FloorplanContextOverrides = {
362
+ children: AnyNode[]
363
+ siblings: AnyNode[]
364
+ parent: AnyNode | null
365
+ }
366
+
367
+ type FloorplanLevelDataHook = (args: {
368
+ siblings: ReadonlyArray<AnyNode>
369
+ nodes: Record<string, AnyNode>
370
+ }) => unknown
371
+
372
+ type FloorplanRenderPass = 'base' | 'overlay'
373
+
374
+ const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const
375
+ // Group members advertise the drag-to-move-the-selection gesture.
376
+ const MOVE_CURSOR_STYLE = { cursor: 'move' } as const
377
+ const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
378
+
379
+ export function isFloorplanOpeningPlacementState({
380
+ phase,
381
+ mode,
382
+ tool,
383
+ movingNodeHasWallOpeningPlacement,
384
+ }: {
385
+ phase: string
386
+ mode: string
387
+ tool: string | null
388
+ movingNodeHasWallOpeningPlacement: boolean
389
+ }): boolean {
390
+ return (
391
+ (phase === 'structure' && mode === 'build' && (tool === 'door' || tool === 'window')) ||
392
+ movingNodeHasWallOpeningPlacement
393
+ )
394
+ }
395
+
396
+ function isFloorplanOpeningPlacementActiveNow(): boolean {
397
+ const { phase, mode, tool } = useEditor.getState()
398
+ const movingNode = getMovingNode()
399
+ return isFloorplanOpeningPlacementState({
400
+ phase,
401
+ mode,
402
+ tool,
403
+ movingNodeHasWallOpeningPlacement:
404
+ movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement,
405
+ })
406
+ }
407
+
112
408
  function snapshotNode(node: AnyNode): NodeSnapshot {
113
409
  // Shallow-clone every non-id, non-type field. Arrays / vec tuples are
114
410
  // deep-cloned to detach from the live store reference.
@@ -124,25 +420,27 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
124
420
  return snapshots.map((s) => ({ id: s.id, data: s.data }))
125
421
  }
126
422
 
423
+ // Stable empty sentinel used by per-entry builders while the floor plan is
424
+ // hidden; committed scene edits still flow through `useScene`.
425
+ const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
426
+
127
427
  export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
128
428
  const selectedLevelId = useViewer((s) => s.selection.levelId)
129
429
  const selectedBuildingId = useViewer((s) => s.selection.buildingId)
130
- const selectedIds = useViewer((s) => s.selection.selectedIds)
131
- const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
132
- const hoveredId = useViewer((s) => s.hoveredId)
133
- const setHoveredId = useViewer((s) => s.setHoveredId)
430
+ const unit = useViewer((s) => s.unit)
431
+ const metricNotation = useViewer((s) => s.metricNotation)
134
432
  const setSelection = useViewer((s) => s.setSelection)
135
433
  const nodes = useScene((s) => s.nodes)
434
+ const installedPlugins = useScene((s) => s.installedPlugins)
435
+ const movingNode = useMovingNode()
136
436
  // When a building is being moved, its explicit selection may be
137
437
  // cleared as part of the move handoff. Fall back to the
138
438
  // mid-drag building id so the dimmed floor keeps rendering
139
439
  // 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
- })
440
+ const movingBuildingId =
441
+ movingNode && nodeRegistry.get(movingNode.type)?.capabilities?.floorplanLevelContainer
442
+ ? movingNode.id
443
+ : null
146
444
  const ambientBuildingSourceId = selectedBuildingId ?? movingBuildingId
147
445
 
148
446
  // When only a building is in scope (no specific level), fall back to
@@ -152,7 +450,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
152
450
  const ambientLevelId = useMemo<AnyNodeId | null>(() => {
153
451
  if (selectedLevelId || !ambientBuildingSourceId) return null
154
452
  const building = nodes[ambientBuildingSourceId]
155
- if (!building || building.type !== 'building') return null
453
+ if (building?.type !== 'building') return null
156
454
  let zero: AnyNodeId | null = null
157
455
  let lowestId: AnyNodeId | null = null
158
456
  let lowestIdx = Number.POSITIVE_INFINITY
@@ -174,9 +472,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
174
472
 
175
473
  const levelId = selectedLevelId ?? ambientLevelId
176
474
  const isAmbient = !selectedLevelId && !!ambientLevelId
177
- const renderCtx = useFloorplanRender()
178
- const movingNode = useEditor((s) => s.movingNode)
475
+ const renderCtx = useFloorplanStaticRender()
476
+ const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0
179
477
  const setMovingNode = useEditor((s) => s.setMovingNode)
478
+ const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
180
479
  // Door / window placement (both build and move) needs the SVG's
181
480
  // background click handler to run — it finds the closest wall via
182
481
  // `findClosestWallPoint` and emits `wall:click` for the door / window
@@ -184,53 +483,124 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
184
483
  // wall's registry entry would otherwise swallow the click via
185
484
  // `handleClickStop` / `handleSelect`, so the placement never fires.
186
485
  // Pass clicks through in that case.
187
- const editorPhase = useEditor((s) => s.phase)
188
486
  const editorMode = useEditor((s) => s.mode)
189
- const editorTool = useEditor((s) => s.tool)
190
487
  const structureLayer = useEditor((s) => s.structureLayer)
191
488
  const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
192
- const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
193
- const isOpeningPlacementActive =
194
- (editorPhase === 'structure' &&
195
- editorMode === 'build' &&
196
- (editorTool === 'door' || editorTool === 'window')) ||
197
- (movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
489
+ const endpointReshape = useEndpointReshape()
198
490
  const isMarqueeSelectionActive =
199
491
  editorMode === 'select' &&
200
492
  floorplanSelectionTool === 'marquee' &&
201
493
  structureLayer !== 'zones' &&
202
494
  !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)
495
+ !endpointReshape
496
+ // While the floor plan is not on screen (pure 3D view), per-entry live
497
+ // selectors freeze to `undefined` so drag publishes do not re-render the
498
+ // hidden floor-plan tree.
499
+ const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
500
+ const drawingType = useDrawingView((s) => s.drawingType)
501
+ const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility)
502
+ const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference)
503
+ const floorplanMode = useFloorplanMode((s) => s.mode)
504
+ const effectiveWallDimensionReference = resolveFloorplanWallDimensionReference(
505
+ floorplanMode,
506
+ wallDimensionReference,
507
+ )
508
+ // Elevator builders read runtime state imperatively, so entries include this
509
+ // rare-changing ref in their cache deps.
215
510
  const interactiveElevators = useInteractive((s) => s.elevators)
216
511
 
217
- const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
218
- // Marquee preview selection — matches the legacy `highlightedIdSet` use
219
- // (filter-while-marquee), surfaces selection chrome without keyboard focus.
220
- const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds])
221
-
222
512
  // Interactive state lives in refs; only the visible feedback bits go
223
513
  // into React state to keep re-renders cheap during drag.
224
514
  const dragRef = useRef<ActiveDrag | null>(null)
225
515
  const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
226
516
  const [activeDragId, setActiveDragId] = useState<string | null>(null)
227
517
  const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
518
+ const geometryCacheRef = useRef<Map<string, CacheEntry>>(new Map())
519
+ const levelDataCacheRef = useRef<Map<string, LevelDataCacheEntry>>(new Map())
520
+ const nodesRef = useRef(nodes)
521
+ const [siblingEpochs, setSiblingEpochs] = useState<Map<AnyNodeId, number>>(() => new Map())
522
+ // Per-node sibling epoch (replaces a single global epoch). Bumped only for the
523
+ // nodes affected by this frame's live drags, so an unaffected wall/opening
524
+ // keeps its epoch and stays cached. `prevLiveFlaggedIdsRef` remembers which
525
+ // sibling-dependent nodes were live last frame, so a node that just STOPPED
526
+ // being dragged (override cleared, no commit) still gets one final rebuild to
527
+ // revert — its dependents (host wall, junction neighbours) don't carry its
528
+ // override in their own deps.
529
+ const nodeSiblingEpochRef = useRef<Map<AnyNodeId, number>>(new Map())
530
+ const prevLiveFlaggedIdsRef = useRef<AnyNodeId[]>([])
228
531
 
229
- const handleSelect = useCallback(
230
- (id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
231
- if (event.button !== 0) return
232
- event.stopPropagation()
233
- setSelection({ selectedIds: [id] })
532
+ useEffect(() => {
533
+ nodesRef.current = nodes
534
+ }, [nodes])
535
+
536
+ const bumpAffectedSiblingEpochs = useCallback(() => {
537
+ if (!floorplanVisible) return
538
+
539
+ const sceneNodes = nodesRef.current
540
+ const liveTransforms = useLiveTransforms.getState().transforms
541
+ const liveOverrides = useLiveNodeOverrides.getState().overrides
542
+ const liveFlaggedIds: AnyNodeId[] = []
543
+
544
+ for (const [id] of liveTransforms) {
545
+ const node = sceneNodes[id as AnyNodeId]
546
+ const def = node ? nodeRegistry.get(node.type) : null
547
+ if (
548
+ node &&
549
+ (def?.floorplanDependsOnSiblings ||
550
+ def?.floorplanSiblingOverrides ||
551
+ def?.floorplanAffectedIds)
552
+ ) {
553
+ liveFlaggedIds.push(id as AnyNodeId)
554
+ }
555
+ }
556
+ for (const [id] of liveOverrides) {
557
+ const node = sceneNodes[id as AnyNodeId]
558
+ const def = node ? nodeRegistry.get(node.type) : null
559
+ if (
560
+ node &&
561
+ (def?.floorplanDependsOnSiblings ||
562
+ def?.floorplanSiblingOverrides ||
563
+ def?.floorplanAffectedIds)
564
+ ) {
565
+ liveFlaggedIds.push(id as AnyNodeId)
566
+ }
567
+ }
568
+
569
+ const expandFrom = Array.from(new Set([...liveFlaggedIds, ...prevLiveFlaggedIdsRef.current]))
570
+ const affectedSiblingIds = computeAffectedSiblingIds(expandFrom, sceneNodes, liveOverrides)
571
+ const nodeSiblingEpochs = nodeSiblingEpochRef.current
572
+ for (const id of affectedSiblingIds) {
573
+ nodeSiblingEpochs.set(id, (nodeSiblingEpochs.get(id) ?? 0) + 1)
574
+ }
575
+ prevLiveFlaggedIdsRef.current = liveFlaggedIds
576
+ if (affectedSiblingIds.size > 0) {
577
+ setSiblingEpochs(new Map(nodeSiblingEpochs))
578
+ }
579
+ }, [floorplanVisible])
580
+
581
+ useEffect(() => {
582
+ bumpAffectedSiblingEpochs()
583
+ const unsubscribeTransforms = useLiveTransforms.subscribe(bumpAffectedSiblingEpochs)
584
+ const unsubscribeOverrides = useLiveNodeOverrides.subscribe(bumpAffectedSiblingEpochs)
585
+ return () => {
586
+ unsubscribeTransforms()
587
+ unsubscribeOverrides()
588
+ }
589
+ }, [bumpAffectedSiblingEpochs])
590
+
591
+ const applyEntrySelection = useCallback(
592
+ (id: AnyNodeId, shouldToggle: boolean) => {
593
+ const currentSelectedIds = useViewer.getState().selection.selectedIds
594
+ const nextSelectedIds = shouldToggle
595
+ ? currentSelectedIds.includes(id)
596
+ ? currentSelectedIds.filter((selectedId) => selectedId !== id)
597
+ : [...currentSelectedIds, id]
598
+ : [id]
599
+ setSelection({ selectedIds: nextSelectedIds })
600
+ if (nextSelectedIds.length === 1 && nextSelectedIds[0] === id) {
601
+ const node = useScene.getState().nodes[id]
602
+ if (node) emitCanvasNodeSelection(node)
603
+ }
234
604
  // Setting selection re-renders the entry — the overlay pass mounts
235
605
  // (endpoint handles, etc.), reshuffling DOM under the cursor between
236
606
  // pointerdown and click. If the click target ends up on the SVG
@@ -239,129 +609,329 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
239
609
  // selection we just set. Swallow the next click globally to break
240
610
  // that race; the listener removes itself after firing (or after a
241
611
  // 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)
612
+ swallowNextClick(200)
249
613
  },
250
614
  [setSelection],
251
615
  )
252
616
 
617
+ const handleSelect = useCallback(
618
+ (id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
619
+ if (event.button !== 0) return
620
+ event.stopPropagation()
621
+ applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey)
622
+ },
623
+ [applyEntrySelection],
624
+ )
625
+
253
626
  const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
627
+ if (isFloorplanOpeningPlacementActiveNow()) return
254
628
  event.stopPropagation()
255
629
  }, [])
256
630
 
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
- }[] = []
631
+ const startDirectMoveDrag = useCallback(
632
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
633
+ if (event.button !== 0 || !(event.metaKey || event.ctrlKey)) return false
284
634
 
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
635
+ const node = useScene.getState().nodes[id]
636
+ if (!node || !isRegistryMovable(node.type)) return false
637
+ // Sole selection only: per-node direct manipulation stands down for a
638
+ // multi-selection (the group session owns plain drags there, and Cmd is
639
+ // the selection-toggle key — a wobbly Cmd+click must not yank one
640
+ // member out of the group).
641
+ const currentSelectedIds = useViewer.getState().selection.selectedIds
642
+ if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== id) return false
643
+
644
+ event.preventDefault()
645
+ event.stopPropagation()
646
+
647
+ const startX = event.clientX
648
+ const startY = event.clientY
649
+ const pointerId = event.pointerId
650
+ let engaged = false
651
+
652
+ const cleanup = () => {
653
+ window.removeEventListener('pointermove', onMove)
654
+ window.removeEventListener('pointerup', onEnd)
655
+ window.removeEventListener('pointercancel', onEnd)
656
+ if (engaged) {
657
+ useViewer.getState().setInputDragging(false)
347
658
  }
348
- const ctx = buildContext(effectiveNode, contextNodes, {
349
- selected,
350
- highlighted,
351
- hovered,
352
- moving,
353
- palette: renderCtx?.palette,
659
+ }
660
+
661
+ const onMove = (moveEvent: PointerEvent) => {
662
+ if (moveEvent.pointerId !== pointerId) return
663
+ if (engaged) return
664
+ const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY)
665
+ if (distance < DIRECT_DRAG_THRESHOLD_PX) return
666
+
667
+ engaged = true
668
+ useViewer.getState().setInputDragging(true)
669
+ swallowNextClick(300)
670
+ createEditorApi().engageMoveDrag(node)
671
+
672
+ requestAnimationFrame(() => {
673
+ window.dispatchEvent(
674
+ new PointerEvent('pointermove', {
675
+ altKey: moveEvent.altKey,
676
+ bubbles: true,
677
+ buttons: moveEvent.buttons,
678
+ clientX: moveEvent.clientX,
679
+ clientY: moveEvent.clientY,
680
+ ctrlKey: moveEvent.ctrlKey,
681
+ metaKey: moveEvent.metaKey,
682
+ pointerId,
683
+ pointerType: moveEvent.pointerType,
684
+ shiftKey: moveEvent.shiftKey,
685
+ }),
686
+ )
354
687
  })
355
- const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
356
- effectiveNode,
357
- ctx,
688
+ }
689
+
690
+ const onEnd = (endEvent: PointerEvent) => {
691
+ if (endEvent.pointerId !== pointerId) return
692
+ cleanup()
693
+ if (!engaged) {
694
+ applyEntrySelection(id, true)
695
+ }
696
+ }
697
+
698
+ window.addEventListener('pointermove', onMove)
699
+ window.addEventListener('pointerup', onEnd)
700
+ window.addEventListener('pointercancel', onEnd)
701
+ return true
702
+ },
703
+ [applyEntrySelection],
704
+ )
705
+
706
+ const startDirectRotateDrag = useCallback(
707
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
708
+ if (event.button !== 2 || !(event.metaKey || event.ctrlKey)) return false
709
+
710
+ const sceneNodes = useScene.getState().nodes
711
+ const selectedNode = sceneNodes[id]
712
+ const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
713
+ if (!node || !canDirectRotateNode(node)) return false
714
+ // Sole selection only — same stand-down as the direct move above.
715
+ const selectedIds = useViewer.getState().selection.selectedIds
716
+ if (selectedIds.length !== 1 || selectedIds[0] !== id) return false
717
+ event.preventDefault()
718
+ event.stopPropagation()
719
+
720
+ const nodeId = node.id as AnyNodeId
721
+ const pointerId = event.pointerId
722
+ const startX = event.clientX
723
+ const sceneApi = createSceneApi(useScene)
724
+ let lastPatch: Partial<AnyNode> | null = null
725
+
726
+ const applyDelta = (pointerEvent: PointerEvent | ReactPointerEvent<SVGGElement>) => {
727
+ const delta = resolveDirectRotationDragDelta(
728
+ startX,
729
+ pointerEvent.clientX,
730
+ DIRECT_ROTATE_RADIANS_PER_PIXEL,
731
+ !isAngleSnapActive(),
358
732
  )
359
- if (geometry) {
360
- const { base, overlay } = splitFloorplanOverlay(geometry)
361
- out.push({ id, node: effectiveNode, base, overlay, selected, highlighted })
733
+ if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
734
+ lastPatch = null
735
+ useLiveNodeOverrides.getState().clear(nodeId)
736
+ useScene.getState().markDirty(nodeId)
737
+ return
362
738
  }
739
+ const patch = resolveDirectRotationPatch(node, delta, sceneApi)
740
+ if (!patch) return
741
+ lastPatch = patch
742
+ useLiveNodeOverrides.getState().set(nodeId, patch as Record<string, unknown>)
743
+ useScene.getState().markDirty(nodeId)
744
+ }
745
+
746
+ const cleanup = () => {
747
+ window.removeEventListener('pointermove', onMove, true)
748
+ window.removeEventListener('pointerup', onUp, true)
749
+ window.removeEventListener('pointercancel', onCancel, true)
750
+ window.removeEventListener('contextmenu', preventContextMenu, true)
751
+ useLiveNodeOverrides.getState().clear(nodeId)
752
+ useScene.getState().markDirty(nodeId)
753
+ resumeSceneHistory(useScene)
754
+ useDirectManipulationFeedback.getState().clearActiveRotateNodeId(nodeId)
755
+ useViewer.getState().setInputDragging(false)
756
+ if (document.body.style.cursor === 'ew-resize') {
757
+ document.body.style.cursor = ''
758
+ }
759
+ }
760
+
761
+ const onMove = (moveEvent: PointerEvent) => {
762
+ if (moveEvent.pointerId !== pointerId) return
763
+ moveEvent.preventDefault()
764
+ moveEvent.stopPropagation()
765
+ applyDelta(moveEvent)
766
+ }
767
+
768
+ const onUp = (upEvent: PointerEvent) => {
769
+ if (upEvent.pointerId !== pointerId) return
770
+ upEvent.preventDefault()
771
+ upEvent.stopPropagation()
772
+ swallowNextClick(300)
773
+ if (lastPatch) {
774
+ sceneApi.update(nodeId, lastPatch)
775
+ sfxEmitter.emit('sfx:item-place')
776
+ }
777
+ cleanup()
778
+ }
779
+
780
+ const onCancel = (cancelEvent: PointerEvent) => {
781
+ if (cancelEvent.pointerId !== pointerId) return
782
+ cleanup()
783
+ }
784
+
785
+ const preventContextMenu = (contextEvent: Event) => {
786
+ contextEvent.preventDefault()
787
+ contextEvent.stopPropagation()
788
+ }
789
+
790
+ pauseSceneHistory(useScene)
791
+ useViewer.getState().setInputDragging(true)
792
+ useDirectManipulationFeedback.getState().setActiveRotateNodeId(nodeId)
793
+ document.body.style.cursor = 'ew-resize'
794
+ sfxEmitter.emit('sfx:item-pick')
795
+ applyDelta(event)
796
+
797
+ window.addEventListener('pointermove', onMove, true)
798
+ window.addEventListener('pointerup', onUp, true)
799
+ window.addEventListener('pointercancel', onCancel, true)
800
+ window.addEventListener('contextmenu', preventContextMenu, true)
801
+ return true
802
+ },
803
+ [],
804
+ )
805
+
806
+ // Photoshop-style group drag: plain pointer-down on a transformable member
807
+ // of a multi-selection slides the whole selection rigidly; a plain click
808
+ // (no drag) enters the group pick-up instead — parity with the single-item
809
+ // click-to-move (clicking outside still deselects). Modified clicks
810
+ // (selection toggle) and Cmd-drag / direct-rotate keep their existing
811
+ // paths. `immediate` engages without the drag threshold — the move-handle
812
+ // dot's pick-up semantics.
813
+ const startGroupMoveDrag = useCallback(
814
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>, immediate = false): boolean => {
815
+ if (event.button !== 0) return false
816
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
817
+ if (movingNode) return false
818
+ if (useEditor.getState().mode === 'delete') return false
819
+ const started = startFloorplanGroupMove(id, event, {
820
+ immediate,
821
+ onClickFallthrough: immediate ? undefined : () => startGroupPickUp(),
822
+ })
823
+ if (!started) return false
824
+ event.preventDefault()
825
+ event.stopPropagation()
826
+ suppressBoxSelectForPointer(event)
827
+ return true
828
+ },
829
+ [movingNode],
830
+ )
831
+
832
+ // Move-handle dot variant — routes the dot through the group session when
833
+ // the owning node is part of a multi-selection.
834
+ const handleGroupMoveHandlePointerDown = useCallback(
835
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => startGroupMoveDrag(id, event, true),
836
+ [startGroupMoveDrag],
837
+ )
838
+
839
+ // The dashed selection box is itself the group's drag handle: a press
840
+ // anywhere inside it slides the group (or picks it up on a plain click),
841
+ // anchored on the first transformable member.
842
+ const handleGroupBoxPointerDown = useCallback(
843
+ (event: ReactPointerEvent<SVGGElement>) => {
844
+ const { selectedIds: currentIds, levelId: currentLevelId } = useViewer.getState().selection
845
+ const sceneNodes = useScene.getState().nodes
846
+ const anchor = currentIds.find(
847
+ (id) =>
848
+ classifyParticipant(sceneNodes[id as AnyNodeId], currentLevelId, sceneNodes) !== null,
849
+ )
850
+ if (!anchor) return
851
+ startGroupMoveDrag(anchor as AnyNodeId, event)
852
+ },
853
+ [startGroupMoveDrag],
854
+ )
855
+
856
+ // Corner rotate handles on the dashed selection box — 15° steps, Shift
857
+ // free, mirroring the 3D group rotate gizmo.
858
+ const handleGroupBoxRotatePointerDown = useCallback((event: ReactPointerEvent<SVGGElement>) => {
859
+ if (event.button !== 0) return
860
+ if (event.metaKey || event.ctrlKey || event.altKey) return
861
+ if (useEditor.getState().mode === 'delete') return
862
+ if (startFloorplanGroupRotate(event)) {
863
+ event.preventDefault()
864
+ suppressBoxSelectForPointer(event)
865
+ }
866
+ }, [])
867
+
868
+ const handleEntryPointerDown = useCallback(
869
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
870
+ // Keep this handler mounted during opening placement and arbitrate from
871
+ // the stores at event time. Commit clears the interaction scope before
872
+ // React paints the next frame; a render-time `undefined` handler leaves
873
+ // a short dead zone where the first post-placement selection is lost.
874
+ if (isFloorplanOpeningPlacementActiveNow()) return
875
+ if (startDirectMoveDrag(id, event)) return
876
+ if (startDirectRotateDrag(id, event)) return
877
+ if (startGroupMoveDrag(id, event)) return
878
+ handleSelect(id, event)
879
+ },
880
+ [handleSelect, startDirectMoveDrag, startDirectRotateDrag, startGroupMoveDrag],
881
+ )
882
+
883
+ const floorplanData = useMemo(() => {
884
+ if (!levelId) {
885
+ geometryCacheRef.current.clear()
886
+ levelDataCacheRef.current.clear()
887
+ return {
888
+ entries: [] as FloorplanEntryDescriptor[],
889
+ levelNodeIdsByType: new Map<string, AnyNodeId[]>(),
890
+ }
891
+ }
892
+
893
+ const out: FloorplanEntryDescriptor[] = []
894
+ const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
895
+
896
+ const collectLevelDataKind = (id: AnyNodeId) => {
897
+ const node = nodes[id]
898
+ if (!node) return
899
+ if (!isNodeKindEnabled(node.type, installedPlugins)) return
900
+ const def = nodeRegistry.get(node.type)
901
+ if (def?.computeFloorplanLevelData) {
902
+ const ids = levelNodeIdsByType.get(node.type)
903
+ if (ids) ids.push(id)
904
+ else levelNodeIdsByType.set(node.type, [id])
363
905
  }
364
906
  const childIds = (node as unknown as { children?: AnyNodeId[] }).children
907
+ if (Array.isArray(childIds)) {
908
+ for (const cid of childIds) collectLevelDataKind(cid)
909
+ }
910
+ }
911
+
912
+ collectLevelDataKind(levelId as AnyNodeId)
913
+
914
+ const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
915
+ if (!isNodeKindEnabled(node.type, installedPlugins)) return
916
+ const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
917
+ if (!drawingNode) return
918
+ const def = nodeRegistry.get(drawingNode.type)
919
+ if (!def?.floorplan) return
920
+ const dependsOnSiblingInputs = !!(
921
+ def.floorplanDependsOnSiblings ||
922
+ def.floorplanSiblingOverrides ||
923
+ def.floorplanAffectedIds
924
+ )
925
+ const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs }
926
+ if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
927
+ out.push(descriptor)
928
+ }
929
+
930
+ const visit = (id: AnyNodeId) => {
931
+ const node = nodes[id]
932
+ if (!node) return
933
+ pushEntry(id, node)
934
+ const childIds = (node as unknown as { children?: AnyNodeId[] }).children
365
935
  if (Array.isArray(childIds)) {
366
936
  for (const cid of childIds) visit(cid)
367
937
  }
@@ -369,6 +939,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
369
939
 
370
940
  visit(levelId as AnyNodeId)
371
941
 
942
+ const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
943
+ if (activeLevelNode) {
944
+ const collectedIds = new Set(out.map((entry) => entry.id))
945
+ for (const linked of collectFloorplanLinkedLevelNodes(
946
+ nodes,
947
+ levelId as AnyNodeId,
948
+ collectedIds,
949
+ )) {
950
+ pushEntry(linked.id, linked.node, {
951
+ children: linked.children,
952
+ siblings: [],
953
+ parent: activeLevelNode,
954
+ })
955
+ }
956
+ }
957
+
372
958
  // Building-scoped kinds (`def.floorplanScope === 'building'`) live
373
959
  // as siblings of the level, not under it — the `visit(levelId)` DFS
374
960
  // above doesn't reach them. Walk every node of those kinds whose
@@ -377,7 +963,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
377
963
  // builders that gate on the current floor — e.g. elevator service
378
964
  // range — keep working). Pure registry-driven dispatch: no kind
379
965
  // name appears in this file.
380
- const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
381
966
  const activeBuildingId = activeLevelNode
382
967
  ? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
383
968
  : null
@@ -386,54 +971,14 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
386
971
  const buildingScopedKindSet = new Set(buildingScopedKinds)
387
972
  for (const [id, node] of Object.entries(nodes)) {
388
973
  if (!node || !buildingScopedKindSet.has(node.type)) continue
389
- if ((node as { visible?: boolean }).visible === false) continue
390
974
  const parentId = (node as { parentId?: AnyNodeId | null }).parentId
391
975
  if (parentId !== activeBuildingId) continue
392
976
  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,
977
+ pushEntry(cid, node, {
416
978
  children: [],
417
979
  siblings: [],
418
980
  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
- }
981
+ })
437
982
  }
438
983
  }
439
984
 
@@ -445,19 +990,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
445
990
  // DFS visit order (stable sort) so siblings keep their relative
446
991
  // priority.
447
992
  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
- ])
993
+ const entryIds = new Set(out.map((entry) => entry.id))
994
+ for (const id of geometryCacheRef.current.keys()) {
995
+ if (!entryIds.has(id as AnyNodeId)) geometryCacheRef.current.delete(id)
996
+ }
997
+ for (const type of levelDataCacheRef.current.keys()) {
998
+ if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
999
+ }
1000
+ return { entries: out, levelNodeIdsByType }
1001
+ }, [drawingType, installedPlugins, levelId, nodes])
461
1002
 
462
1003
  // ── Generic 2D affordance dispatch ─────────────────────────────────
463
1004
  //
@@ -466,6 +1007,37 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
466
1007
  // dispatcher then owns: history pause/resume, snapshot capture,
467
1008
  // pointer-move/up/cancel routing, and the single-undo dance on
468
1009
  // commit. Each kind owns the actual mutation logic inside `apply`.
1010
+ const commitAffordanceAction = useCallback(
1011
+ (
1012
+ nodeId: AnyNodeId,
1013
+ affordance: string,
1014
+ payload: unknown,
1015
+ event: ReactMouseEvent<SVGElement>,
1016
+ ) => {
1017
+ if (event.button !== 0 || movingNode || dragRef.current) return
1018
+
1019
+ const sceneNodes = useScene.getState().nodes
1020
+ const node = sceneNodes[nodeId]
1021
+ if (!node) return
1022
+ const handler = nodeRegistry.get(node.type)?.floorplanAffordances?.[affordance]
1023
+ if (!handler) return
1024
+ const initialPlanPoint = clientToPlan(event.clientX, event.clientY)
1025
+ if (!initialPlanPoint) return
1026
+
1027
+ const session = handler.start({
1028
+ node,
1029
+ payload,
1030
+ nodes: sceneNodes,
1031
+ initialPlanPoint,
1032
+ gridSnapStep: useEditor.getState().gridSnapStep,
1033
+ })
1034
+ if (!(session.commit && session.canCommit())) return
1035
+ session.commit()
1036
+ sfxEmitter.emit('sfx:structure-build')
1037
+ },
1038
+ [movingNode],
1039
+ )
1040
+
469
1041
  const startAffordanceDrag = useCallback(
470
1042
  (
471
1043
  nodeId: AnyNodeId,
@@ -493,6 +1065,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
493
1065
 
494
1066
  event.preventDefault()
495
1067
  event.stopPropagation()
1068
+ suppressBoxSelectForPointer(event)
496
1069
 
497
1070
  const session = handler.start({
498
1071
  node,
@@ -525,22 +1098,73 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
525
1098
  }
526
1099
  }
527
1100
 
1101
+ // Begin the matching reshaping scope so the contextual snapping HUD shows
1102
+ // the right chip during the edit AND `getActiveSnapContext()` resolves the
1103
+ // polygon / wall mode-set the affordance's snap math reads. Torn down on
1104
+ // release / cancel below. `null` for resize / rotate (no snapping chip).
1105
+ const reshapeScope = floorplanAffordanceReshapeScope(affordance, nodeId, payload)
1106
+ if (reshapeScope) {
1107
+ useInteractionScope.getState().begin(reshapeScope)
1108
+ }
1109
+
1110
+ const captureTarget = event.currentTarget as Element
528
1111
  dragRef.current = {
529
1112
  pointerId: event.pointerId,
1113
+ captureTarget,
530
1114
  handleId,
531
1115
  session,
532
1116
  snapshots,
533
1117
  historyPaused: true,
1118
+ lastPlanPoint: initialPlanPoint,
534
1119
  rotation,
1120
+ reshapeScopeNodeId: reshapeScope ? nodeId : undefined,
535
1121
  }
536
1122
  setActiveDragId(handleId)
537
1123
  setSelection({ selectedIds: [nodeId] })
538
- ;(event.currentTarget as Element).setPointerCapture?.(event.pointerId)
1124
+ captureTarget.setPointerCapture?.(event.pointerId)
539
1125
  },
540
1126
  [movingNode, setSelection],
541
1127
  )
542
1128
 
543
1129
  useEffect(() => {
1130
+ // Tear down the scope this drag opened (if any) — a reshaping scope for an
1131
+ // edit affordance, or a handle-drag scope for a rotate-arrow — matched by
1132
+ // node id so a concurrent scope from another path is never ended by mistake.
1133
+ const endReshapeScope = (drag: ActiveDrag) => {
1134
+ if (drag.reshapeScopeNodeId) {
1135
+ useInteractionScope
1136
+ .getState()
1137
+ .endIf(
1138
+ (s) =>
1139
+ (s.kind === 'reshaping' || s.kind === 'handle-drag') &&
1140
+ s.nodeId === drag.reshapeScopeNodeId,
1141
+ )
1142
+ }
1143
+ }
1144
+
1145
+ const cancelActiveDrag = (pointerId?: number, clearDragFeedback = true) =>
1146
+ cancelFloorplanAffordanceDrag(
1147
+ dragRef,
1148
+ {
1149
+ restoreSnapshots: (snapshots) =>
1150
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots)),
1151
+ resumeHistory: () => resumeSceneHistory(useScene),
1152
+ clearPreview: (id) => {
1153
+ useLiveNodeOverrides.getState().clear(id)
1154
+ useLiveTransforms.getState().clear(id)
1155
+ },
1156
+ clearSnapFeedback: clearSurfacePlanSnapFeedback,
1157
+ endReshapeScope,
1158
+ clearDragFeedback: clearDragFeedback
1159
+ ? () => {
1160
+ setActiveDragId(null)
1161
+ setRotationOverlay(null)
1162
+ }
1163
+ : undefined,
1164
+ },
1165
+ pointerId,
1166
+ )
1167
+
544
1168
  const onPointerMove = (event: PointerEvent) => {
545
1169
  const drag = dragRef.current
546
1170
  if (!drag || event.pointerId !== drag.pointerId) return
@@ -548,6 +1172,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
548
1172
  const planPoint = clientToPlan(event.clientX, event.clientY)
549
1173
  if (!planPoint) return
550
1174
 
1175
+ drag.lastPlanPoint = planPoint
551
1176
  drag.session.apply({
552
1177
  planPoint,
553
1178
  modifiers: {
@@ -569,6 +1194,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
569
1194
  let delta = current - rot.initialAngle
570
1195
  while (delta > Math.PI) delta -= 2 * Math.PI
571
1196
  while (delta < -Math.PI) delta += 2 * Math.PI
1197
+ delta = snapDirectRotationDelta(delta, !isAngleSnapActive())
572
1198
  if (Math.abs(delta) < 0.0087) {
573
1199
  setRotationOverlay(null)
574
1200
  } else {
@@ -603,14 +1229,18 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
603
1229
  }
604
1230
  drag.session.commit()
605
1231
  sfxEmitter.emit('sfx:structure-build')
1232
+ clearSurfacePlanSnapFeedback()
1233
+ endReshapeScope(drag)
606
1234
  dragRef.current = null
607
1235
  setActiveDragId(null)
608
1236
  setRotationOverlay(null)
609
1237
  return
610
1238
  }
611
1239
 
612
- // Capture the final state BEFORE the revert so we know what to
613
- // re-apply post-resume.
1240
+ // Legacy compatibility for sessions that still wrote preview state into
1241
+ // `useScene` during `apply()`: capture the final state BEFORE the revert
1242
+ // so we know what to re-apply post-resume. New sessions should provide a
1243
+ // `commit()` hook and preview through live overrides/transforms instead.
614
1244
  const sceneNodes = useScene.getState().nodes
615
1245
  const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
616
1246
  for (const snap of drag.snapshots) {
@@ -629,7 +1259,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
629
1259
  }
630
1260
 
631
1261
  if (commitValid && finalUpdates.length > 0) {
632
- // Single-undo dance (mirrors the 3D move-endpoint-tool):
1262
+ // Legacy single-undo dance (mirrors the old 3D move-endpoint-tool):
633
1263
  // 1. Revert to baseline while history is still paused (untracked).
634
1264
  // 2. Resume history.
635
1265
  // 3. Re-apply the final state — recorded as one tracked change.
@@ -654,209 +1284,1033 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
654
1284
  for (const id of drag.session.affectedIds) overrides.clear(id)
655
1285
  }
656
1286
 
1287
+ clearSurfacePlanSnapFeedback()
1288
+ endReshapeScope(drag)
657
1289
  dragRef.current = null
658
1290
  setActiveDragId(null)
659
1291
  setRotationOverlay(null)
660
1292
  }
661
1293
 
662
1294
  const onPointerCancel = (event: PointerEvent) => {
663
- const drag = dragRef.current
664
- if (!drag || event.pointerId !== drag.pointerId) return
1295
+ cancelActiveDrag(event.pointerId)
1296
+ }
665
1297
 
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
1298
+ // Re-run the active session the moment a modifier key flips so behaviors
1299
+ // like the wall endpoint's Alt-detach / re-attach take effect immediately
1300
+ // instead of waiting for the next pointer move. `event.altKey` & co
1301
+ // already reflect the post-transition state on both keydown and keyup.
1302
+ const onModifierKeyChange = (event: KeyboardEvent) => {
1303
+ const drag = dragRef.current
1304
+ if (!drag || event.repeat) return
1305
+ if (
1306
+ event.key !== 'Alt' &&
1307
+ event.key !== 'Shift' &&
1308
+ event.key !== 'Control' &&
1309
+ event.key !== 'Meta'
1310
+ ) {
1311
+ return
671
1312
  }
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)
1313
+ drag.session.apply({
1314
+ planPoint: drag.lastPlanPoint,
1315
+ modifiers: {
1316
+ shiftKey: event.shiftKey,
1317
+ altKey: event.altKey,
1318
+ ctrlKey: event.ctrlKey,
1319
+ metaKey: event.metaKey,
1320
+ },
1321
+ })
686
1322
  }
687
1323
 
688
1324
  window.addEventListener('pointermove', onPointerMove)
689
1325
  window.addEventListener('pointerup', onPointerUp)
690
1326
  window.addEventListener('pointercancel', onPointerCancel)
1327
+ window.addEventListener('keydown', onModifierKeyChange)
1328
+ window.addEventListener('keyup', onModifierKeyChange)
1329
+ const unsubscribeToolCancel = subscribeFloorplanAffordanceToolCancel(
1330
+ () => cancelActiveDrag(),
1331
+ markToolCancelConsumed,
1332
+ )
691
1333
  return () => {
692
1334
  window.removeEventListener('pointermove', onPointerMove)
693
1335
  window.removeEventListener('pointerup', onPointerUp)
694
1336
  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)
1337
+ window.removeEventListener('keydown', onModifierKeyChange)
1338
+ window.removeEventListener('keyup', onModifierKeyChange)
1339
+ unsubscribeToolCancel()
1340
+ if (!cancelActiveDrag(undefined, false)) {
1341
+ clearSurfacePlanSnapFeedback()
1342
+ }
1343
+ }
1344
+ }, [])
1345
+
1346
+ const entries = floorplanData.entries
1347
+ if (entries.length === 0) return null
1348
+
1349
+ const palette = renderCtx?.palette
1350
+
1351
+ return (
1352
+ // The outer wrapper stops `click` events that escape an entry's
1353
+ // `onClick={handleClickStop}`. The base+overlay split means
1354
+ // pointer-down can land on the base `<g>` and pointer-up on the
1355
+ // overlay `<g>` (selection mounts the overlay on top mid-gesture).
1356
+ // When the down/up targets differ, the browser dispatches `click`
1357
+ // to the lowest common ancestor — which sits ABOVE the entry-level
1358
+ // handler. Without this guard the click reaches the SVG's
1359
+ // `handleBackgroundClick`, which calls
1360
+ // `resolveFloorplanBackgroundSelection` → `clear-elements` (because
1361
+ // registry-driven items aren't in the legacy hit-test set) →
1362
+ // clearing the selection that pointer-down just set, so items
1363
+ // appear to "deselect themselves a fraction of a second after
1364
+ // clicking." Scoped to `onClick` so hover / drag / pointer events
1365
+ // still propagate normally inside the registry tree.
1366
+ <g
1367
+ className="floorplan-registry-layer"
1368
+ onClick={handleClickStop}
1369
+ opacity={isAmbient ? 0.3 : undefined}
1370
+ style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
1371
+ >
1372
+ {/* Base pass — rank-sorted body geometry (polygons, paths, fills,
1373
+ strokes, hatches). Lower-rank kinds (zones) paint first so
1374
+ higher-rank kinds (slabs, then walls / items / shelves) layer
1375
+ on top in the expected document-order z-stack. */}
1376
+ <g className="floorplan-registry-base">
1377
+ {entries.map((entry) => (
1378
+ <FloorplanRegistryEntry
1379
+ activeDragId={handleIdForNode(activeDragId, entry.id)}
1380
+ annotationVisibility={annotationVisibility}
1381
+ floorplanMode={floorplanMode}
1382
+ floorplanVisible={floorplanVisible}
1383
+ geometryCacheRef={geometryCacheRef}
1384
+ hatchPatternId={renderCtx?.hatchPatternId}
1385
+ hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1386
+ interactiveElevators={interactiveElevators}
1387
+ isMarqueeSelectionActive={isMarqueeSelectionActive}
1388
+ key={`base-${entry.id}`}
1389
+ levelDataCacheRef={levelDataCacheRef}
1390
+ levelNodeIdsByType={floorplanData.levelNodeIdsByType}
1391
+ moving={movingNode?.id === entry.id}
1392
+ node={entry.node}
1393
+ nodeId={entry.id}
1394
+ nodes={nodes}
1395
+ onClickStop={handleClickStop}
1396
+ onEntryPointerDown={handleEntryPointerDown}
1397
+ onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
1398
+ onHandleHoverChange={setHoveredHandleId}
1399
+ onHandleDoubleClick={commitAffordanceAction}
1400
+ onHandlePointerDown={startAffordanceDrag}
1401
+ palette={palette}
1402
+ pass="base"
1403
+ sceneRotationDeg={sceneRotationDeg}
1404
+ setMovingNode={setMovingNode}
1405
+ setMovingNodeOrigin={setMovingNodeOrigin}
1406
+ siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1407
+ unit={unit}
1408
+ metricNotation={metricNotation}
1409
+ wallDimensionReference={effectiveWallDimensionReference}
1410
+ visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
1411
+ ctxOverrides={entry.ctxOverrides}
1412
+ />
1413
+ ))}
1414
+ </g>
1415
+ {/* Overlay pass — interactive handles (vertex / midpoint / edge /
1416
+ move) and labels (text / dimensions). Painted after every base
1417
+ entry so polygon-editor chrome on a selected slab stays above
1418
+ neighbouring walls, and a zone name stays readable above the
1419
+ slab + wall geometry sitting on top of the zone. Each overlay
1420
+ still routes through the same selection-handling `<g>` so a
1421
+ click on a zone's name selects the zone. */}
1422
+ <g className="floorplan-registry-overlay">
1423
+ {entries.map((entry) => (
1424
+ <FloorplanRegistryEntry
1425
+ activeDragId={handleIdForNode(activeDragId, entry.id)}
1426
+ annotationVisibility={annotationVisibility}
1427
+ floorplanMode={floorplanMode}
1428
+ floorplanVisible={floorplanVisible}
1429
+ geometryCacheRef={geometryCacheRef}
1430
+ hatchPatternId={renderCtx?.hatchPatternId}
1431
+ hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1432
+ interactiveElevators={interactiveElevators}
1433
+ isMarqueeSelectionActive={isMarqueeSelectionActive}
1434
+ key={`overlay-${entry.id}`}
1435
+ levelDataCacheRef={levelDataCacheRef}
1436
+ levelNodeIdsByType={floorplanData.levelNodeIdsByType}
1437
+ moving={movingNode?.id === entry.id}
1438
+ node={entry.node}
1439
+ nodeId={entry.id}
1440
+ nodes={nodes}
1441
+ onClickStop={handleClickStop}
1442
+ onEntryPointerDown={handleEntryPointerDown}
1443
+ onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
1444
+ onHandleHoverChange={setHoveredHandleId}
1445
+ onHandleDoubleClick={commitAffordanceAction}
1446
+ onHandlePointerDown={startAffordanceDrag}
1447
+ palette={palette}
1448
+ pass="overlay"
1449
+ sceneRotationDeg={sceneRotationDeg}
1450
+ setMovingNode={setMovingNode}
1451
+ setMovingNodeOrigin={setMovingNodeOrigin}
1452
+ siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1453
+ unit={unit}
1454
+ metricNotation={metricNotation}
1455
+ wallDimensionReference={effectiveWallDimensionReference}
1456
+ visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
1457
+ ctxOverrides={entry.ctxOverrides}
1458
+ />
1459
+ ))}
1460
+ </g>
1461
+ <FloorplanAnnotationLayoutResolver active={floorplanVisible} />
1462
+ {/* Dashed group bbox — shows what a group drag carries along while a
1463
+ multi-selection exists, rides the live delta mid-drag, and doubles
1464
+ as the group's whole-area drag handle. */}
1465
+ <ScaleAwareFloorplanGroupSelectionBox
1466
+ onPointerDown={handleGroupBoxPointerDown}
1467
+ onRotatePointerDown={handleGroupBoxRotatePointerDown}
1468
+ palette={palette}
1469
+ />
1470
+ {/* Transient live-rotation readout — drawn last so the wedge + degree
1471
+ chip sit above all handle chrome while a rotate-arrow is dragged. */}
1472
+ {rotationOverlay && palette ? (
1473
+ <RotationAngleOverlay
1474
+ overlay={rotationOverlay}
1475
+ palette={palette}
1476
+ sceneRotationDeg={sceneRotationDeg}
1477
+ />
1478
+ ) : null}
1479
+ </g>
1480
+ )
1481
+ })
1482
+
1483
+ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
1484
+ const markerRef = useRef<SVGGElement>(null)
1485
+ const collisionLabelElementsRef = useRef<SVGGElement[]>([])
1486
+ const registryLabelElementsRef = useRef<SVGGElement[]>([])
1487
+ const registryOrientationElementsRef = useRef<SVGGElement[]>([])
1488
+ const appliedRotationDegRef = useRef<number | null>(null)
1489
+ const appliedLayoutInputsRef = useRef<object | null>(null)
1490
+ const interactionIdle = useInteractionScope((state) => isIdle(state.scope))
1491
+ const sceneRotationDeg = useFloorplanSceneRotation()
1492
+ const [settledLayoutEpoch, setSettledLayoutEpoch] = useState(0)
1493
+ const drawingType = useDrawingView((state) => state.drawingType)
1494
+ const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides)
1495
+ const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility)
1496
+ const wallDimensionReference = useFloorplanAnnotationVisibility(
1497
+ (state) => state.wallDimensionReference,
1498
+ )
1499
+ const floorplanMode = useFloorplanMode((state) => state.mode)
1500
+ const layoutInputs = useMemo(
1501
+ () => ({
1502
+ annotationVisibility,
1503
+ annotationLayoutOverrides,
1504
+ drawingType,
1505
+ interactionIdle,
1506
+ settledLayoutEpoch,
1507
+ wallDimensionReference,
1508
+ floorplanMode,
1509
+ }),
1510
+ [
1511
+ annotationVisibility,
1512
+ annotationLayoutOverrides,
1513
+ drawingType,
1514
+ interactionIdle,
1515
+ settledLayoutEpoch,
1516
+ wallDimensionReference,
1517
+ floorplanMode,
1518
+ ],
1519
+ )
1520
+ const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride)
1521
+ const layoutEnabled = active && interactionIdle
1522
+
1523
+ useEffect(() => {
1524
+ if (!layoutEnabled) return
1525
+ let frame: number | null = null
1526
+ const invalidateLayout = () => {
1527
+ if (frame !== null) return
1528
+ frame = window.requestAnimationFrame(() => {
1529
+ frame = null
1530
+ setSettledLayoutEpoch((epoch) => epoch + 1)
1531
+ })
1532
+ }
1533
+ const unsubscribeScene = useScene.subscribe((state, previousState) => {
1534
+ if (
1535
+ state.nodes !== previousState.nodes ||
1536
+ state.installedPlugins !== previousState.installedPlugins
1537
+ ) {
1538
+ invalidateLayout()
1539
+ }
1540
+ })
1541
+ const unsubscribeTransforms = useLiveTransforms.subscribe((state, previousState) => {
1542
+ if (state.transforms !== previousState.transforms) invalidateLayout()
1543
+ })
1544
+ const unsubscribeOverrides = useLiveNodeOverrides.subscribe((state, previousState) => {
1545
+ if (state.overrides !== previousState.overrides) invalidateLayout()
1546
+ })
1547
+ const unsubscribeInteractive = useInteractive.subscribe((state, previousState) => {
1548
+ if (state.elevators !== previousState.elevators) invalidateLayout()
1549
+ })
1550
+
1551
+ return () => {
1552
+ unsubscribeScene()
1553
+ unsubscribeTransforms()
1554
+ unsubscribeOverrides()
1555
+ unsubscribeInteractive()
1556
+ if (frame !== null) window.cancelAnimationFrame(frame)
1557
+ }
1558
+ }, [layoutEnabled])
1559
+
1560
+ useLayoutEffect(() => {
1561
+ // Explicit layout inputs replace the former whole-subtree MutationObserver.
1562
+ if (!active) {
1563
+ appliedLayoutInputsRef.current = null
1564
+ return
1565
+ }
1566
+ if (!interactionIdle) return
1567
+ const registryLayer = markerRef.current?.parentElement
1568
+ if (!registryLayer) return
1569
+ const update = resolveFloorplanAnnotationUpdate({
1570
+ layoutInputsChanged: appliedLayoutInputsRef.current !== layoutInputs,
1571
+ nextRotationDeg: sceneRotationDeg,
1572
+ previousRotationDeg: appliedRotationDegRef.current,
1573
+ })
1574
+ if (update.resolveCollisions) {
1575
+ const svg = markerRef.current?.ownerSVGElement
1576
+ if (!svg) return
1577
+ collisionLabelElementsRef.current = Array.from(
1578
+ svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
1579
+ )
1580
+ registryLabelElementsRef.current = collisionLabelElementsRef.current.filter((label) =>
1581
+ registryLayer.contains(label),
1582
+ )
1583
+ registryOrientationElementsRef.current = Array.from(
1584
+ registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-angle-radians]'),
1585
+ )
1586
+ }
1587
+ if (update.updateLabelPresentation) {
1588
+ updateSvgFloorplanLabelOrientations(registryOrientationElementsRef.current, sceneRotationDeg)
1589
+ appliedRotationDegRef.current = sceneRotationDeg
1590
+ }
1591
+ if (!update.resolveCollisions) return
1592
+ const svg = markerRef.current?.ownerSVGElement
1593
+ if (!svg) return
1594
+ resolveSvgAnnotationCollisions(svg, {
1595
+ labels: collisionLabelElementsRef.current,
1596
+ layoutOverrides: annotationLayoutOverrides,
1597
+ })
1598
+ appliedLayoutInputsRef.current = layoutInputs
1599
+
1600
+ for (const [index, label] of registryLabelElementsRef.current.entries()) {
1601
+ const id = svgAnnotationLabelId(label, index)
1602
+ label.dataset.floorplanAnnotationId = id
1603
+ label.style.pointerEvents = 'all'
1604
+ label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move'
1605
+ }
1606
+ }, [active, annotationLayoutOverrides, interactionIdle, layoutInputs, sceneRotationDeg])
1607
+
1608
+ useEffect(() => {
1609
+ if (!layoutEnabled) return
1610
+ const registryLayer = markerRef.current?.parentElement
1611
+ if (!registryLayer) return
1612
+ let cleanupPointerDrag: (() => void) | null = null
1613
+ let cancelActivePointerDrag: (() => void) | null = null
1614
+
1615
+ const findLabel = (target: EventTarget | null): SVGGElement | null => {
1616
+ if (!(target instanceof Element)) return null
1617
+ const label = target.closest<SVGGElement>('[data-floorplan-annotation-label]')
1618
+ return label && registryLayer.contains(label) ? label : null
1619
+ }
1620
+
1621
+ const labelId = (label: SVGGElement): string => {
1622
+ const labels = registryLabelElementsRef.current
1623
+ return svgAnnotationLabelId(label, Math.max(0, labels.indexOf(label)))
1624
+ }
1625
+
1626
+ const onPointerDown = (event: PointerEvent) => {
1627
+ const label = findLabel(event.target)
1628
+ if (!(label && event.button === 0)) return
1629
+ const matrix = label.getScreenCTM()
1630
+ if (!matrix) return
1631
+ event.preventDefault()
1632
+ event.stopPropagation()
1633
+ cancelActivePointerDrag?.()
1634
+ label.style.cursor = 'grabbing'
1635
+ const id = labelId(label)
1636
+ const start = { x: event.clientX, y: event.clientY }
1637
+ const existing = useDrawingView.getState().annotationLayoutOverrides[id] ?? {
1638
+ ...readFloorplanAnnotationLayoutOffset(label),
1639
+ pinned: true,
1640
+ }
1641
+ const wasPinned = useDrawingView.getState().annotationLayoutOverrides[id]?.pinned === true
1642
+ let latest = existing
1643
+ let moved = false
1644
+
1645
+ const onPointerMove = (moveEvent: PointerEvent) => {
1646
+ if (moveEvent.pointerId !== event.pointerId) return
1647
+ moved = true
1648
+ const local = screenVectorToFloorplanAnnotationLocal(
1649
+ matrix,
1650
+ moveEvent.clientX - start.x,
1651
+ moveEvent.clientY - start.y,
1652
+ )
1653
+ latest = {
1654
+ dx: existing.dx + local.x,
1655
+ dy: existing.dy + local.y,
1656
+ pinned: true,
704
1657
  }
705
- const overrides = useLiveNodeOverrides.getState()
706
- for (const id of drag.session.affectedIds) overrides.clear(id)
707
- dragRef.current = null
1658
+ const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
1659
+ label.setAttribute(
1660
+ 'transform',
1661
+ `${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(),
1662
+ )
1663
+ }
1664
+
1665
+ const finishPointerDrag = (endEvent: PointerEvent) => {
1666
+ if (endEvent.pointerId !== event.pointerId) return
1667
+ cleanupPointerDrag?.()
1668
+ label.style.cursor = moved || wasPinned ? 'grab' : 'move'
1669
+ if (moved) setAnnotationLayoutOverride(id, latest)
1670
+ }
1671
+
1672
+ const cancelPointerDrag = (cancelEvent: PointerEvent) => {
1673
+ if (cancelEvent.pointerId !== event.pointerId) return
1674
+ cancelActivePointerDrag?.()
1675
+ }
1676
+
1677
+ cancelActivePointerDrag = () => {
1678
+ cleanupPointerDrag?.()
1679
+ label.style.cursor = wasPinned ? 'grab' : 'move'
1680
+ const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
1681
+ label.setAttribute(
1682
+ 'transform',
1683
+ `${defaultTransform} translate(${existing.dx} ${existing.dy})`.trim(),
1684
+ )
1685
+ }
1686
+
1687
+ cleanupPointerDrag = () => {
1688
+ window.removeEventListener('pointermove', onPointerMove)
1689
+ window.removeEventListener('pointerup', finishPointerDrag)
1690
+ window.removeEventListener('pointercancel', cancelPointerDrag)
1691
+ cleanupPointerDrag = null
1692
+ cancelActivePointerDrag = null
1693
+ }
1694
+ window.addEventListener('pointermove', onPointerMove)
1695
+ window.addEventListener('pointerup', finishPointerDrag)
1696
+ window.addEventListener('pointercancel', cancelPointerDrag)
1697
+ }
1698
+
1699
+ const onDoubleClick = (event: MouseEvent) => {
1700
+ const label = findLabel(event.target)
1701
+ if (!label) return
1702
+ event.preventDefault()
1703
+ event.stopPropagation()
1704
+ label.style.cursor = 'move'
1705
+ setAnnotationLayoutOverride(labelId(label), null)
1706
+ }
1707
+
1708
+ registryLayer.addEventListener('pointerdown', onPointerDown)
1709
+ registryLayer.addEventListener('dblclick', onDoubleClick)
1710
+ return () => {
1711
+ cancelActivePointerDrag?.()
1712
+ registryLayer.removeEventListener('pointerdown', onPointerDown)
1713
+ registryLayer.removeEventListener('dblclick', onDoubleClick)
1714
+ for (const label of registryLabelElementsRef.current) {
1715
+ label.style.pointerEvents = ''
1716
+ label.style.cursor = ''
1717
+ }
1718
+ }
1719
+ }, [layoutEnabled, setAnnotationLayoutOverride])
1720
+ return <g pointerEvents="none" ref={markerRef} />
1721
+ }
1722
+
1723
+ function screenVectorToFloorplanAnnotationLocal(matrix: DOMMatrix, dx: number, dy: number) {
1724
+ const determinant = matrix.a * matrix.d - matrix.b * matrix.c
1725
+ if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 }
1726
+ return {
1727
+ x: (matrix.d * dx - matrix.c * dy) / determinant,
1728
+ y: (-matrix.b * dx + matrix.a * dy) / determinant,
1729
+ }
1730
+ }
1731
+
1732
+ function readFloorplanAnnotationLayoutOffset(label: SVGGElement) {
1733
+ const dx = Number(label.dataset.floorplanAnnotationLayoutDx ?? 0)
1734
+ const dy = Number(label.dataset.floorplanAnnotationLayoutDy ?? 0)
1735
+ return {
1736
+ dx: Number.isFinite(dx) ? dx : 0,
1737
+ dy: Number.isFinite(dy) ? dy : 0,
1738
+ }
1739
+ }
1740
+
1741
+ type FloorplanRegistryEntryProps = {
1742
+ activeDragId: string | null
1743
+ annotationVisibility: FloorplanAnnotationVisibility
1744
+ floorplanMode: FloorplanMode
1745
+ ctxOverrides: FloorplanContextOverrides | undefined
1746
+ floorplanVisible: boolean
1747
+ geometryCacheRef: { current: Map<string, CacheEntry> }
1748
+ hatchPatternId: string | undefined
1749
+ hoveredHandleId: string | null
1750
+ interactiveElevators: unknown
1751
+ isMarqueeSelectionActive: boolean
1752
+ levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> }
1753
+ levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
1754
+ moving: boolean
1755
+ node: AnyNode
1756
+ nodeId: AnyNodeId
1757
+ nodes: Record<string, AnyNode>
1758
+ onClickStop: (event: React.MouseEvent<SVGGElement>) => void
1759
+ onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
1760
+ onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
1761
+ onHandleHoverChange: (id: string | null) => void
1762
+ onHandleDoubleClick: (
1763
+ nodeId: AnyNodeId,
1764
+ affordance: string,
1765
+ payload: unknown,
1766
+ event: ReactMouseEvent<SVGElement>,
1767
+ ) => void
1768
+ onHandlePointerDown: (
1769
+ nodeId: AnyNodeId,
1770
+ handleId: string,
1771
+ affordance: string,
1772
+ payload: unknown,
1773
+ event: ReactPointerEvent<SVGGElement>,
1774
+ rotationPivot?: FloorplanPoint,
1775
+ ) => void
1776
+ palette: FloorplanPalette | undefined
1777
+ pass: FloorplanRenderPass
1778
+ sceneRotationDeg: number
1779
+ setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
1780
+ setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
1781
+ siblingEpoch: number
1782
+ unit: 'metric' | 'imperial'
1783
+ metricNotation: 'meters' | 'millimeters'
1784
+ wallDimensionReference: FloorplanWallDimensionReference
1785
+ visibilityRootId: AnyNodeId | undefined
1786
+ }
1787
+
1788
+ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1789
+ activeDragId,
1790
+ annotationVisibility,
1791
+ floorplanMode,
1792
+ ctxOverrides,
1793
+ floorplanVisible,
1794
+ geometryCacheRef,
1795
+ hatchPatternId,
1796
+ hoveredHandleId,
1797
+ interactiveElevators,
1798
+ isMarqueeSelectionActive,
1799
+ levelDataCacheRef,
1800
+ levelNodeIdsByType,
1801
+ moving,
1802
+ node,
1803
+ nodeId,
1804
+ nodes,
1805
+ onClickStop,
1806
+ onEntryPointerDown,
1807
+ onGroupMovePointerDown,
1808
+ onHandleHoverChange,
1809
+ onHandleDoubleClick,
1810
+ onHandlePointerDown,
1811
+ palette,
1812
+ pass,
1813
+ sceneRotationDeg,
1814
+ setMovingNode,
1815
+ setMovingNodeOrigin,
1816
+ siblingEpoch,
1817
+ unit,
1818
+ metricNotation,
1819
+ wallDimensionReference,
1820
+ visibilityRootId,
1821
+ }: FloorplanRegistryEntryProps): React.ReactElement | null {
1822
+ const selected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
1823
+ const highlighted = useViewer((state) => state.previewSelectedIds.includes(nodeId))
1824
+ const suppressHandles = useViewer(
1825
+ (state) =>
1826
+ state.selection.selectedIds.length > 1 && state.selection.selectedIds.includes(nodeId),
1827
+ )
1828
+ const selectedLevelId = useViewer((state) => state.selection.levelId)
1829
+ const selectionProxyId = resolveSelectionProxyId(
1830
+ node,
1831
+ nodes as Record<string, AnyNode | undefined>,
1832
+ )
1833
+ const hovered = useViewer((state) => state.hoveredId === selectionProxyId)
1834
+ const setHoveredId = useViewer((state) => state.setHoveredId)
1835
+ const referencedAnnotationRole = useViewer((state) =>
1836
+ floorplanEntryReferencedAnnotationRole(node, new Set(state.selection.selectedIds)),
1837
+ )
1838
+ const activeRotateNodeId = useDirectManipulationFeedback((state) =>
1839
+ state.activeRotateNodeId === nodeId ? nodeId : null,
1840
+ )
1841
+ const groupMoveCursor =
1842
+ suppressHandles && classifyParticipant(node, selectedLevelId, nodes) !== null
1843
+ const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
1844
+ const liveOverride = useLiveNodeOverrides((s) =>
1845
+ floorplanVisible ? s.overrides.get(nodeId) : undefined,
1846
+ )
1847
+ const liveOverrides = floorplanVisible
1848
+ ? useLiveNodeOverrides.getState().overrides
1849
+ : EMPTY_LIVE_OVERRIDES
1850
+ const presentationVisibility = resolveFloorplanAnnotationVisibility(
1851
+ floorplanMode,
1852
+ annotationVisibility,
1853
+ {
1854
+ referencedAnnotationRole,
1855
+ selected,
1856
+ target: 'editor',
1857
+ },
1858
+ )
1859
+
1860
+ const handlePointerDown = useCallback(
1861
+ (event: ReactPointerEvent<SVGGElement>) => onEntryPointerDown(nodeId, event),
1862
+ [nodeId, onEntryPointerDown],
1863
+ )
1864
+
1865
+ // Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
1866
+ // the highlight halo in 3D as well as registry floor-plan hover strokes.
1867
+ const handlePointerEnter = useCallback(() => {
1868
+ const currentNode = useScene.getState().nodes[nodeId]
1869
+ setHoveredId(
1870
+ currentNode
1871
+ ? resolveSelectionProxyId(
1872
+ currentNode,
1873
+ useScene.getState().nodes as Record<string, AnyNode | undefined>,
1874
+ )
1875
+ : nodeId,
1876
+ )
1877
+ }, [nodeId, setHoveredId])
1878
+
1879
+ const handlePointerLeave = useCallback(() => {
1880
+ const node = useScene.getState().nodes[nodeId]
1881
+ const targetId = node
1882
+ ? resolveSelectionProxyId(
1883
+ node,
1884
+ useScene.getState().nodes as Record<string, AnyNode | undefined>,
1885
+ )
1886
+ : nodeId
1887
+ if (useViewer.getState().hoveredId === targetId) setHoveredId(null)
1888
+ }, [nodeId, setHoveredId])
1889
+
1890
+ const handleHandlePointerDown = useCallback(
1891
+ (
1892
+ affordance: string,
1893
+ payload: unknown,
1894
+ event: ReactPointerEvent<SVGGElement>,
1895
+ rotationPivot?: FloorplanPoint,
1896
+ ) => {
1897
+ onHandlePointerDown(
1898
+ nodeId,
1899
+ makeHandleId(nodeId, payload),
1900
+ affordance,
1901
+ payload,
1902
+ event,
1903
+ rotationPivot,
1904
+ )
1905
+ },
1906
+ [nodeId, onHandlePointerDown],
1907
+ )
1908
+
1909
+ const handleHandleDoubleClick = useCallback(
1910
+ (affordance: string, payload: unknown, event: ReactMouseEvent<SVGElement>) => {
1911
+ onHandleDoubleClick(nodeId, affordance, payload, event)
1912
+ },
1913
+ [nodeId, onHandleDoubleClick],
1914
+ )
1915
+
1916
+ const handleMoveHandlePointerDown = useCallback(
1917
+ (event: ReactPointerEvent<SVGGElement>) => {
1918
+ if (event.button !== 0) return
1919
+ const currentNode = useScene.getState().nodes[nodeId]
1920
+ if (!currentNode) return
1921
+ event.preventDefault()
1922
+ event.stopPropagation()
1923
+ suppressBoxSelectForPointer(event)
1924
+ // In a multi-selection the move dot drives the group session, matching
1925
+ // the body-drag gesture — the whole selection slides, not one member.
1926
+ if (onGroupMovePointerDown(nodeId, event)) return
1927
+ sfxEmitter.emit('sfx:item-pick')
1928
+ setMovingNode(currentNode as never)
1929
+ // Claim 2D ownership of this move at the source. `setMovingNode`
1930
+ // resets the origin to null, so this must follow it.
1931
+ setMovingNodeOrigin('2d')
1932
+ },
1933
+ [nodeId, onGroupMovePointerDown, setMovingNode, setMovingNodeOrigin],
1934
+ )
1935
+
1936
+ const cacheEntry = buildFloorplanEntryGeometry({
1937
+ automaticDimensions: presentationVisibility.automaticDimensions,
1938
+ ctxOverrides,
1939
+ geometryCache: geometryCacheRef.current,
1940
+ highlighted,
1941
+ hovered,
1942
+ interactiveElevators,
1943
+ levelDataCache: levelDataCacheRef.current,
1944
+ levelNodeIdsByType,
1945
+ live,
1946
+ liveOverride,
1947
+ liveOverrides,
1948
+ moving,
1949
+ node,
1950
+ nodeId,
1951
+ nodes,
1952
+ palette,
1953
+ selected,
1954
+ siblingEpoch,
1955
+ unit,
1956
+ metricNotation,
1957
+ wallDimensionReference,
1958
+ visibilityRootId,
1959
+ })
1960
+ const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
1961
+ const visibleGeometry = rawGeometry
1962
+ ? filterFloorplanAnnotationGeometry(rawGeometry, presentationVisibility)
1963
+ : null
1964
+ // Multi-selection shows highlight only: strip this member's edit handles /
1965
+ // dimension chrome (all of which live in the overlay pass) while keeping
1966
+ // its highlighted body geometry.
1967
+ const geometry =
1968
+ visibleGeometry && suppressHandles && pass === 'overlay'
1969
+ ? stripHandleChrome(visibleGeometry)
1970
+ : visibleGeometry
1971
+ if (!geometry) return null
1972
+
1973
+ const entryClick = isMarqueeSelectionActive ? undefined : onClickStop
1974
+ const entryPointerDown = isMarqueeSelectionActive ? undefined : handlePointerDown
1975
+
1976
+ return (
1977
+ <g
1978
+ className="floorplan-registry-entry"
1979
+ data-node-id={nodeId}
1980
+ onClick={entryClick}
1981
+ onPointerDown={entryPointerDown}
1982
+ onPointerEnter={handlePointerEnter}
1983
+ onPointerLeave={handlePointerLeave}
1984
+ style={groupMoveCursor ? MOVE_CURSOR_STYLE : POINTER_CURSOR_STYLE}
1985
+ >
1986
+ <InteractiveGeometry
1987
+ activeDragId={activeDragId}
1988
+ activeRotateNodeId={activeRotateNodeId}
1989
+ geometry={geometry}
1990
+ hatchPatternId={hatchPatternId}
1991
+ hoveredHandleId={hoveredHandleId}
1992
+ isMarqueeSelectionActive={isMarqueeSelectionActive}
1993
+ nodeId={nodeId}
1994
+ onHandleDoubleClick={handleHandleDoubleClick}
1995
+ onHandleHoverChange={onHandleHoverChange}
1996
+ onHandlePointerDown={handleHandlePointerDown}
1997
+ onMoveHandlePointerDown={handleMoveHandlePointerDown}
1998
+ palette={palette}
1999
+ sceneRotationDeg={sceneRotationDeg}
2000
+ />
2001
+ </g>
2002
+ )
2003
+ }, shallowPropsAreEqual)
2004
+
2005
+ type BuildFloorplanEntryGeometryArgs = {
2006
+ automaticDimensions: boolean
2007
+ ctxOverrides: FloorplanContextOverrides | undefined
2008
+ geometryCache: Map<string, CacheEntry>
2009
+ highlighted: boolean
2010
+ hovered: boolean
2011
+ interactiveElevators: unknown
2012
+ levelDataCache: Map<string, LevelDataCacheEntry>
2013
+ levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
2014
+ live: LiveTransform | undefined
2015
+ liveOverride: LiveNodeOverrides | undefined
2016
+ liveOverrides: Map<string, LiveNodeOverrides>
2017
+ moving: boolean
2018
+ node: AnyNode
2019
+ nodeId: AnyNodeId
2020
+ nodes: Record<string, AnyNode>
2021
+ palette: FloorplanPalette | undefined
2022
+ selected: boolean
2023
+ siblingEpoch: number
2024
+ unit: 'metric' | 'imperial'
2025
+ metricNotation: 'meters' | 'millimeters'
2026
+ wallDimensionReference: FloorplanWallDimensionReference
2027
+ visibilityRootId: AnyNodeId | undefined
2028
+ }
2029
+
2030
+ export function collectFloorplanDependencyNodes(
2031
+ def: AnyNodeDefinition,
2032
+ node: AnyNode,
2033
+ nodes: Record<string, AnyNode>,
2034
+ liveOverrides?: Map<string, LiveNodeOverrides>,
2035
+ ): AnyNode[] {
2036
+ return (def.floorplanDependencies?.(node) ?? []).flatMap((id) => {
2037
+ const dependency = nodes[id]
2038
+ if (!dependency) return []
2039
+ const dependencyOverride = liveOverrides?.get(dependency.id)
2040
+ const effectiveDependency = dependencyOverride
2041
+ ? ({ ...dependency, ...dependencyOverride } as AnyNode)
2042
+ : dependency
2043
+ const parent = dependency.parentId ? nodes[dependency.parentId] : undefined
2044
+ if (!parent) return [effectiveDependency]
2045
+ const parentOverride = liveOverrides?.get(parent.id)
2046
+ const effectiveParent = parentOverride ? ({ ...parent, ...parentOverride } as AnyNode) : parent
2047
+ return [effectiveDependency, effectiveParent]
2048
+ })
2049
+ }
2050
+
2051
+ function floorplanEntryReferencedAnnotationRole(
2052
+ node: AnyNode,
2053
+ selectedIds: ReadonlySet<string>,
2054
+ ): FloorplanAnnotationRole | undefined {
2055
+ if (selectedIds.size === 0) return undefined
2056
+ const definition = nodeRegistry.get(node.type)
2057
+ const role = getFloorplanNodeExtension(definition)?.referencedSelectionAnnotationRole
2058
+ if (!role) return undefined
2059
+ const dependencyIds = definition?.floorplanDependencies?.(node) ?? []
2060
+ return dependencyIds.some((id) => selectedIds.has(id)) ? role : undefined
2061
+ }
2062
+
2063
+ function buildFloorplanEntryGeometry({
2064
+ automaticDimensions,
2065
+ ctxOverrides,
2066
+ geometryCache,
2067
+ highlighted,
2068
+ hovered,
2069
+ interactiveElevators,
2070
+ levelDataCache,
2071
+ levelNodeIdsByType,
2072
+ live,
2073
+ liveOverride,
2074
+ liveOverrides,
2075
+ moving,
2076
+ node,
2077
+ nodeId,
2078
+ nodes,
2079
+ palette,
2080
+ selected,
2081
+ siblingEpoch,
2082
+ unit,
2083
+ metricNotation,
2084
+ wallDimensionReference,
2085
+ visibilityRootId,
2086
+ }: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
2087
+ const def = nodeRegistry.get(node.type)
2088
+ const builder = def?.floorplan
2089
+ if (!builder) return null
2090
+
2091
+ const visible = visibilityRootId
2092
+ ? isFloorplanHierarchyVisible(node, nodes, liveOverrides, visibilityRootId)
2093
+ : isFloorplanNodeVisible(node, liveOverride)
2094
+ if (!visible) {
2095
+ geometryCache.delete(nodeId)
2096
+ return null
2097
+ }
2098
+
2099
+ const dependsOnSiblingInputs = !!(
2100
+ def.floorplanDependsOnSiblings ||
2101
+ def.floorplanSiblingOverrides ||
2102
+ def.floorplanAffectedIds
2103
+ )
2104
+ const dependencyNodes = collectFloorplanDependencyNodes(def, node, nodes, liveOverrides)
2105
+ const deps: NodeDeps = {
2106
+ automaticDimensions,
2107
+ node,
2108
+ live,
2109
+ unit,
2110
+ metricNotation,
2111
+ wallDimensionReference,
2112
+ selected,
2113
+ highlighted,
2114
+ hovered,
2115
+ moving,
2116
+ liveOverride,
2117
+ palette,
2118
+ siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
2119
+ // Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
2120
+ // committed state via `ctx`, so committed sibling edits still invalidate.
2121
+ committedNodes: dependsOnSiblingInputs ? nodes : null,
2122
+ dependencyNodes,
2123
+ interactiveElevators,
2124
+ }
2125
+ const cached = geometryCache.get(nodeId)
2126
+ if (cached && nodeDepsEqual(cached.deps, deps)) return cached
2127
+
2128
+ const applyLiveTransform = (sourceNode: AnyNode): AnyNode => {
2129
+ if (!live) return sourceNode
2130
+ const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position)
2131
+ const parentFrameProjection = nodeRegistry.get(sourceNode.type)?.capabilities?.movable
2132
+ ?.parentFrame?.floorplanLiveTransform
2133
+ if (parentFrameProjection) {
2134
+ return parentFrameProjection({ node: sourceNode, live })
2135
+ }
2136
+ if (sourceNode.type === 'door' || sourceNode.type === 'window') {
2137
+ const r = (sourceNode as { rotation?: unknown }).rotation
2138
+ return {
2139
+ ...sourceNode,
2140
+ position: live.position,
2141
+ rotation: Array.isArray(r)
2142
+ ? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
2143
+ : r,
2144
+ } as AnyNode
2145
+ }
2146
+ if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) {
2147
+ return applyPositionLiveTransform(sourceNode, live)
2148
+ }
2149
+ if (sourceNode.type === 'slab' || sourceNode.type === 'ceiling' || sourceNode.type === 'zone') {
2150
+ const dx = live.position[0]
2151
+ const dz = live.position[2]
2152
+ if (dx === 0 && dz === 0) return sourceNode
2153
+ const surface = sourceNode as {
2154
+ polygon: Array<[number, number]>
2155
+ holes?: Array<Array<[number, number]>>
2156
+ }
2157
+ return {
2158
+ ...sourceNode,
2159
+ polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
2160
+ holes: (surface.holes ?? []).map((h) =>
2161
+ h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
2162
+ ),
2163
+ } as AnyNode
2164
+ }
2165
+ return sourceNode
2166
+ }
2167
+
2168
+ const contextNodes = def.floorplanSiblingOverrides
2169
+ ? def.floorplanSiblingOverrides({
2170
+ nodeId,
2171
+ nodes,
2172
+ liveTransforms: useLiveTransforms.getState().transforms,
2173
+ liveOverrides,
2174
+ })
2175
+ : nodes
2176
+ const sourceNode = contextNodes !== nodes ? (contextNodes[nodeId] ?? node) : node
2177
+ const overrideNode = liveOverride ? ({ ...sourceNode, ...liveOverride } as AnyNode) : sourceNode
2178
+ const effectiveNode = applyLiveTransform(overrideNode)
2179
+ const levelData = getFloorplanLevelData(
2180
+ node.type,
2181
+ nodes,
2182
+ liveOverrides,
2183
+ levelNodeIdsByType,
2184
+ levelDataCache,
2185
+ )
2186
+ const viewState = {
2187
+ automaticDimensions,
2188
+ selected,
2189
+ unit,
2190
+ metricNotation,
2191
+ wallDimensionReference,
2192
+ highlighted,
2193
+ hovered,
2194
+ moving,
2195
+ palette,
2196
+ }
2197
+ const resolveContextNode = <N = AnyNode>(rid: AnyNodeId): N | undefined => {
2198
+ const contextNode = contextNodes[rid]
2199
+ if (!contextNode) return undefined
2200
+ const contextOverride = liveOverrides.get(contextNode.id)
2201
+ return (contextOverride ? { ...contextNode, ...contextOverride } : contextNode) as N
2202
+ }
2203
+ const ctx: GeometryContext = ctxOverrides
2204
+ ? {
2205
+ resolve: resolveContextNode,
2206
+ children: ctxOverrides.children,
2207
+ siblings: ctxOverrides.siblings,
2208
+ parent: ctxOverrides.parent,
2209
+ levelData,
2210
+ extensions: createFloorplanContextExtensions({
2211
+ automaticDimensions,
2212
+ metricNotation,
2213
+ purpose: 'edit',
2214
+ wallDimensionReference,
2215
+ }),
2216
+ viewState: palette
2217
+ ? {
2218
+ selected,
2219
+ unit,
2220
+ highlighted,
2221
+ hovered,
2222
+ moving,
2223
+ palette,
2224
+ }
2225
+ : undefined,
708
2226
  }
709
- // Clear any alignment guide a session left behind on mid-drag unmount.
710
- useAlignmentGuides.getState().clear()
711
- }
712
- }, [])
713
-
714
- if (entries.length === 0) return null
715
-
716
- const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1
717
- const palette = renderCtx?.palette
718
-
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)
2227
+ : {
2228
+ ...buildContext(effectiveNode, contextNodes, viewState, levelData),
2229
+ resolve: resolveContextNode,
729
2230
  }
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>
2231
+ const modelGeometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
2232
+ effectiveNode,
2233
+ ctx,
778
2234
  )
2235
+ const contextualGeometry = selected
2236
+ ? (getFloorplanNodeExtension(def)?.contextualDimensions?.(effectiveNode, ctx) ?? null)
2237
+ : null
2238
+ const taggedContextualGeometry = withFloorplanGeometryMetadata(contextualGeometry, {
2239
+ annotationRole: 'contextual-dimension',
2240
+ })
2241
+ const geometry =
2242
+ modelGeometry && taggedContextualGeometry
2243
+ ? { kind: 'group' as const, children: [modelGeometry, taggedContextualGeometry] }
2244
+ : (modelGeometry ?? taggedContextualGeometry)
2245
+ const { base, overlay } = geometry
2246
+ ? splitFloorplanOverlay(geometry)
2247
+ : { base: null, overlay: null }
2248
+ const entry: CacheEntry = { deps, base, overlay, node: effectiveNode }
2249
+ geometryCache.set(nodeId, entry)
2250
+ return entry
2251
+ }
779
2252
 
780
- return (
781
- // The outer wrapper stops `click` events that escape an entry's
782
- // `onClick={handleClickStop}`. The base+overlay split means
783
- // pointer-down can land on the base `<g>` and pointer-up on the
784
- // overlay `<g>` (selection mounts the overlay on top mid-gesture).
785
- // When the down/up targets differ, the browser dispatches `click`
786
- // to the lowest common ancestor — which sits ABOVE the entry-level
787
- // handler. Without this guard the click reaches the SVG's
788
- // `handleBackgroundClick`, which calls
789
- // `resolveFloorplanBackgroundSelection` `clear-elements` (because
790
- // registry-driven items aren't in the legacy hit-test set) →
791
- // clearing the selection that pointer-down just set, so items
792
- // appear to "deselect themselves a fraction of a second after
793
- // clicking." Scoped to `onClick` so hover / drag / pointer events
794
- // still propagate normally inside the registry tree.
795
- <g
796
- className="floorplan-registry-layer"
797
- onClick={isOpeningPlacementActive ? undefined : handleClickStop}
798
- opacity={isAmbient ? 0.3 : undefined}
799
- style={isAmbient ? { pointerEvents: 'none' } : undefined}
800
- >
801
- {/* Base pass — rank-sorted body geometry (polygons, paths, fills,
802
- strokes, hatches). Lower-rank kinds (zones) paint first so
803
- higher-rank kinds (slabs, then walls / items / shelves) layer
804
- on top in the expected document-order z-stack. */}
805
- <g className="floorplan-registry-base">
806
- {entries.map(({ id, base }) => (base ? renderEntry(id, base, `base-${id}`) : null))}
807
- </g>
808
- {/* Overlay pass — interactive handles (vertex / midpoint / edge /
809
- move) and labels (text / dimensions). Painted after every base
810
- entry so polygon-editor chrome on a selected slab stays above
811
- neighbouring walls, and a zone name stays readable above the
812
- slab + wall geometry sitting on top of the zone. Each overlay
813
- still routes through the same selection-handling `<g>` so a
814
- click on a zone's name selects the zone. */}
815
- <g className="floorplan-registry-overlay">
816
- {entries.map(({ id, overlay }) =>
817
- overlay ? renderEntry(id, overlay, `overlay-${id}`) : null,
818
- )}
819
- </g>
820
- {/* Transient live-rotation readout — drawn last so the wedge + degree
821
- chip sit above all handle chrome while a rotate-arrow is dragged. */}
822
- {rotationOverlay && palette ? (
823
- <RotationAngleOverlay
824
- overlay={rotationOverlay}
825
- palette={palette}
826
- sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
827
- unitsPerPixel={unitsPerPixel}
828
- />
829
- ) : null}
830
- </g>
831
- )
832
- })
2253
+ export function getFloorplanLevelData(
2254
+ type: string,
2255
+ nodes: Record<string, AnyNode>,
2256
+ liveOverrides: Map<string, LiveNodeOverrides>,
2257
+ levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>,
2258
+ levelDataCache: Map<string, LevelDataCacheEntry>,
2259
+ ): unknown {
2260
+ const def = nodeRegistry.get(type)
2261
+ if (!def?.computeFloorplanLevelData) return undefined
2262
+ const ids = levelNodeIdsByType.get(type)
2263
+ const sampleId = ids?.[0]
2264
+ if (!ids || !sampleId) return undefined
2265
+
2266
+ const cached = levelDataCache.get(type)
2267
+ if (
2268
+ cached &&
2269
+ cached.nodes === nodes &&
2270
+ cached.liveOverrides === liveOverrides &&
2271
+ cached.ids === ids
2272
+ ) {
2273
+ return cached.value
2274
+ }
2275
+
2276
+ const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook
2277
+ const contextNodes = def.floorplanSiblingOverrides
2278
+ ? def.floorplanSiblingOverrides({
2279
+ nodeId: sampleId,
2280
+ nodes,
2281
+ liveTransforms: useLiveTransforms.getState().transforms,
2282
+ liveOverrides,
2283
+ })
2284
+ : nodes
2285
+ const siblings: AnyNode[] = []
2286
+ for (const id of ids) {
2287
+ const sibling = contextNodes[id]
2288
+ if (sibling?.type === type) siblings.push(sibling)
2289
+ }
2290
+ const value = computeLevelData({ siblings, nodes: contextNodes })
2291
+ levelDataCache.set(type, { nodes, liveOverrides, ids, value })
2292
+ return value
2293
+ }
833
2294
 
834
2295
  // ── Interactive geometry walker ──────────────────────────────────────
835
2296
 
836
- function InteractiveGeometry({
837
- geometry,
838
- unitsPerPixel,
839
- palette,
840
- hatchPatternId,
841
- hoveredHandleId,
842
- activeDragId,
843
- isMarqueeSelectionActive,
844
- nodeId,
845
- sceneRotationDeg,
846
- onHandleHoverChange,
847
- onHandlePointerDown,
848
- onMoveHandlePointerDown,
849
- }: {
2297
+ type InteractiveGeometryProps = {
850
2298
  geometry: FloorplanGeometry
851
- unitsPerPixel: number
2299
+ unitsPerPixel?: number
852
2300
  palette: FloorplanPalette | undefined
853
2301
  hatchPatternId: string | undefined
854
2302
  hoveredHandleId: string | null
855
2303
  activeDragId: string | null
2304
+ activeRotateNodeId: AnyNodeId | null
856
2305
  isMarqueeSelectionActive: boolean
857
2306
  nodeId: AnyNodeId
858
2307
  sceneRotationDeg: number
859
2308
  onHandleHoverChange: (id: string | null) => void
2309
+ onHandleDoubleClick: (
2310
+ affordance: string,
2311
+ payload: unknown,
2312
+ event: ReactMouseEvent<SVGElement>,
2313
+ ) => void
860
2314
  onHandlePointerDown: (
861
2315
  affordance: string,
862
2316
  payload: unknown,
@@ -866,7 +2320,30 @@ function InteractiveGeometry({
866
2320
  rotationPivot?: FloorplanPoint,
867
2321
  ) => void
868
2322
  onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
869
- }): React.ReactElement {
2323
+ }
2324
+
2325
+ export const InteractiveGeometry = memo(function InteractiveGeometry({
2326
+ geometry,
2327
+ unitsPerPixel: unitsPerPixelOverride,
2328
+ palette,
2329
+ hatchPatternId,
2330
+ hoveredHandleId,
2331
+ activeDragId,
2332
+ activeRotateNodeId,
2333
+ isMarqueeSelectionActive,
2334
+ nodeId,
2335
+ sceneRotationDeg,
2336
+ onHandleDoubleClick,
2337
+ onHandleHoverChange,
2338
+ onHandlePointerDown,
2339
+ onMoveHandlePointerDown,
2340
+ }: InteractiveGeometryProps): React.ReactElement {
2341
+ const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel()
2342
+ const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel
2343
+ // Keep handles pixel-sized through normal navigation, then let them shrink
2344
+ // with the plan once zoom compensation would make them dominate the geometry.
2345
+ const handleUnitsPerPixel = resolveFloorplanHandleUnitsPerPixel(unitsPerPixel)
2346
+
870
2347
  return renderInteractive(geometry, 0)
871
2348
 
872
2349
  function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement {
@@ -874,7 +2351,14 @@ function InteractiveGeometry({
874
2351
  case 'group': {
875
2352
  const transform = formatGroupTransform(g.transform)
876
2353
  return (
877
- <g key={keyHint} transform={transform}>
2354
+ <g
2355
+ data-floorplan-annotation-obstacle={
2356
+ floorplanAnnotationObstacleMode(g) ??
2357
+ (isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined)
2358
+ }
2359
+ key={keyHint}
2360
+ transform={transform}
2361
+ >
878
2362
  {g.children.map((child, i) => renderInteractive(child, i))}
879
2363
  </g>
880
2364
  )
@@ -911,6 +2395,7 @@ function InteractiveGeometry({
911
2395
  case 'endpoint-handle': {
912
2396
  if (!palette) return <></>
913
2397
  const handleId = makeHandleId(nodeId, g.payload)
2398
+ const doubleClickAffordance = floorplanHandleDoubleClickAffordance(g)
914
2399
  const isHovered = hoveredHandleId === handleId
915
2400
  const isActive = activeDragId === handleId
916
2401
  // Variant picks the colour-set. Endpoint dots use the orange
@@ -934,10 +2419,10 @@ function InteractiveGeometry({
934
2419
  : palette.endpointHandleFill
935
2420
  const outerRadius =
936
2421
  (isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) *
937
- unitsPerPixel
2422
+ handleUnitsPerPixel
938
2423
  const dotRadius =
939
2424
  (isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) *
940
- unitsPerPixel
2425
+ handleUnitsPerPixel
941
2426
  return (
942
2427
  <g
943
2428
  key={keyHint}
@@ -953,7 +2438,7 @@ function InteractiveGeometry({
953
2438
  r={outerRadius}
954
2439
  stroke={hoverStroke}
955
2440
  strokeOpacity={isActive ? 0.24 : 0.16}
956
- strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * unitsPerPixel}
2441
+ strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * handleUnitsPerPixel}
957
2442
  style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
958
2443
  vectorEffect="non-scaling-stroke"
959
2444
  />
@@ -965,7 +2450,7 @@ function InteractiveGeometry({
965
2450
  r={outerRadius}
966
2451
  stroke={hoverStroke}
967
2452
  strokeOpacity={isActive ? 0.72 : 0.52}
968
- strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
2453
+ strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel}
969
2454
  style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
970
2455
  vectorEffect="non-scaling-stroke"
971
2456
  />
@@ -992,6 +2477,15 @@ function InteractiveGeometry({
992
2477
  cx={g.point[0]}
993
2478
  cy={g.point[1]}
994
2479
  fill="transparent"
2480
+ onDoubleClick={
2481
+ doubleClickAffordance
2482
+ ? (event) => {
2483
+ event.preventDefault()
2484
+ event.stopPropagation()
2485
+ onHandleDoubleClick(doubleClickAffordance, g.payload, event)
2486
+ }
2487
+ : undefined
2488
+ }
995
2489
  onPointerDown={(e) =>
996
2490
  onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
997
2491
  }
@@ -1095,7 +2589,7 @@ function InteractiveGeometry({
1095
2589
  // each end pointing tangentially in opposite directions —
1096
2590
  // "rotate either way."
1097
2591
  const handleId = makeHandleId(nodeId, g.payload)
1098
- const isHovered = hoveredHandleId === handleId
2592
+ const isHovered = hoveredHandleId === handleId || activeRotateNodeId === nodeId
1099
2593
  // Arc geometry (all values precomputed for a 72° arc of
1100
2594
  // radius 0.13 — comparable footprint to `move-arrow`).
1101
2595
  const R = 0.13
@@ -1317,8 +2811,8 @@ function InteractiveGeometry({
1317
2811
  // Slightly smaller than endpoint dots; hover-expanded.
1318
2812
  const baseRadiusPx = 6
1319
2813
  const hoverRadiusPx = 8
1320
- const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel
1321
- const plusHalf = 3 * unitsPerPixel
2814
+ const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * handleUnitsPerPixel
2815
+ const plusHalf = 3 * handleUnitsPerPixel
1322
2816
  return (
1323
2817
  <g
1324
2818
  key={keyHint}
@@ -1331,10 +2825,10 @@ function InteractiveGeometry({
1331
2825
  cy={g.point[1]}
1332
2826
  fill="none"
1333
2827
  pointerEvents="none"
1334
- r={radius + 2 * unitsPerPixel}
2828
+ r={radius + 2 * handleUnitsPerPixel}
1335
2829
  stroke={hoverStroke}
1336
2830
  strokeOpacity={0.16}
1337
- strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
2831
+ strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel}
1338
2832
  style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
1339
2833
  vectorEffect="non-scaling-stroke"
1340
2834
  />
@@ -1402,35 +2896,106 @@ function InteractiveGeometry({
1402
2896
  // and flip by 180° if it falls outside (-90, 90] — that keeps
1403
2897
  // text reading left-to-right, top-to-bottom regardless of the
1404
2898
  // building's orientation.
2899
+ const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
2900
+
2901
+ const labelUnitsPerPixel = Math.max(unitsPerPixel, 1e-6)
2902
+ const outlined = g.appearance === 'outlined'
2903
+ const padX = labelUnitsPerPixel * 6
2904
+ const padY = labelUnitsPerPixel * 3
2905
+ const fontSize = labelUnitsPerPixel * (outlined ? 12 : 10)
2906
+ // Rough text width approximation — SVG can't measure text without
2907
+ // the DOM. 6.2px per char at 10px font keeps the plate visually
2908
+ // balanced for the short length strings ("3.24m", "1'2\"", etc.).
2909
+ const textWidth = g.text.length * labelUnitsPerPixel * 6.2
2910
+ const plateW = textWidth + padX * 2
2911
+ const plateH = fontSize + padY * 2
2912
+ const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`
2913
+ return (
2914
+ <g
2915
+ data-floorplan-annotation-angle-radians={g.angle}
2916
+ data-floorplan-annotation-default-transform={labelTransform}
2917
+ data-floorplan-annotation-label=""
2918
+ data-floorplan-annotation-priority="20"
2919
+ data-floorplan-annotation-screen-upright={g.screenUpright ? 'true' : undefined}
2920
+ data-floorplan-annotation-transform-after-rotation={`translate(0 ${
2921
+ -(g.offsetPx ?? 0) * labelUnitsPerPixel
2922
+ })`}
2923
+ data-floorplan-annotation-transform-before-rotation={`translate(${g.cx} ${g.cy})`}
2924
+ key={keyHint}
2925
+ pointerEvents="none"
2926
+ transform={labelTransform}
2927
+ >
2928
+ {outlined ? null : (
2929
+ <rect
2930
+ fill={palette.measurementLabelBackground}
2931
+ height={plateH}
2932
+ opacity={0.92}
2933
+ rx={labelUnitsPerPixel * 3}
2934
+ ry={labelUnitsPerPixel * 3}
2935
+ stroke={palette.measurementStroke}
2936
+ strokeWidth={labelUnitsPerPixel * 0.5}
2937
+ vectorEffect="non-scaling-stroke"
2938
+ width={plateW}
2939
+ x={-plateW / 2}
2940
+ y={-plateH / 2}
2941
+ />
2942
+ )}
2943
+ <text
2944
+ dominantBaseline="middle"
2945
+ fill={outlined ? '#ffffff' : palette.measurementLabelText}
2946
+ fontFamily={
2947
+ outlined
2948
+ ? 'system-ui, -apple-system, sans-serif'
2949
+ : 'ui-monospace, SFMono-Regular, Menlo, monospace'
2950
+ }
2951
+ fontSize={fontSize}
2952
+ fontWeight={outlined ? 500 : 600}
2953
+ paintOrder={outlined ? 'stroke' : undefined}
2954
+ stroke={outlined ? palette.measurementStroke : undefined}
2955
+ strokeLinecap={outlined ? 'round' : undefined}
2956
+ strokeLinejoin={outlined ? 'round' : undefined}
2957
+ strokeWidth={outlined ? fontSize * 0.35 : undefined}
2958
+ textAnchor="middle"
2959
+ x={0}
2960
+ y={0}
2961
+ >
2962
+ {g.text}
2963
+ </text>
2964
+ </g>
2965
+ )
2966
+ }
2967
+ case 'equal-spacing-badge': {
2968
+ // A distinct accent (Figma-style "=" rhythm) so equal spacing reads
2969
+ // apart from the orange placement dimensions. Same screen-upright flip
2970
+ // as the dimension-label case above.
2971
+ const accent = '#ec4899'
1405
2972
  let degrees = (g.angle * 180) / Math.PI
1406
2973
  let screenDegrees = degrees + sceneRotationDeg
1407
2974
  screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
1408
2975
  if (screenDegrees > 90) degrees -= 180
1409
2976
  else if (screenDegrees <= -90) degrees += 180
1410
2977
 
2978
+ const label = `= ${g.text}`
1411
2979
  const padX = unitsPerPixel * 6
1412
2980
  const padY = unitsPerPixel * 3
1413
2981
  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
2982
+ const textWidth = label.length * unitsPerPixel * 6.2
1418
2983
  const plateW = textWidth + padX * 2
1419
2984
  const plateH = fontSize + padY * 2
1420
2985
  return (
1421
2986
  <g
1422
2987
  key={keyHint}
1423
2988
  pointerEvents="none"
1424
- transform={`translate(${g.cx} ${g.cy}) rotate(${degrees})`}
2989
+ transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${degrees})`}
1425
2990
  >
1426
2991
  <rect
1427
- fill={palette.measurementLabelBackground}
2992
+ fill="#ffffff"
1428
2993
  height={plateH}
1429
- opacity={0.92}
2994
+ opacity={0.95}
1430
2995
  rx={unitsPerPixel * 3}
1431
2996
  ry={unitsPerPixel * 3}
1432
- stroke={palette.measurementStroke}
1433
- strokeWidth={unitsPerPixel * 0.5}
2997
+ stroke={accent}
2998
+ strokeWidth={unitsPerPixel * 0.75}
1434
2999
  vectorEffect="non-scaling-stroke"
1435
3000
  width={plateW}
1436
3001
  x={-plateW / 2}
@@ -1438,162 +3003,28 @@ function InteractiveGeometry({
1438
3003
  />
1439
3004
  <text
1440
3005
  dominantBaseline="middle"
1441
- fill={palette.measurementLabelText}
3006
+ fill={accent}
1442
3007
  fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
1443
3008
  fontSize={fontSize}
1444
- fontWeight={600}
3009
+ fontWeight={700}
1445
3010
  textAnchor="middle"
1446
3011
  x={0}
1447
3012
  y={0}
1448
3013
  >
1449
- {g.text}
3014
+ {label}
1450
3015
  </text>
1451
3016
  </g>
1452
3017
  )
1453
3018
  }
1454
3019
  case 'dimension': {
1455
3020
  if (!palette) return <></>
1456
- const stroke = g.stroke ?? palette.measurementStroke
1457
- // Offset endpoints along the outward normal — this is where the
1458
- // dimension line sits, parallel to the edge.
1459
- const ox = g.offsetNormal[0] * g.offsetDistance
1460
- const oy = g.offsetNormal[1] * g.offsetDistance
1461
- const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy]
1462
- const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy]
1463
-
1464
- // Extension line endpoints — extend past the dimension line by
1465
- // `extensionOvershoot` so the tip clears the dimension stroke.
1466
- const eOvershoot = g.extensionOvershoot
1467
- const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot)
1468
- const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot)
1469
- const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy]
1470
- const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy]
1471
-
1472
- const dx = dEnd[0] - dStart[0]
1473
- const dy = dEnd[1] - dStart[1]
1474
- const length = Math.hypot(dx, dy)
1475
- if (length < 1e-6) return <></>
1476
- const dirX = dx / length
1477
- const dirY = dy / length
1478
-
1479
- // Plan-unit constants matching the legacy `floorplan-
1480
- // measurements-layer.tsx`. `strokeWidth` is intentionally a
1481
- // raw value (not multiplied by `unitsPerPixel`) because every
1482
- // stroke here uses `vectorEffect: non-scaling-stroke` — the
1483
- // browser interprets it as screen-pixel-stable. Multiplying
1484
- // by `unitsPerPixel` would shrink the strokes by ~100× and
1485
- // make them invisible. Tick length, dash pattern, font size,
1486
- // and the label gap stay in plan units (they're geometry,
1487
- // not stroke width).
1488
- const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2
1489
- const perpX = -dirY * tickHalf
1490
- const perpY = dirX * tickHalf
1491
-
1492
- const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE
1493
- const labelGap = 0.5 // plan units — gap in the dimension line for the label
1494
- const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04)
1495
-
1496
- const midX = (dStart[0] + dEnd[0]) / 2
1497
- const midY = (dStart[1] + dEnd[1]) / 2
1498
- const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf]
1499
- const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf]
1500
-
1501
- // Keep the label parallel to the dimension line, but decide the
1502
- // 180° flip from the on-SCREEN angle, not the local one. The parent
1503
- // `<g>` is rotated by `sceneRotationDeg` (default 90° in the floor
1504
- // plan), so a label kept upright in local coords still renders
1505
- // upside down for half of the wall orientations. Same fix as the
1506
- // `dimension-label` case above.
1507
- let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI
1508
- let screenDeg = labelDeg + sceneRotationDeg
1509
- screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
1510
- if (screenDeg > 90) labelDeg -= 180
1511
- else if (screenDeg <= -90) labelDeg += 180
1512
-
1513
3021
  return (
1514
- <g key={keyHint} pointerEvents="none">
1515
- {/* Extension lines (dashed). */}
1516
- <line
1517
- stroke={stroke}
1518
- strokeDasharray="0.08 0.12"
1519
- strokeLinecap="round"
1520
- strokeOpacity={0.95}
1521
- strokeWidth={1.35}
1522
- vectorEffect="non-scaling-stroke"
1523
- x1={g.start[0]}
1524
- x2={eStartTip[0]}
1525
- y1={g.start[1]}
1526
- y2={eStartTip[1]}
1527
- />
1528
- <line
1529
- stroke={stroke}
1530
- strokeDasharray="0.08 0.12"
1531
- strokeLinecap="round"
1532
- strokeOpacity={0.95}
1533
- strokeWidth={1.35}
1534
- vectorEffect="non-scaling-stroke"
1535
- x1={g.end[0]}
1536
- x2={eEndTip[0]}
1537
- y1={g.end[1]}
1538
- y2={eEndTip[1]}
1539
- />
1540
- {/* Dimension line: two halves with the label in between. */}
1541
- <line
1542
- stroke={stroke}
1543
- strokeLinecap="round"
1544
- strokeWidth={1.35}
1545
- vectorEffect="non-scaling-stroke"
1546
- x1={dStart[0]}
1547
- x2={gapStart[0]}
1548
- y1={dStart[1]}
1549
- y2={gapStart[1]}
1550
- />
1551
- <line
1552
- stroke={stroke}
1553
- strokeLinecap="round"
1554
- strokeWidth={1.35}
1555
- vectorEffect="non-scaling-stroke"
1556
- x1={gapEnd[0]}
1557
- x2={dEnd[0]}
1558
- y1={gapEnd[1]}
1559
- y2={dEnd[1]}
1560
- />
1561
- {/* End ticks. */}
1562
- <line
1563
- stroke={stroke}
1564
- strokeLinecap="round"
1565
- strokeWidth={1.35}
1566
- vectorEffect="non-scaling-stroke"
1567
- x1={dStart[0] - perpX}
1568
- x2={dStart[0] + perpX}
1569
- y1={dStart[1] - perpY}
1570
- y2={dStart[1] + perpY}
1571
- />
1572
- <line
1573
- stroke={stroke}
1574
- strokeLinecap="round"
1575
- strokeWidth={1.35}
1576
- vectorEffect="non-scaling-stroke"
1577
- x1={dEnd[0] - perpX}
1578
- x2={dEnd[0] + perpX}
1579
- y1={dEnd[1] - perpY}
1580
- y2={dEnd[1] + perpY}
1581
- />
1582
- {/* Rotated label centered in the gap. */}
1583
- <text
1584
- dominantBaseline="central"
1585
- fill={stroke}
1586
- fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
1587
- fontSize={fontSize}
1588
- fontWeight={600}
1589
- textAnchor="middle"
1590
- transform={`rotate(${labelDeg} ${midX} ${midY})`}
1591
- x={midX}
1592
- y={midY}
1593
- >
1594
- {g.text}
1595
- </text>
1596
- </g>
3022
+ <FloorplanDimensionRenderer
3023
+ geometry={g}
3024
+ key={keyHint}
3025
+ sceneRotationDeg={sceneRotationDeg}
3026
+ stroke={g.stroke ?? palette.measurementStroke}
3027
+ />
1597
3028
  )
1598
3029
  }
1599
3030
  case 'text': {
@@ -1601,8 +3032,18 @@ function InteractiveGeometry({
1601
3032
  // Counter-rotate by the scene rotation so the label reads
1602
3033
  // horizontally on screen even when the floor-plan view is
1603
3034
  // rotated (default `sceneRotationDeg` is 90°).
3035
+ const beforeRotation = `translate(${g.x} ${g.y})`
3036
+ const transform = `${beforeRotation} rotate(${-sceneRotationDeg})`
1604
3037
  return (
1605
- <g key={keyHint} transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}>
3038
+ <g
3039
+ data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
3040
+ data-floorplan-annotation-angle-radians={0}
3041
+ data-floorplan-annotation-default-transform={transform}
3042
+ data-floorplan-annotation-screen-upright="true"
3043
+ data-floorplan-annotation-transform-before-rotation={beforeRotation}
3044
+ key={keyHint}
3045
+ transform={transform}
3046
+ >
1606
3047
  <text
1607
3048
  dominantBaseline={g.dominantBaseline ?? 'middle'}
1608
3049
  fill={g.fill ?? '#171717'}
@@ -1630,14 +3071,30 @@ function InteractiveGeometry({
1630
3071
  geometry={g}
1631
3072
  key={keyHint}
1632
3073
  pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
3074
+ sceneRotationDeg={sceneRotationDeg}
1633
3075
  />
1634
3076
  )
1635
3077
  }
1636
3078
  }
1637
- }
3079
+ }, shallowPropsAreEqual)
1638
3080
 
1639
3081
  // ── Helpers ──────────────────────────────────────────────────────────
1640
3082
 
3083
+ function shallowPropsAreEqual<T extends object>(a: T, b: T): boolean {
3084
+ const aKeys = Object.keys(a) as Array<keyof T>
3085
+ const bKeys = Object.keys(b) as Array<keyof T>
3086
+ if (aKeys.length !== bKeys.length) return false
3087
+ for (const key of aKeys) {
3088
+ if (!Object.is(a[key], b[key])) return false
3089
+ }
3090
+ return true
3091
+ }
3092
+
3093
+ function handleIdForNode(handleId: string | null, nodeId: AnyNodeId): string | null {
3094
+ if (!handleId) return null
3095
+ return handleId === nodeId || handleId.startsWith(`${nodeId}:`) ? handleId : null
3096
+ }
3097
+
1641
3098
  function applyPositionLiveTransform(
1642
3099
  node: AnyNode,
1643
3100
  live: { position: [number, number, number]; rotation: number },
@@ -1661,16 +3118,48 @@ function applyPositionLiveTransform(
1661
3118
  } as AnyNode
1662
3119
  }
1663
3120
 
1664
- function buildContext(
3121
+ export function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean {
3122
+ const overrideVisible = liveOverride?.visible
3123
+ if (typeof overrideVisible === 'boolean') return overrideVisible
3124
+ return (node as { visible?: boolean }).visible !== false
3125
+ }
3126
+
3127
+ function isFloorplanHierarchyVisible(
3128
+ node: AnyNode,
3129
+ nodes: Record<string, AnyNode>,
3130
+ liveOverrides: Map<string, LiveNodeOverrides>,
3131
+ rootId: AnyNodeId,
3132
+ ): boolean {
3133
+ let current: AnyNode | undefined = node
3134
+ const seen = new Set<AnyNodeId>()
3135
+ while (current) {
3136
+ if (seen.has(current.id)) return true
3137
+ seen.add(current.id)
3138
+ if (!isFloorplanNodeVisible(current, liveOverrides.get(current.id))) return false
3139
+ if (current.id === rootId) return true
3140
+ const parentId = current.parentId as AnyNodeId | null
3141
+ if (!parentId) return true
3142
+ current = nodes[parentId]
3143
+ }
3144
+ return true
3145
+ }
3146
+
3147
+ export function buildContext(
1665
3148
  node: AnyNode,
1666
3149
  nodes: Record<string, AnyNode>,
1667
3150
  viewState: {
3151
+ automaticDimensions?: boolean
1668
3152
  selected: boolean
3153
+ unit: 'metric' | 'imperial'
3154
+ metricNotation?: 'meters' | 'millimeters'
3155
+ purpose?: 'edit' | 'document'
3156
+ wallDimensionReference?: FloorplanWallDimensionReference
1669
3157
  highlighted: boolean
1670
3158
  hovered: boolean
1671
3159
  moving: boolean
1672
3160
  palette: FloorplanPalette | undefined
1673
3161
  },
3162
+ levelData?: unknown,
1674
3163
  ): GeometryContext {
1675
3164
  const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
1676
3165
 
@@ -1703,9 +3192,17 @@ function buildContext(
1703
3192
  children,
1704
3193
  siblings,
1705
3194
  parent,
3195
+ levelData,
3196
+ extensions: createFloorplanContextExtensions({
3197
+ automaticDimensions: viewState.automaticDimensions,
3198
+ metricNotation: viewState.metricNotation ?? 'meters',
3199
+ purpose: viewState.purpose ?? 'edit',
3200
+ wallDimensionReference: viewState.wallDimensionReference,
3201
+ }),
1706
3202
  viewState: viewState.palette
1707
3203
  ? {
1708
3204
  selected: viewState.selected,
3205
+ unit: viewState.unit,
1709
3206
  highlighted: viewState.highlighted,
1710
3207
  hovered: viewState.hovered,
1711
3208
  moving: viewState.moving,
@@ -1715,6 +3212,29 @@ function buildContext(
1715
3212
  }
1716
3213
  }
1717
3214
 
3215
+ export function collectFloorplanLinkedLevelNodes(
3216
+ nodes: Record<string, AnyNode>,
3217
+ levelId: AnyNodeId,
3218
+ excludedIds: ReadonlySet<AnyNodeId> = new Set(),
3219
+ ): Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> {
3220
+ const linked: Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> = []
3221
+ for (const [rawId, node] of Object.entries(nodes)) {
3222
+ if (!node) continue
3223
+ const definition = nodeRegistry.get(node.type)
3224
+ const linkedLevelIds = getFloorplanNodeExtension(definition)?.linkedLevelIds
3225
+ if (!definition?.floorplan || !linkedLevelIds) continue
3226
+ const id = rawId as AnyNodeId
3227
+ if (excludedIds.has(id)) continue
3228
+ if (!linkedLevelIds(node).includes(levelId)) continue
3229
+ const childIds = (node as { children?: AnyNodeId[] }).children
3230
+ const children = Array.isArray(childIds)
3231
+ ? childIds.map((childId) => nodes[childId]).filter((child): child is AnyNode => !!child)
3232
+ : []
3233
+ linked.push({ id, node, children })
3234
+ }
3235
+ return linked
3236
+ }
3237
+
1718
3238
  /**
1719
3239
  * Stable id for a handle on a node, derived from the node id + opaque
1720
3240
  * payload. Used to track hover / active visual state when multiple
@@ -1735,6 +3255,14 @@ function makeHandleId(nodeId: AnyNodeId, payload: unknown): string {
1735
3255
  return `${nodeId}:${String(payload)}`
1736
3256
  }
1737
3257
 
3258
+ export function floorplanHandleDoubleClickAffordance(
3259
+ geometry: FloorplanGeometry,
3260
+ ): 'delete-vertex' | null {
3261
+ return geometry.kind === 'endpoint-handle' && geometry.affordance === 'move-vertex'
3262
+ ? 'delete-vertex'
3263
+ : null
3264
+ }
3265
+
1738
3266
  /**
1739
3267
  * Geometry kinds that always render in the overlay pass — interactive
1740
3268
  * handles and node labels. These need to sit above every kind's base
@@ -1753,7 +3281,9 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
1753
3281
  'move-arrow',
1754
3282
  'rotate-arrow',
1755
3283
  'dimension',
3284
+ 'dimension-string',
1756
3285
  'dimension-label',
3286
+ 'equal-spacing-badge',
1757
3287
  ])
1758
3288
 
1759
3289
  /**
@@ -1766,10 +3296,16 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
1766
3296
  * / translations apply in both passes. Empty groups collapse to `null`
1767
3297
  * so the caller can skip emitting an `<g>` when there's nothing to draw.
1768
3298
  */
1769
- function splitFloorplanOverlay(g: FloorplanGeometry): {
3299
+ export function splitFloorplanOverlay(g: FloorplanGeometry): {
1770
3300
  base: FloorplanGeometry | null
1771
3301
  overlay: FloorplanGeometry | null
1772
3302
  } {
3303
+ if (readFloorplanGeometryMetadata(g).renderPass === 'overlay') {
3304
+ return { base: null, overlay: g }
3305
+ }
3306
+ if (isFloorplanAnnotationObstacleGeometry(g)) {
3307
+ return { base: null, overlay: g }
3308
+ }
1773
3309
  if (OVERLAY_KINDS.has(g.kind)) {
1774
3310
  return { base: null, overlay: g }
1775
3311
  }
@@ -1782,18 +3318,185 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
1782
3318
  if (split.overlay) overlayChildren.push(split.overlay)
1783
3319
  }
1784
3320
  const base: FloorplanGeometry | null =
1785
- baseChildren.length > 0
1786
- ? { kind: 'group', children: baseChildren, transform: g.transform }
1787
- : null
3321
+ baseChildren.length > 0 ? { ...g, children: baseChildren, transform: g.transform } : null
1788
3322
  const overlay: FloorplanGeometry | null =
1789
3323
  overlayChildren.length > 0
1790
- ? { kind: 'group', children: overlayChildren, transform: g.transform }
3324
+ ? { ...g, children: overlayChildren, transform: g.transform }
1791
3325
  : null
1792
3326
  return { base, overlay }
1793
3327
  }
1794
3328
  return { base: g, overlay: null }
1795
3329
  }
1796
3330
 
3331
+ /**
3332
+ * Per-node edit chrome hidden while a multi-selection is active: the group is
3333
+ * manipulated as one rigid piece (drag to move, R/T to rotate), so individual
3334
+ * handles / dimension labels on each member would mislead. `text` stays —
3335
+ * zone names are identification, not editing chrome.
3336
+ */
3337
+ const HANDLE_CHROME_KINDS = new Set<FloorplanGeometry['kind']>([
3338
+ 'endpoint-handle',
3339
+ 'midpoint-handle',
3340
+ 'edge-handle',
3341
+ 'move-handle',
3342
+ 'move-arrow',
3343
+ 'rotate-arrow',
3344
+ 'dimension',
3345
+ 'dimension-label',
3346
+ 'equal-spacing-badge',
3347
+ ])
3348
+
3349
+ function stripHandleChrome(g: FloorplanGeometry): FloorplanGeometry | null {
3350
+ if (HANDLE_CHROME_KINDS.has(g.kind)) return null
3351
+ if (g.kind === 'group') {
3352
+ const children = g.children
3353
+ .map(stripHandleChrome)
3354
+ .filter((c): c is FloorplanGeometry => c !== null)
3355
+ if (children.length === 0) return null
3356
+ return { kind: 'group', children, transform: g.transform }
3357
+ }
3358
+ return g
3359
+ }
3360
+
3361
+ // Stable string key for a wall endpoint, rounded to 1 mm so floating-point
3362
+ // drift collapses while distinct corners stay distinct.
3363
+ function endpointKey(x: number, y: number): string {
3364
+ return `${Math.round(x * 1000)},${Math.round(y * 1000)}`
3365
+ }
3366
+
3367
+ // Given the sibling-dependent nodes with a live drag in flight, the set of
3368
+ // floor-plan geometries that must rebuild this frame. A node's geometry depends
3369
+ // on more than its own data:
3370
+ // - a wall's miters depend on the walls meeting at each of its endpoints, so a
3371
+ // dragged wall invalidates the walls at its old AND new junctions, plus its
3372
+ // own door/window children (their cuts are drawn into it);
3373
+ // - a door/window cut is drawn into its host wall, so it invalidates that wall;
3374
+ // - a gutter join depends on sibling gutters under the same roof.
3375
+ // Everything else stays cached, so dragging one wall/opening rebuilds a handful
3376
+ // of geometries rather than every wall + opening on the level.
3377
+ export function computeAffectedSiblingIds(
3378
+ liveFlaggedIds: readonly AnyNodeId[],
3379
+ nodes: Record<string, AnyNode>,
3380
+ liveOverrides: Map<string, Record<string, unknown>>,
3381
+ ): Set<AnyNodeId> {
3382
+ const affected = new Set<AnyNodeId>()
3383
+ if (liveFlaggedIds.length === 0) return affected
3384
+
3385
+ // Junction map (committed wall endpoint → wall ids), built lazily on first use.
3386
+ let junctions: Map<string, AnyNodeId[]> | null = null
3387
+ const wallsAtPoint = (x: number, y: number): AnyNodeId[] => {
3388
+ if (!junctions) {
3389
+ junctions = new Map()
3390
+ for (const id in nodes) {
3391
+ const n = nodes[id]
3392
+ if (n?.type !== 'wall') continue
3393
+ const w = n as unknown as { start: [number, number]; end: [number, number] }
3394
+ for (const [px, py] of [w.start, w.end]) {
3395
+ const key = endpointKey(px, py)
3396
+ const arr = junctions.get(key)
3397
+ if (arr) arr.push(id as AnyNodeId)
3398
+ else junctions.set(key, [id as AnyNodeId])
3399
+ }
3400
+ }
3401
+ }
3402
+ return junctions.get(endpointKey(x, y)) ?? []
3403
+ }
3404
+
3405
+ for (const id of liveFlaggedIds) {
3406
+ const node = nodes[id]
3407
+ if (!node) continue
3408
+ affected.add(id)
3409
+ const def = nodeRegistry.get(node.type)
3410
+ const extraAffectedIds = def?.floorplanAffectedIds?.({
3411
+ nodeId: id,
3412
+ node,
3413
+ nodes: nodes as Record<AnyNodeId, AnyNode>,
3414
+ liveTransforms: useLiveTransforms.getState().transforms,
3415
+ liveOverrides,
3416
+ })
3417
+ if (extraAffectedIds) {
3418
+ for (const extraId of extraAffectedIds) affected.add(extraId)
3419
+ }
3420
+ if (node.type === 'wall') {
3421
+ const w = node as unknown as {
3422
+ start: [number, number]
3423
+ end: [number, number]
3424
+ children?: AnyNodeId[]
3425
+ }
3426
+ // Use the live (override-merged) endpoints as well as the committed ones,
3427
+ // so walls at both the wall's old and new junctions get fresh miters.
3428
+ const ov = liveOverrides.get(id) as
3429
+ | { start?: [number, number]; end?: [number, number] }
3430
+ | undefined
3431
+ const points: [number, number][] = [w.start, w.end]
3432
+ if (ov?.start) points.push(ov.start)
3433
+ if (ov?.end) points.push(ov.end)
3434
+ for (const [px, py] of points) {
3435
+ for (const wid of wallsAtPoint(px, py)) affected.add(wid)
3436
+ }
3437
+ if (Array.isArray(w.children)) {
3438
+ for (const cid of w.children) {
3439
+ const child = nodes[cid]
3440
+ if (child?.type === 'door' || child?.type === 'window') affected.add(cid)
3441
+ }
3442
+ }
3443
+ } else if (node.type === 'door' || node.type === 'window') {
3444
+ const hostId = (node as { parentId?: string }).parentId
3445
+ if (hostId) affected.add(hostId as AnyNodeId)
3446
+ const liveHostId = (liveOverrides.get(id) as { parentId?: string } | undefined)?.parentId
3447
+ if (liveHostId) affected.add(liveHostId as AnyNodeId)
3448
+ } else if (node.type === 'gutter') {
3449
+ const roofId = (node as { parentId?: string }).parentId
3450
+ if (roofId) {
3451
+ for (const sid in nodes) {
3452
+ const s = nodes[sid]
3453
+ if (s?.type === 'gutter' && (s as { parentId?: string }).parentId === roofId) {
3454
+ affected.add(sid as AnyNodeId)
3455
+ }
3456
+ }
3457
+ }
3458
+ }
3459
+ }
3460
+ return affected
3461
+ }
3462
+
3463
+ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
3464
+ const keys: Array<keyof NodeDeps> = [
3465
+ 'automaticDimensions',
3466
+ 'node',
3467
+ 'live',
3468
+ 'unit',
3469
+ 'metricNotation',
3470
+ 'wallDimensionReference',
3471
+ 'selected',
3472
+ 'highlighted',
3473
+ 'hovered',
3474
+ 'moving',
3475
+ 'liveOverride',
3476
+ 'palette',
3477
+ 'siblingEpoch',
3478
+ 'committedNodes',
3479
+ 'dependencyNodes',
3480
+ 'interactiveElevators',
3481
+ ]
3482
+ for (const key of keys) {
3483
+ if (!depsValueEqual(a[key], b[key])) return false
3484
+ }
3485
+ return true
3486
+ }
3487
+
3488
+ function depsValueEqual(a: unknown, b: unknown): boolean {
3489
+ if (Array.isArray(a) || Array.isArray(b)) {
3490
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
3491
+ if (a.length !== b.length) return false
3492
+ for (let i = 0; i < a.length; i++) {
3493
+ if (!Object.is(a[i], b[i])) return false
3494
+ }
3495
+ return true
3496
+ }
3497
+ return Object.is(a, b)
3498
+ }
3499
+
1797
3500
  /**
1798
3501
  * Z-order bucket for floor-plan rendering. Lower rank = painted first =
1799
3502
  * sits under everything with a higher rank. SVG renders in document
@@ -1809,7 +3512,7 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
1809
3512
  * Sort is stable in modern JS engines, so siblings within the same
1810
3513
  * bucket keep their DFS order (= scene tree order).
1811
3514
  */
1812
- function floorplanLayerRank(type: string): number {
3515
+ export function floorplanLayerRank(type: string): number {
1813
3516
  switch (type) {
1814
3517
  case 'zone':
1815
3518
  return 0
@@ -1855,17 +3558,22 @@ const ROTATION_WEDGE_SEGMENTS = 48
1855
3558
  * is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads
1856
3559
  * horizontally regardless of the building's on-screen orientation.
1857
3560
  */
1858
- function RotationAngleOverlay({
3561
+ export function RotationAngleOverlay({
1859
3562
  overlay,
1860
3563
  palette,
1861
- unitsPerPixel,
3564
+ unitsPerPixel: unitsPerPixelOverride,
1862
3565
  sceneRotationDeg,
1863
3566
  }: {
1864
3567
  overlay: RotationOverlayState
1865
- palette: FloorplanPalette
1866
- unitsPerPixel: number
3568
+ palette: Pick<
3569
+ FloorplanPalette,
3570
+ 'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke'
3571
+ >
3572
+ unitsPerPixel?: number
1867
3573
  sceneRotationDeg: number
1868
3574
  }): React.ReactElement {
3575
+ const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel()
3576
+ const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel
1869
3577
  const { pivot, startAngle, endAngle, radius, sweep } = overlay
1870
3578
  const span = endAngle - startAngle
1871
3579
  const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS))
@@ -1944,20 +3652,14 @@ function formatGroupTransform(t?: {
1944
3652
  return parts.length > 0 ? parts.join(' ') : undefined
1945
3653
  }
1946
3654
 
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]
3655
+ function swallowNextClick(timeoutMs = 0) {
3656
+ const swallowClick = (event: MouseEvent) => {
3657
+ event.stopPropagation()
3658
+ event.preventDefault()
3659
+ window.removeEventListener('click', swallowClick, true)
3660
+ }
3661
+ window.addEventListener('click', swallowClick, true)
3662
+ setTimeout(() => {
3663
+ window.removeEventListener('click', swallowClick, true)
3664
+ }, timeoutMs)
1963
3665
  }