@pascal-app/editor 0.8.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,3325 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeDefinition,
6
+ type AnyNodeId,
7
+ createSceneApi,
8
+ emitter,
9
+ type FloorplanAffordanceSession,
10
+ type FloorplanGeometry,
11
+ type FloorplanPalette,
12
+ type FloorplanPoint,
13
+ type GeometryContext,
14
+ isNodeKindEnabled,
15
+ isRegistryMovable,
16
+ kindsWithFloorplanScope,
17
+ type LiveNodeOverrides,
18
+ type LiveTransform,
19
+ nodeRegistry,
20
+ pauseSceneHistory,
21
+ resolveBuildingForLevel,
22
+ resolveSelectionProxyId,
23
+ resumeSceneHistory,
24
+ useInteractive,
25
+ useLiveNodeOverrides,
26
+ useLiveTransforms,
27
+ useScene,
28
+ } from '@pascal-app/core'
29
+ import { useViewer } from '@pascal-app/viewer'
30
+ import {
31
+ memo,
32
+ type MouseEvent as ReactMouseEvent,
33
+ type PointerEvent as ReactPointerEvent,
34
+ useCallback,
35
+ useEffect,
36
+ useMemo,
37
+ useRef,
38
+ useState,
39
+ } from 'react'
40
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
41
+ import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
42
+ import {
43
+ canDirectRotateNode,
44
+ resolveDirectManipulationNode,
45
+ resolveDirectRotationDragDelta,
46
+ resolveDirectRotationPatch,
47
+ snapDirectRotationDelta,
48
+ } from '../../../lib/direct-manipulation'
49
+ import { createEditorApi } from '../../../lib/editor-api'
50
+ import { clientToPlan } from '../../../lib/floorplan/plan-coords'
51
+ import {
52
+ type ActiveInteractionScope,
53
+ boundaryReshapeScope,
54
+ controlPointReshapeScope,
55
+ curveReshapeScope,
56
+ endpointReshapeScope,
57
+ holeEditScope,
58
+ tangentReshapeScope,
59
+ } from '../../../lib/interaction/scope'
60
+ import { sfxEmitter } from '../../../lib/sfx-bus'
61
+ import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
62
+ import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
63
+ import useEditor from '../../../store/use-editor'
64
+ import useInteractionScope, {
65
+ useEndpointReshape,
66
+ useMovingNode,
67
+ } from '../../../store/use-interaction-scope'
68
+ import { startGroupPickUp } from '../../editor/group-actions'
69
+ import { classifyParticipant } from '../../editor/group-transform-shared'
70
+ import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
71
+ import {
72
+ FloorplanGroupSelectionBox,
73
+ startFloorplanGroupMove,
74
+ startFloorplanGroupRotate,
75
+ } from '../floorplan-group-move'
76
+ import { useFloorplanRender } from '../floorplan-render-context'
77
+ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
78
+ import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
79
+
80
+ /**
81
+ * Registry-driven floor-plan layer.
82
+ *
83
+ * For every node in the active level whose definition exposes
84
+ * `def.floorplan`, builds a `GeometryContext` (with `viewState` so the
85
+ * kind can theme its output), calls the builder, and walks the resulting
86
+ * tree. Static primitives (polygon / line / circle / etc.) defer to
87
+ * `<FloorplanGeometryRenderer>`. Interactive primitives — `hatch`,
88
+ * `hit-line`, `endpoint-handle`, `dimension-label` — render here so they
89
+ * can access the SVG context for pointer events + units-per-pixel.
90
+ *
91
+ * Selection: clicking the entry's `<g>` selects the node. The wall
92
+ * `def.floorplan` also emits a `hit-line` along the centerline so the
93
+ * user can grab the wall body even at zoom levels where the polygon is
94
+ * skinny.
95
+ *
96
+ * 2D endpoint drag: when an `endpoint-handle` is pointer-downed and its
97
+ * `affordance === 'move-endpoint'`, this layer drives the legacy wall
98
+ * endpoint flow inline — snap pointer to walls/grid, run linked-wall
99
+ * cascade, live-update positions with history paused, single undo on
100
+ * commit. The kind-generic abstraction lands once fence + slab + ceiling
101
+ * pick up their 2D drags too (next iteration).
102
+ */
103
+ // Handle / hit-area sizes mirror the legacy `FLOORPLAN_ENDPOINT_HANDLE_*`
104
+ // constants in floorplan-panel.tsx. Sizes are in screen pixels — the
105
+ // dispatcher multiplies by `unitsPerPixel` so handles stay the same on-
106
+ // screen size at any zoom.
107
+ const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8
108
+ const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9
109
+ const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3
110
+ const ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX = 4
111
+ const ENDPOINT_HIT_STROKE_WIDTH_PX = 18
112
+ const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16
113
+ const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7
114
+ const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
115
+ const DIRECT_DRAG_THRESHOLD_PX = 4
116
+ const DIRECT_ROTATE_EPSILON = 1e-6
117
+ const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
118
+
119
+ /**
120
+ * Snapshot of node fields captured at drag-start, used by the single-undo
121
+ * dance to revert untracked before re-applying as a single tracked
122
+ * change. The dispatcher only knows about the `affectedIds` the
123
+ * affordance declares; it captures whatever fields exist on each node by
124
+ * cloning the full record minus the registry-managed `id` / `type`.
125
+ */
126
+ type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
127
+
128
+ type ActiveDrag = {
129
+ pointerId: number
130
+ captureTarget: Element
131
+ /** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */
132
+ handleId: string
133
+ session: FloorplanAffordanceSession
134
+ snapshots: NodeSnapshot[]
135
+ historyPaused: boolean
136
+ /**
137
+ * Last plan point handed to `session.apply` (the grab point until the first
138
+ * move). Lets the modifier-key listeners re-run the session immediately on
139
+ * an Alt/Shift flip instead of waiting for the next pointer move.
140
+ */
141
+ lastPlanPoint: FloorplanPoint
142
+ /**
143
+ * Set only for rotate-arrow drags (handles that carry a `pivot`). Drives
144
+ * the live angle wedge + degree readout — the 2D twin of the 3D rotate
145
+ * gizmo's readout. The bearing sweep is measured the same way every
146
+ * rotate affordance measures it: `atan2(pointer − pivot)`.
147
+ */
148
+ rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number }
149
+ /**
150
+ * Node id of the reshaping scope this drag began (boundary / curve / endpoint
151
+ * edits), so the matching `endIf` on release/cancel tears down exactly this
152
+ * scope. Unset for affordances that drive no snapping scope (resize / rotate).
153
+ */
154
+ reshapeScopeNodeId?: string
155
+ }
156
+
157
+ type FloorplanAffordanceCancelEffects = {
158
+ restoreSnapshots: (snapshots: NodeSnapshot[]) => void
159
+ resumeHistory: () => void
160
+ clearPreview: (id: AnyNodeId) => void
161
+ clearSnapFeedback: () => void
162
+ endReshapeScope: (drag: ActiveDrag) => void
163
+ clearDragFeedback?: () => void
164
+ }
165
+
166
+ export function cancelFloorplanAffordanceDrag(
167
+ dragRef: { current: ActiveDrag | null },
168
+ effects: FloorplanAffordanceCancelEffects,
169
+ pointerId?: number,
170
+ ): boolean {
171
+ const drag = dragRef.current
172
+ if (!drag || (pointerId !== undefined && pointerId !== drag.pointerId)) return false
173
+
174
+ // Clear ownership before cleanup so a queued pointer-up cannot commit the
175
+ // session while cancellation side effects are still running.
176
+ dragRef.current = null
177
+
178
+ if (drag.captureTarget.hasPointerCapture?.(drag.pointerId)) {
179
+ drag.captureTarget.releasePointerCapture?.(drag.pointerId)
180
+ }
181
+
182
+ effects.restoreSnapshots(drag.snapshots)
183
+ if (drag.historyPaused) {
184
+ effects.resumeHistory()
185
+ drag.historyPaused = false
186
+ }
187
+ effects.clearSnapFeedback()
188
+ for (const id of drag.session.affectedIds) effects.clearPreview(id)
189
+ effects.endReshapeScope(drag)
190
+ effects.clearDragFeedback?.()
191
+ return true
192
+ }
193
+
194
+ export function subscribeFloorplanAffordanceToolCancel(
195
+ cancelActiveDrag: () => boolean,
196
+ consumeToolCancel: () => void,
197
+ ): () => void {
198
+ const onToolCancel = () => {
199
+ if (cancelActiveDrag()) consumeToolCancel()
200
+ }
201
+ emitter.on('tool:cancel', onToolCancel)
202
+ return () => emitter.off('tool:cancel', onToolCancel)
203
+ }
204
+
205
+ // Map a floor-plan affordance to the reshaping scope it represents, so the
206
+ // dispatcher can drive the contextual snapping HUD (the chip) AND make
207
+ // `getActiveSnapContext()` resolve the right mode-set during the edit. Geometry
208
+ // edits that set a direction/shape map to a scope; resize / rotate / body-move
209
+ // affordances return `null` (no polygon/wall snapping chip). Keyed off the
210
+ // affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex`
211
+ // / `curve` / `move-endpoint`).
212
+ function affordanceReshapeScope(
213
+ affordance: string,
214
+ nodeId: string,
215
+ payload: unknown,
216
+ ): ActiveInteractionScope | null {
217
+ if (affordance.includes('vertex') || affordance.includes('edge')) {
218
+ const holeIndex = (payload as { holeIndex?: number } | undefined)?.holeIndex
219
+ return holeIndex !== undefined
220
+ ? holeEditScope({ nodeId, holeIndex })
221
+ : boundaryReshapeScope(nodeId)
222
+ }
223
+ if (affordance.includes('curve')) {
224
+ return curveReshapeScope(nodeId)
225
+ }
226
+ if (affordance.includes('control-point')) {
227
+ const index = (payload as { index?: number } | undefined)?.index ?? 0
228
+ return controlPointReshapeScope(nodeId, index)
229
+ }
230
+ if (affordance.includes('tangent')) {
231
+ const target = payload as { index?: number; side?: 'in' | 'out' } | undefined
232
+ return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out')
233
+ }
234
+ if (affordance.includes('endpoint')) {
235
+ const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
236
+ return endpointReshapeScope(nodeId, endpoint)
237
+ }
238
+ // Roof-segment width/depth resize — a no-angle dimension edit, so the
239
+ // no-angle 'polygon' snap set (grid / lines / off) via a boundary scope.
240
+ // Matched exactly so a still-legacy `*-resize` affordance on another kind
241
+ // doesn't get a chip its snap math can't honour yet.
242
+ if (affordance === 'roof-segment-resize') {
243
+ return boundaryReshapeScope(nodeId)
244
+ }
245
+ // 2D corner rotate-arrow (column / elevator / roof-segment / shelf / spawn /
246
+ // stair). Begin the same handle-drag scope the 3D rotate gizmo uses, label-
247
+ // matched, so the contextual HUD shows the "Shift = rotate freely" hint over
248
+ // the drag. The affordance applies the 15° angle step itself.
249
+ if (affordance.includes('rotate')) {
250
+ return { kind: 'handle-drag', nodeId, handle: ROTATE_HANDLE_DRAG_LABEL }
251
+ }
252
+ return null
253
+ }
254
+
255
+ /**
256
+ * Transient live-rotation readout state. Rebuilt each pointer-move while a
257
+ * rotate-arrow is dragged and cleared on release. World-plan coords.
258
+ */
259
+ type RotationOverlayState = {
260
+ pivot: FloorplanPoint
261
+ startAngle: number
262
+ endAngle: number
263
+ radius: number
264
+ /** Swept magnitude in radians, for the degree chip. */
265
+ sweep: number
266
+ }
267
+
268
+ type FloorplanEntryDescriptor = {
269
+ id: AnyNodeId
270
+ node: AnyNode
271
+ dependsOnSiblingInputs: boolean
272
+ ctxOverrides?: FloorplanContextOverrides
273
+ }
274
+
275
+ type NodeDeps = {
276
+ node: AnyNode
277
+ live: LiveTransform | undefined
278
+ unit: 'metric' | 'imperial'
279
+ selected: boolean
280
+ highlighted: boolean
281
+ hovered: boolean
282
+ moving: boolean
283
+ liveOverride: LiveNodeOverrides | undefined
284
+ palette: FloorplanPalette | undefined
285
+ siblingEpoch: number
286
+ committedNodes: Record<string, AnyNode> | null
287
+ dependencyNodes: AnyNode[]
288
+ interactiveElevators: unknown
289
+ }
290
+
291
+ type CacheEntry = {
292
+ deps: NodeDeps
293
+ base: FloorplanGeometry | null
294
+ overlay: FloorplanGeometry | null
295
+ node: AnyNode
296
+ }
297
+
298
+ type LevelDataCacheEntry = {
299
+ nodes: Record<string, AnyNode>
300
+ liveOverrides: Map<string, LiveNodeOverrides>
301
+ ids: readonly AnyNodeId[]
302
+ value: unknown
303
+ }
304
+
305
+ type FloorplanContextOverrides = {
306
+ children: AnyNode[]
307
+ siblings: AnyNode[]
308
+ parent: AnyNode | null
309
+ }
310
+
311
+ type FloorplanLevelDataHook = (args: {
312
+ siblings: ReadonlyArray<AnyNode>
313
+ nodes: Record<string, AnyNode>
314
+ }) => unknown
315
+
316
+ type FloorplanRenderPass = 'base' | 'overlay'
317
+
318
+ const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const
319
+ // Group members advertise the drag-to-move-the-selection gesture.
320
+ const MOVE_CURSOR_STYLE = { cursor: 'move' } as const
321
+ const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
322
+
323
+ function snapshotNode(node: AnyNode): NodeSnapshot {
324
+ // Shallow-clone every non-id, non-type field. Arrays / vec tuples are
325
+ // deep-cloned to detach from the live store reference.
326
+ const data: Record<string, unknown> = {}
327
+ for (const [key, value] of Object.entries(node)) {
328
+ if (key === 'id' || key === 'type' || key === 'object' || key === 'parentId') continue
329
+ data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
330
+ }
331
+ return { id: node.id, data }
332
+ }
333
+
334
+ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
335
+ return snapshots.map((s) => ({ id: s.id, data: s.data }))
336
+ }
337
+
338
+ // Stable empty sentinel used by per-entry builders while the floor plan is
339
+ // hidden; committed scene edits still flow through `useScene`.
340
+ const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
341
+
342
+ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
343
+ const selectedLevelId = useViewer((s) => s.selection.levelId)
344
+ const selectedBuildingId = useViewer((s) => s.selection.buildingId)
345
+ const unit = useViewer((s) => s.unit)
346
+ const showMeasurements = useViewer((s) => s.showMeasurements)
347
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
348
+ const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
349
+ const hoveredId = useViewer((s) => s.hoveredId)
350
+ const activeRotateNodeId = useDirectManipulationFeedback((s) => s.activeRotateNodeId)
351
+ const setHoveredId = useViewer((s) => s.setHoveredId)
352
+ const setSelection = useViewer((s) => s.setSelection)
353
+ const nodes = useScene((s) => s.nodes)
354
+ const installedPlugins = useScene((s) => s.installedPlugins)
355
+ const movingNode = useMovingNode()
356
+ // When a building is being moved, its explicit selection may be
357
+ // cleared as part of the move handoff. Fall back to the
358
+ // mid-drag building id so the dimmed floor keeps rendering
359
+ // throughout the gesture.
360
+ const movingBuildingId =
361
+ movingNode && nodeRegistry.get(movingNode.type)?.capabilities?.floorplanLevelContainer
362
+ ? movingNode.id
363
+ : null
364
+ const ambientBuildingSourceId = selectedBuildingId ?? movingBuildingId
365
+
366
+ // When only a building is in scope (no specific level), fall back to
367
+ // its level 0 (or the lowest-indexed level) so the floor still
368
+ // renders as context — dimmed and non-interactive — instead of
369
+ // disappearing entirely.
370
+ const ambientLevelId = useMemo<AnyNodeId | null>(() => {
371
+ if (selectedLevelId || !ambientBuildingSourceId) return null
372
+ const building = nodes[ambientBuildingSourceId]
373
+ if (building?.type !== 'building') return null
374
+ let zero: AnyNodeId | null = null
375
+ let lowestId: AnyNodeId | null = null
376
+ let lowestIdx = Number.POSITIVE_INFINITY
377
+ const childIds = (building as unknown as { children?: AnyNodeId[] }).children ?? []
378
+ for (const childId of childIds) {
379
+ const child = nodes[childId]
380
+ if (child?.type !== 'level') continue
381
+ if (child.level === 0) {
382
+ zero = child.id
383
+ break
384
+ }
385
+ if (child.level < lowestIdx) {
386
+ lowestIdx = child.level
387
+ lowestId = child.id
388
+ }
389
+ }
390
+ return zero ?? lowestId
391
+ }, [selectedLevelId, ambientBuildingSourceId, nodes])
392
+
393
+ const levelId = selectedLevelId ?? ambientLevelId
394
+ const isAmbient = !selectedLevelId && !!ambientLevelId
395
+ const renderCtx = useFloorplanRender()
396
+ const setMovingNode = useEditor((s) => s.setMovingNode)
397
+ const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
398
+ // Door / window placement (both build and move) needs the SVG's
399
+ // background click handler to run — it finds the closest wall via
400
+ // `findClosestWallPoint` and emits `wall:click` for the door / window
401
+ // tool. When the user clicks *on top of* a wall in this mode, the
402
+ // wall's registry entry would otherwise swallow the click via
403
+ // `handleClickStop` / `handleSelect`, so the placement never fires.
404
+ // Pass clicks through in that case.
405
+ const editorPhase = useEditor((s) => s.phase)
406
+ const editorMode = useEditor((s) => s.mode)
407
+ const editorTool = useEditor((s) => s.tool)
408
+ const structureLayer = useEditor((s) => s.structureLayer)
409
+ const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
410
+ const endpointReshape = useEndpointReshape()
411
+ const isOpeningPlacementActive =
412
+ (editorPhase === 'structure' &&
413
+ editorMode === 'build' &&
414
+ (editorTool === 'door' || editorTool === 'window')) ||
415
+ (movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
416
+ const isMarqueeSelectionActive =
417
+ editorMode === 'select' &&
418
+ floorplanSelectionTool === 'marquee' &&
419
+ structureLayer !== 'zones' &&
420
+ !movingNode &&
421
+ !endpointReshape
422
+ // While the floor plan is not on screen (pure 3D view), per-entry live
423
+ // selectors freeze to `undefined` so drag publishes do not re-render the
424
+ // hidden floor-plan tree.
425
+ const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
426
+ // Elevator builders read runtime state imperatively, so entries include this
427
+ // rare-changing ref in their cache deps.
428
+ const interactiveElevators = useInteractive((s) => s.elevators)
429
+
430
+ const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
431
+ // Marquee preview selection — matches the legacy `highlightedIdSet` use
432
+ // (filter-while-marquee), surfaces selection chrome without keyboard focus.
433
+ const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds])
434
+ // Multi-selection: members show highlight only (per-node edit chrome hidden)
435
+ // and transformable members advertise the drag-to-move gesture.
436
+ const isMultiSelect = selectedIds.length > 1
437
+ const groupParticipantIdSet = useMemo(() => {
438
+ if (selectedIds.length < 2 || !levelId) return null
439
+ return new Set(
440
+ selectedIds.filter(
441
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
442
+ ),
443
+ )
444
+ }, [selectedIds, levelId, nodes])
445
+
446
+ // Interactive state lives in refs; only the visible feedback bits go
447
+ // into React state to keep re-renders cheap during drag.
448
+ const dragRef = useRef<ActiveDrag | null>(null)
449
+ const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
450
+ const [activeDragId, setActiveDragId] = useState<string | null>(null)
451
+ const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
452
+ const geometryCacheRef = useRef<Map<string, CacheEntry>>(new Map())
453
+ const levelDataCacheRef = useRef<Map<string, LevelDataCacheEntry>>(new Map())
454
+ const nodesRef = useRef(nodes)
455
+ const [siblingEpochs, setSiblingEpochs] = useState<Map<AnyNodeId, number>>(() => new Map())
456
+ // Per-node sibling epoch (replaces a single global epoch). Bumped only for the
457
+ // nodes affected by this frame's live drags, so an unaffected wall/opening
458
+ // keeps its epoch and stays cached. `prevLiveFlaggedIdsRef` remembers which
459
+ // sibling-dependent nodes were live last frame, so a node that just STOPPED
460
+ // being dragged (override cleared, no commit) still gets one final rebuild to
461
+ // revert — its dependents (host wall, junction neighbours) don't carry its
462
+ // override in their own deps.
463
+ const nodeSiblingEpochRef = useRef<Map<AnyNodeId, number>>(new Map())
464
+ const prevLiveFlaggedIdsRef = useRef<AnyNodeId[]>([])
465
+
466
+ useEffect(() => {
467
+ nodesRef.current = nodes
468
+ }, [nodes])
469
+
470
+ const bumpAffectedSiblingEpochs = useCallback(() => {
471
+ if (!floorplanVisible) return
472
+
473
+ const sceneNodes = nodesRef.current
474
+ const liveTransforms = useLiveTransforms.getState().transforms
475
+ const liveOverrides = useLiveNodeOverrides.getState().overrides
476
+ const liveFlaggedIds: AnyNodeId[] = []
477
+
478
+ for (const [id] of liveTransforms) {
479
+ const node = sceneNodes[id as AnyNodeId]
480
+ const def = node ? nodeRegistry.get(node.type) : null
481
+ if (
482
+ node &&
483
+ (def?.floorplanDependsOnSiblings ||
484
+ def?.floorplanSiblingOverrides ||
485
+ def?.floorplanAffectedIds)
486
+ ) {
487
+ liveFlaggedIds.push(id as AnyNodeId)
488
+ }
489
+ }
490
+ for (const [id] of liveOverrides) {
491
+ const node = sceneNodes[id as AnyNodeId]
492
+ const def = node ? nodeRegistry.get(node.type) : null
493
+ if (
494
+ node &&
495
+ (def?.floorplanDependsOnSiblings ||
496
+ def?.floorplanSiblingOverrides ||
497
+ def?.floorplanAffectedIds)
498
+ ) {
499
+ liveFlaggedIds.push(id as AnyNodeId)
500
+ }
501
+ }
502
+
503
+ const expandFrom = Array.from(new Set([...liveFlaggedIds, ...prevLiveFlaggedIdsRef.current]))
504
+ const affectedSiblingIds = computeAffectedSiblingIds(expandFrom, sceneNodes, liveOverrides)
505
+ const nodeSiblingEpochs = nodeSiblingEpochRef.current
506
+ for (const id of affectedSiblingIds) {
507
+ nodeSiblingEpochs.set(id, (nodeSiblingEpochs.get(id) ?? 0) + 1)
508
+ }
509
+ prevLiveFlaggedIdsRef.current = liveFlaggedIds
510
+ if (affectedSiblingIds.size > 0) {
511
+ setSiblingEpochs(new Map(nodeSiblingEpochs))
512
+ }
513
+ }, [floorplanVisible])
514
+
515
+ useEffect(() => {
516
+ bumpAffectedSiblingEpochs()
517
+ const unsubscribeTransforms = useLiveTransforms.subscribe(bumpAffectedSiblingEpochs)
518
+ const unsubscribeOverrides = useLiveNodeOverrides.subscribe(bumpAffectedSiblingEpochs)
519
+ return () => {
520
+ unsubscribeTransforms()
521
+ unsubscribeOverrides()
522
+ }
523
+ }, [bumpAffectedSiblingEpochs])
524
+
525
+ const applyEntrySelection = useCallback(
526
+ (id: AnyNodeId, shouldToggle: boolean) => {
527
+ const currentSelectedIds = useViewer.getState().selection.selectedIds
528
+ setSelection({
529
+ selectedIds: shouldToggle
530
+ ? currentSelectedIds.includes(id)
531
+ ? currentSelectedIds.filter((selectedId) => selectedId !== id)
532
+ : [...currentSelectedIds, id]
533
+ : [id],
534
+ })
535
+ // Setting selection re-renders the entry — the overlay pass mounts
536
+ // (endpoint handles, etc.), reshuffling DOM under the cursor between
537
+ // pointerdown and click. If the click target ends up on the SVG
538
+ // background, `<g floorplan-registry-layer onClick=handleClickStop>`
539
+ // never sees it, and the SVG's `handleBackgroundClick` clears the
540
+ // selection we just set. Swallow the next click globally to break
541
+ // that race; the listener removes itself after firing (or after a
542
+ // safety timeout if no click follows).
543
+ swallowNextClick(200)
544
+ },
545
+ [setSelection],
546
+ )
547
+
548
+ const handleSelect = useCallback(
549
+ (id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
550
+ if (event.button !== 0) return
551
+ event.stopPropagation()
552
+ applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey)
553
+ },
554
+ [applyEntrySelection],
555
+ )
556
+
557
+ const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
558
+ event.stopPropagation()
559
+ }, [])
560
+
561
+ const startDirectMoveDrag = useCallback(
562
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
563
+ if (event.button !== 0 || !(event.metaKey || event.ctrlKey)) return false
564
+
565
+ const node = useScene.getState().nodes[id]
566
+ if (!node || !isRegistryMovable(node.type)) return false
567
+ // Sole selection only: per-node direct manipulation stands down for a
568
+ // multi-selection (the group session owns plain drags there, and Cmd is
569
+ // the selection-toggle key — a wobbly Cmd+click must not yank one
570
+ // member out of the group).
571
+ const currentSelectedIds = useViewer.getState().selection.selectedIds
572
+ if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== id) return false
573
+
574
+ event.preventDefault()
575
+ event.stopPropagation()
576
+
577
+ const startX = event.clientX
578
+ const startY = event.clientY
579
+ const pointerId = event.pointerId
580
+ let engaged = false
581
+
582
+ const cleanup = () => {
583
+ window.removeEventListener('pointermove', onMove)
584
+ window.removeEventListener('pointerup', onEnd)
585
+ window.removeEventListener('pointercancel', onEnd)
586
+ if (engaged) {
587
+ useViewer.getState().setInputDragging(false)
588
+ }
589
+ }
590
+
591
+ const onMove = (moveEvent: PointerEvent) => {
592
+ if (moveEvent.pointerId !== pointerId) return
593
+ if (engaged) return
594
+ const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY)
595
+ if (distance < DIRECT_DRAG_THRESHOLD_PX) return
596
+
597
+ engaged = true
598
+ useViewer.getState().setInputDragging(true)
599
+ swallowNextClick(300)
600
+ createEditorApi().engageMoveDrag(node)
601
+
602
+ requestAnimationFrame(() => {
603
+ window.dispatchEvent(
604
+ new PointerEvent('pointermove', {
605
+ altKey: moveEvent.altKey,
606
+ bubbles: true,
607
+ buttons: moveEvent.buttons,
608
+ clientX: moveEvent.clientX,
609
+ clientY: moveEvent.clientY,
610
+ ctrlKey: moveEvent.ctrlKey,
611
+ metaKey: moveEvent.metaKey,
612
+ pointerId,
613
+ pointerType: moveEvent.pointerType,
614
+ shiftKey: moveEvent.shiftKey,
615
+ }),
616
+ )
617
+ })
618
+ }
619
+
620
+ const onEnd = (endEvent: PointerEvent) => {
621
+ if (endEvent.pointerId !== pointerId) return
622
+ cleanup()
623
+ if (!engaged) {
624
+ applyEntrySelection(id, true)
625
+ }
626
+ }
627
+
628
+ window.addEventListener('pointermove', onMove)
629
+ window.addEventListener('pointerup', onEnd)
630
+ window.addEventListener('pointercancel', onEnd)
631
+ return true
632
+ },
633
+ [applyEntrySelection],
634
+ )
635
+
636
+ const startDirectRotateDrag = useCallback(
637
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
638
+ if (event.button !== 2 || !(event.metaKey || event.ctrlKey)) return false
639
+
640
+ const sceneNodes = useScene.getState().nodes
641
+ const selectedNode = sceneNodes[id]
642
+ const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
643
+ if (!node || !canDirectRotateNode(node)) return false
644
+ // Sole selection only — same stand-down as the direct move above.
645
+ const selectedIds = useViewer.getState().selection.selectedIds
646
+ if (selectedIds.length !== 1 || selectedIds[0] !== id) return false
647
+ event.preventDefault()
648
+ event.stopPropagation()
649
+
650
+ const nodeId = node.id as AnyNodeId
651
+ const pointerId = event.pointerId
652
+ const startX = event.clientX
653
+ const sceneApi = createSceneApi(useScene)
654
+ let lastPatch: Partial<AnyNode> | null = null
655
+
656
+ const applyDelta = (pointerEvent: PointerEvent | ReactPointerEvent<SVGGElement>) => {
657
+ const delta = resolveDirectRotationDragDelta(
658
+ startX,
659
+ pointerEvent.clientX,
660
+ DIRECT_ROTATE_RADIANS_PER_PIXEL,
661
+ pointerEvent.shiftKey,
662
+ )
663
+ if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
664
+ lastPatch = null
665
+ useLiveNodeOverrides.getState().clear(nodeId)
666
+ useScene.getState().markDirty(nodeId)
667
+ return
668
+ }
669
+ const patch = resolveDirectRotationPatch(node, delta, sceneApi)
670
+ if (!patch) return
671
+ lastPatch = patch
672
+ useLiveNodeOverrides.getState().set(nodeId, patch as Record<string, unknown>)
673
+ useScene.getState().markDirty(nodeId)
674
+ }
675
+
676
+ const cleanup = () => {
677
+ window.removeEventListener('pointermove', onMove, true)
678
+ window.removeEventListener('pointerup', onUp, true)
679
+ window.removeEventListener('pointercancel', onCancel, true)
680
+ window.removeEventListener('contextmenu', preventContextMenu, true)
681
+ useLiveNodeOverrides.getState().clear(nodeId)
682
+ useScene.getState().markDirty(nodeId)
683
+ resumeSceneHistory(useScene)
684
+ useDirectManipulationFeedback.getState().clearActiveRotateNodeId(nodeId)
685
+ useViewer.getState().setInputDragging(false)
686
+ if (document.body.style.cursor === 'ew-resize') {
687
+ document.body.style.cursor = ''
688
+ }
689
+ }
690
+
691
+ const onMove = (moveEvent: PointerEvent) => {
692
+ if (moveEvent.pointerId !== pointerId) return
693
+ moveEvent.preventDefault()
694
+ moveEvent.stopPropagation()
695
+ applyDelta(moveEvent)
696
+ }
697
+
698
+ const onUp = (upEvent: PointerEvent) => {
699
+ if (upEvent.pointerId !== pointerId) return
700
+ upEvent.preventDefault()
701
+ upEvent.stopPropagation()
702
+ swallowNextClick(300)
703
+ if (lastPatch) {
704
+ sceneApi.update(nodeId, lastPatch)
705
+ sfxEmitter.emit('sfx:item-place')
706
+ }
707
+ cleanup()
708
+ }
709
+
710
+ const onCancel = (cancelEvent: PointerEvent) => {
711
+ if (cancelEvent.pointerId !== pointerId) return
712
+ cleanup()
713
+ }
714
+
715
+ const preventContextMenu = (contextEvent: Event) => {
716
+ contextEvent.preventDefault()
717
+ contextEvent.stopPropagation()
718
+ }
719
+
720
+ pauseSceneHistory(useScene)
721
+ useViewer.getState().setInputDragging(true)
722
+ useDirectManipulationFeedback.getState().setActiveRotateNodeId(nodeId)
723
+ document.body.style.cursor = 'ew-resize'
724
+ sfxEmitter.emit('sfx:item-pick')
725
+ applyDelta(event)
726
+
727
+ window.addEventListener('pointermove', onMove, true)
728
+ window.addEventListener('pointerup', onUp, true)
729
+ window.addEventListener('pointercancel', onCancel, true)
730
+ window.addEventListener('contextmenu', preventContextMenu, true)
731
+ return true
732
+ },
733
+ [],
734
+ )
735
+
736
+ // Photoshop-style group drag: plain pointer-down on a transformable member
737
+ // of a multi-selection slides the whole selection rigidly; a plain click
738
+ // (no drag) enters the group pick-up instead — parity with the single-item
739
+ // click-to-move (clicking outside still deselects). Modified clicks
740
+ // (selection toggle) and Cmd-drag / direct-rotate keep their existing
741
+ // paths. `immediate` engages without the drag threshold — the move-handle
742
+ // dot's pick-up semantics.
743
+ const startGroupMoveDrag = useCallback(
744
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>, immediate = false): boolean => {
745
+ if (event.button !== 0) return false
746
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
747
+ if (movingNode) return false
748
+ if (useEditor.getState().mode === 'delete') return false
749
+ const started = startFloorplanGroupMove(id, event, {
750
+ immediate,
751
+ onClickFallthrough: immediate ? undefined : () => startGroupPickUp(),
752
+ })
753
+ if (!started) return false
754
+ event.preventDefault()
755
+ event.stopPropagation()
756
+ suppressBoxSelectForPointer(event)
757
+ return true
758
+ },
759
+ [movingNode],
760
+ )
761
+
762
+ // Move-handle dot variant — routes the dot through the group session when
763
+ // the owning node is part of a multi-selection.
764
+ const handleGroupMoveHandlePointerDown = useCallback(
765
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => startGroupMoveDrag(id, event, true),
766
+ [startGroupMoveDrag],
767
+ )
768
+
769
+ // The dashed selection box is itself the group's drag handle: a press
770
+ // anywhere inside it slides the group (or picks it up on a plain click),
771
+ // anchored on the first transformable member.
772
+ const handleGroupBoxPointerDown = useCallback(
773
+ (event: ReactPointerEvent<SVGGElement>) => {
774
+ const { selectedIds: currentIds, levelId: currentLevelId } = useViewer.getState().selection
775
+ const sceneNodes = useScene.getState().nodes
776
+ const anchor = currentIds.find(
777
+ (id) =>
778
+ classifyParticipant(sceneNodes[id as AnyNodeId], currentLevelId, sceneNodes) !== null,
779
+ )
780
+ if (!anchor) return
781
+ startGroupMoveDrag(anchor as AnyNodeId, event)
782
+ },
783
+ [startGroupMoveDrag],
784
+ )
785
+
786
+ // Corner rotate handles on the dashed selection box — 15° steps, Shift
787
+ // free, mirroring the 3D group rotate gizmo.
788
+ const handleGroupBoxRotatePointerDown = useCallback((event: ReactPointerEvent<SVGGElement>) => {
789
+ if (event.button !== 0) return
790
+ if (event.metaKey || event.ctrlKey || event.altKey) return
791
+ if (useEditor.getState().mode === 'delete') return
792
+ if (startFloorplanGroupRotate(event)) {
793
+ event.preventDefault()
794
+ suppressBoxSelectForPointer(event)
795
+ }
796
+ }, [])
797
+
798
+ const handleEntryPointerDown = useCallback(
799
+ (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
800
+ if (startDirectMoveDrag(id, event)) return
801
+ if (startDirectRotateDrag(id, event)) return
802
+ if (startGroupMoveDrag(id, event)) return
803
+ handleSelect(id, event)
804
+ },
805
+ [handleSelect, startDirectMoveDrag, startDirectRotateDrag, startGroupMoveDrag],
806
+ )
807
+
808
+ const floorplanData = useMemo(() => {
809
+ if (!levelId) {
810
+ geometryCacheRef.current.clear()
811
+ levelDataCacheRef.current.clear()
812
+ return {
813
+ entries: [] as FloorplanEntryDescriptor[],
814
+ levelNodeIdsByType: new Map<string, AnyNodeId[]>(),
815
+ }
816
+ }
817
+
818
+ const out: FloorplanEntryDescriptor[] = []
819
+ const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
820
+
821
+ const collectLevelDataKind = (id: AnyNodeId) => {
822
+ const node = nodes[id]
823
+ if (!node) return
824
+ if (!isNodeKindEnabled(node.type, installedPlugins)) return
825
+ const def = nodeRegistry.get(node.type)
826
+ if (def?.computeFloorplanLevelData) {
827
+ const ids = levelNodeIdsByType.get(node.type)
828
+ if (ids) ids.push(id)
829
+ else levelNodeIdsByType.set(node.type, [id])
830
+ }
831
+ const childIds = (node as unknown as { children?: AnyNodeId[] }).children
832
+ if (Array.isArray(childIds)) {
833
+ for (const cid of childIds) collectLevelDataKind(cid)
834
+ }
835
+ }
836
+
837
+ collectLevelDataKind(levelId as AnyNodeId)
838
+
839
+ const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
840
+ if (!isNodeKindEnabled(node.type, installedPlugins)) return
841
+ const def = nodeRegistry.get(node.type)
842
+ if (!def?.floorplan) return
843
+ if (node.type === 'measurement' && !showMeasurements) return
844
+ const dependsOnSiblingInputs = !!(
845
+ def.floorplanDependsOnSiblings ||
846
+ def.floorplanSiblingOverrides ||
847
+ def.floorplanAffectedIds
848
+ )
849
+ const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs }
850
+ if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
851
+ out.push(descriptor)
852
+ }
853
+
854
+ const visit = (id: AnyNodeId) => {
855
+ const node = nodes[id]
856
+ if (!node) return
857
+ pushEntry(id, node)
858
+ const childIds = (node as unknown as { children?: AnyNodeId[] }).children
859
+ if (Array.isArray(childIds)) {
860
+ for (const cid of childIds) visit(cid)
861
+ }
862
+ }
863
+
864
+ visit(levelId as AnyNodeId)
865
+
866
+ // Building-scoped kinds (`def.floorplanScope === 'building'`) live
867
+ // as siblings of the level, not under it — the `visit(levelId)` DFS
868
+ // above doesn't reach them. Walk every node of those kinds whose
869
+ // parent matches the active level's building, and synthesise a
870
+ // `GeometryContext` whose `parent` is the active level (so kind
871
+ // builders that gate on the current floor — e.g. elevator service
872
+ // range — keep working). Pure registry-driven dispatch: no kind
873
+ // name appears in this file.
874
+ const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
875
+ const activeBuildingId = activeLevelNode
876
+ ? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
877
+ : null
878
+ if (activeLevelNode && activeBuildingId) {
879
+ const buildingScopedKinds = kindsWithFloorplanScope('building')
880
+ const buildingScopedKindSet = new Set(buildingScopedKinds)
881
+ for (const [id, node] of Object.entries(nodes)) {
882
+ if (!node || !buildingScopedKindSet.has(node.type)) continue
883
+ const parentId = (node as { parentId?: AnyNodeId | null }).parentId
884
+ if (parentId !== activeBuildingId) continue
885
+ const cid = id as AnyNodeId
886
+ pushEntry(cid, node, {
887
+ children: [],
888
+ siblings: [],
889
+ parent: activeLevelNode,
890
+ })
891
+ }
892
+ }
893
+
894
+ // Stable z-order sort. SVG renders in document order — later siblings
895
+ // paint on top of earlier ones — so anything that should sit *under*
896
+ // other floor-plan geometry has to come first in the entries array.
897
+ // Zones are conceptual room/area regions; walls / slabs / furniture
898
+ // all belong on top of them. Within a layer bucket we preserve the
899
+ // DFS visit order (stable sort) so siblings keep their relative
900
+ // priority.
901
+ out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
902
+ const entryIds = new Set(out.map((entry) => entry.id))
903
+ for (const id of geometryCacheRef.current.keys()) {
904
+ if (!entryIds.has(id as AnyNodeId)) geometryCacheRef.current.delete(id)
905
+ }
906
+ for (const type of levelDataCacheRef.current.keys()) {
907
+ if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
908
+ }
909
+ return { entries: out, levelNodeIdsByType }
910
+ }, [installedPlugins, levelId, nodes, showMeasurements])
911
+
912
+ // ── Generic 2D affordance dispatch ─────────────────────────────────
913
+ //
914
+ // Pointer-down on an interactive handle resolves the kind's
915
+ // `def.floorplanAffordances?.[affordance]` and starts a session. The
916
+ // dispatcher then owns: history pause/resume, snapshot capture,
917
+ // pointer-move/up/cancel routing, and the single-undo dance on
918
+ // commit. Each kind owns the actual mutation logic inside `apply`.
919
+ const commitAffordanceAction = useCallback(
920
+ (
921
+ nodeId: AnyNodeId,
922
+ affordance: string,
923
+ payload: unknown,
924
+ event: ReactMouseEvent<SVGElement>,
925
+ ) => {
926
+ if (event.button !== 0 || movingNode || dragRef.current) return
927
+
928
+ const sceneNodes = useScene.getState().nodes
929
+ const node = sceneNodes[nodeId]
930
+ if (!node) return
931
+ const handler = nodeRegistry.get(node.type)?.floorplanAffordances?.[affordance]
932
+ if (!handler) return
933
+ const initialPlanPoint = clientToPlan(event.clientX, event.clientY)
934
+ if (!initialPlanPoint) return
935
+
936
+ const session = handler.start({
937
+ node,
938
+ payload,
939
+ nodes: sceneNodes,
940
+ initialPlanPoint,
941
+ gridSnapStep: useEditor.getState().gridSnapStep,
942
+ })
943
+ if (!(session.commit && session.canCommit())) return
944
+ session.commit()
945
+ sfxEmitter.emit('sfx:structure-build')
946
+ },
947
+ [movingNode],
948
+ )
949
+
950
+ const startAffordanceDrag = useCallback(
951
+ (
952
+ nodeId: AnyNodeId,
953
+ handleId: string,
954
+ affordance: string,
955
+ payload: unknown,
956
+ event: ReactPointerEvent<SVGGElement>,
957
+ // Present only for rotate-arrow handles — the pivot the node turns
958
+ // around, used to drive the live angle wedge + degree readout.
959
+ rotationPivot?: FloorplanPoint,
960
+ ) => {
961
+ if (event.button !== 0) return
962
+ if (movingNode) return
963
+
964
+ const sceneNodes = useScene.getState().nodes
965
+ const node = sceneNodes[nodeId]
966
+ if (!node) return
967
+
968
+ const def = nodeRegistry.get(node.type)
969
+ const handler = def?.floorplanAffordances?.[affordance]
970
+ if (!handler) return
971
+
972
+ const initialPlanPoint = clientToPlan(event.clientX, event.clientY)
973
+ if (!initialPlanPoint) return
974
+
975
+ event.preventDefault()
976
+ event.stopPropagation()
977
+ suppressBoxSelectForPointer(event)
978
+
979
+ const session = handler.start({
980
+ node,
981
+ payload,
982
+ nodes: sceneNodes,
983
+ initialPlanPoint,
984
+ gridSnapStep: useEditor.getState().gridSnapStep,
985
+ })
986
+
987
+ const snapshots: NodeSnapshot[] = []
988
+ for (const id of session.affectedIds) {
989
+ const n = sceneNodes[id]
990
+ if (n) snapshots.push(snapshotNode(n))
991
+ }
992
+
993
+ pauseSceneHistory(useScene)
994
+
995
+ // Rotation readout setup. The wedge radius tracks the grab distance
996
+ // from the pivot (≈ the handle's orbit), nudged inward so the swept
997
+ // fill reads as the handle swinging round rather than overlapping it,
998
+ // and floored so a tight footprint still shows a legible wedge.
999
+ let rotation: ActiveDrag['rotation']
1000
+ if (rotationPivot) {
1001
+ const dx = initialPlanPoint[0] - rotationPivot[0]
1002
+ const dz = initialPlanPoint[1] - rotationPivot[1]
1003
+ rotation = {
1004
+ pivot: rotationPivot,
1005
+ initialAngle: Math.atan2(dz, dx),
1006
+ radius: Math.max(Math.hypot(dx, dz) * 0.72, 0.25),
1007
+ }
1008
+ }
1009
+
1010
+ // Begin the matching reshaping scope so the contextual snapping HUD shows
1011
+ // the right chip during the edit AND `getActiveSnapContext()` resolves the
1012
+ // polygon / wall mode-set the affordance's snap math reads. Torn down on
1013
+ // release / cancel below. `null` for resize / rotate (no snapping chip).
1014
+ const reshapeScope = affordanceReshapeScope(affordance, nodeId, payload)
1015
+ if (reshapeScope) {
1016
+ useInteractionScope.getState().begin(reshapeScope)
1017
+ }
1018
+
1019
+ const captureTarget = event.currentTarget as Element
1020
+ dragRef.current = {
1021
+ pointerId: event.pointerId,
1022
+ captureTarget,
1023
+ handleId,
1024
+ session,
1025
+ snapshots,
1026
+ historyPaused: true,
1027
+ lastPlanPoint: initialPlanPoint,
1028
+ rotation,
1029
+ reshapeScopeNodeId: reshapeScope ? nodeId : undefined,
1030
+ }
1031
+ setActiveDragId(handleId)
1032
+ setSelection({ selectedIds: [nodeId] })
1033
+ captureTarget.setPointerCapture?.(event.pointerId)
1034
+ },
1035
+ [movingNode, setSelection],
1036
+ )
1037
+
1038
+ useEffect(() => {
1039
+ // Tear down the scope this drag opened (if any) — a reshaping scope for an
1040
+ // edit affordance, or a handle-drag scope for a rotate-arrow — matched by
1041
+ // node id so a concurrent scope from another path is never ended by mistake.
1042
+ const endReshapeScope = (drag: ActiveDrag) => {
1043
+ if (drag.reshapeScopeNodeId) {
1044
+ useInteractionScope
1045
+ .getState()
1046
+ .endIf(
1047
+ (s) =>
1048
+ (s.kind === 'reshaping' || s.kind === 'handle-drag') &&
1049
+ s.nodeId === drag.reshapeScopeNodeId,
1050
+ )
1051
+ }
1052
+ }
1053
+
1054
+ const cancelActiveDrag = (pointerId?: number, clearDragFeedback = true) =>
1055
+ cancelFloorplanAffordanceDrag(
1056
+ dragRef,
1057
+ {
1058
+ restoreSnapshots: (snapshots) =>
1059
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots)),
1060
+ resumeHistory: () => resumeSceneHistory(useScene),
1061
+ clearPreview: (id) => {
1062
+ useLiveNodeOverrides.getState().clear(id)
1063
+ useLiveTransforms.getState().clear(id)
1064
+ },
1065
+ clearSnapFeedback: clearSurfacePlanSnapFeedback,
1066
+ endReshapeScope,
1067
+ clearDragFeedback: clearDragFeedback
1068
+ ? () => {
1069
+ setActiveDragId(null)
1070
+ setRotationOverlay(null)
1071
+ }
1072
+ : undefined,
1073
+ },
1074
+ pointerId,
1075
+ )
1076
+
1077
+ const onPointerMove = (event: PointerEvent) => {
1078
+ const drag = dragRef.current
1079
+ if (!drag || event.pointerId !== drag.pointerId) return
1080
+
1081
+ const planPoint = clientToPlan(event.clientX, event.clientY)
1082
+ if (!planPoint) return
1083
+
1084
+ drag.lastPlanPoint = planPoint
1085
+ drag.session.apply({
1086
+ planPoint,
1087
+ modifiers: {
1088
+ shiftKey: event.shiftKey,
1089
+ altKey: event.altKey,
1090
+ ctrlKey: event.ctrlKey,
1091
+ metaKey: event.metaKey,
1092
+ },
1093
+ })
1094
+
1095
+ // Live rotation readout. Sweep from the bearing at grab to the
1096
+ // current pointer bearing around the pivot — the same measurement
1097
+ // every rotate affordance applies — and surface it as a wedge +
1098
+ // degree chip. Suppressed below ~0.5° so a fresh grab doesn't flash
1099
+ // a zero-width sliver.
1100
+ const rot = drag.rotation
1101
+ if (rot) {
1102
+ const current = Math.atan2(planPoint[1] - rot.pivot[1], planPoint[0] - rot.pivot[0])
1103
+ let delta = current - rot.initialAngle
1104
+ while (delta > Math.PI) delta -= 2 * Math.PI
1105
+ while (delta < -Math.PI) delta += 2 * Math.PI
1106
+ // Match the affordance's 15° angle step (Shift = free) so the wedge +
1107
+ // degree chip read the committed rotation, not the raw pointer bearing.
1108
+ delta = snapDirectRotationDelta(delta, event.shiftKey)
1109
+ if (Math.abs(delta) < 0.0087) {
1110
+ setRotationOverlay(null)
1111
+ } else {
1112
+ setRotationOverlay({
1113
+ pivot: rot.pivot,
1114
+ startAngle: rot.initialAngle,
1115
+ endAngle: rot.initialAngle + delta,
1116
+ radius: rot.radius,
1117
+ sweep: Math.abs(delta),
1118
+ })
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ const onPointerUp = (event: PointerEvent) => {
1124
+ const drag = dragRef.current
1125
+ if (!drag || event.pointerId !== drag.pointerId) return
1126
+
1127
+ const commitValid = drag.session.canCommit()
1128
+
1129
+ // Sessions with a `commit` hook own their atomic write (e.g.
1130
+ // affordances that publish to `useLiveNodeOverrides` during
1131
+ // `apply()` and never touch scene mid-drag). Mirrors the move
1132
+ // overlay's `session.commit` path — revert untracked (no-op when
1133
+ // the session never wrote to scene), resume history, then let
1134
+ // the session do the tracked write.
1135
+ if (commitValid && drag.session.commit) {
1136
+ useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
1137
+ if (drag.historyPaused) {
1138
+ resumeSceneHistory(useScene)
1139
+ drag.historyPaused = false
1140
+ }
1141
+ drag.session.commit()
1142
+ sfxEmitter.emit('sfx:structure-build')
1143
+ clearSurfacePlanSnapFeedback()
1144
+ endReshapeScope(drag)
1145
+ dragRef.current = null
1146
+ setActiveDragId(null)
1147
+ setRotationOverlay(null)
1148
+ return
1149
+ }
1150
+
1151
+ // Legacy compatibility for sessions that still wrote preview state into
1152
+ // `useScene` during `apply()`: capture the final state BEFORE the revert
1153
+ // so we know what to re-apply post-resume. New sessions should provide a
1154
+ // `commit()` hook and preview through live overrides/transforms instead.
1155
+ const sceneNodes = useScene.getState().nodes
1156
+ const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
1157
+ for (const snap of drag.snapshots) {
1158
+ const current = sceneNodes[snap.id]
1159
+ if (!current) continue
1160
+ const data: Record<string, unknown> = {}
1161
+ let changed = false
1162
+ for (const [key, before] of Object.entries(snap.data)) {
1163
+ const after = (current as unknown as Record<string, unknown>)[key]
1164
+ if (!deepEqual(before, after)) {
1165
+ data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
1166
+ changed = true
1167
+ }
1168
+ }
1169
+ if (changed) finalUpdates.push({ id: snap.id, data })
1170
+ }
1171
+
1172
+ if (commitValid && finalUpdates.length > 0) {
1173
+ // Legacy single-undo dance (mirrors the old 3D move-endpoint-tool):
1174
+ // 1. Revert to baseline while history is still paused (untracked).
1175
+ // 2. Resume history.
1176
+ // 3. Re-apply the final state — recorded as one tracked change.
1177
+ useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
1178
+ if (drag.historyPaused) {
1179
+ resumeSceneHistory(useScene)
1180
+ drag.historyPaused = false
1181
+ }
1182
+ useScene.getState().updateNodes(finalUpdates)
1183
+ sfxEmitter.emit('sfx:structure-build')
1184
+ } else {
1185
+ // Either no net change or canCommit() rejected — revert and
1186
+ // resume without committing. Also clear any live overrides
1187
+ // the session published (no-op when the session writes to
1188
+ // scene directly).
1189
+ useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
1190
+ if (drag.historyPaused) {
1191
+ resumeSceneHistory(useScene)
1192
+ drag.historyPaused = false
1193
+ }
1194
+ const overrides = useLiveNodeOverrides.getState()
1195
+ for (const id of drag.session.affectedIds) overrides.clear(id)
1196
+ }
1197
+
1198
+ clearSurfacePlanSnapFeedback()
1199
+ endReshapeScope(drag)
1200
+ dragRef.current = null
1201
+ setActiveDragId(null)
1202
+ setRotationOverlay(null)
1203
+ }
1204
+
1205
+ const onPointerCancel = (event: PointerEvent) => {
1206
+ cancelActiveDrag(event.pointerId)
1207
+ }
1208
+
1209
+ // Re-run the active session the moment a modifier key flips so behaviors
1210
+ // like the wall endpoint's Alt-detach / re-attach take effect immediately
1211
+ // instead of waiting for the next pointer move. `event.altKey` & co
1212
+ // already reflect the post-transition state on both keydown and keyup.
1213
+ const onModifierKeyChange = (event: KeyboardEvent) => {
1214
+ const drag = dragRef.current
1215
+ if (!drag || event.repeat) return
1216
+ if (
1217
+ event.key !== 'Alt' &&
1218
+ event.key !== 'Shift' &&
1219
+ event.key !== 'Control' &&
1220
+ event.key !== 'Meta'
1221
+ ) {
1222
+ return
1223
+ }
1224
+ drag.session.apply({
1225
+ planPoint: drag.lastPlanPoint,
1226
+ modifiers: {
1227
+ shiftKey: event.shiftKey,
1228
+ altKey: event.altKey,
1229
+ ctrlKey: event.ctrlKey,
1230
+ metaKey: event.metaKey,
1231
+ },
1232
+ })
1233
+ }
1234
+
1235
+ window.addEventListener('pointermove', onPointerMove)
1236
+ window.addEventListener('pointerup', onPointerUp)
1237
+ window.addEventListener('pointercancel', onPointerCancel)
1238
+ window.addEventListener('keydown', onModifierKeyChange)
1239
+ window.addEventListener('keyup', onModifierKeyChange)
1240
+ const unsubscribeToolCancel = subscribeFloorplanAffordanceToolCancel(
1241
+ () => cancelActiveDrag(),
1242
+ markToolCancelConsumed,
1243
+ )
1244
+ return () => {
1245
+ window.removeEventListener('pointermove', onPointerMove)
1246
+ window.removeEventListener('pointerup', onPointerUp)
1247
+ window.removeEventListener('pointercancel', onPointerCancel)
1248
+ window.removeEventListener('keydown', onModifierKeyChange)
1249
+ window.removeEventListener('keyup', onModifierKeyChange)
1250
+ unsubscribeToolCancel()
1251
+ if (!cancelActiveDrag(undefined, false)) {
1252
+ clearSurfacePlanSnapFeedback()
1253
+ }
1254
+ }
1255
+ }, [])
1256
+
1257
+ const entries = floorplanData.entries
1258
+ if (entries.length === 0) return null
1259
+
1260
+ const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1
1261
+ const palette = renderCtx?.palette
1262
+
1263
+ return (
1264
+ // The outer wrapper stops `click` events that escape an entry's
1265
+ // `onClick={handleClickStop}`. The base+overlay split means
1266
+ // pointer-down can land on the base `<g>` and pointer-up on the
1267
+ // overlay `<g>` (selection mounts the overlay on top mid-gesture).
1268
+ // When the down/up targets differ, the browser dispatches `click`
1269
+ // to the lowest common ancestor — which sits ABOVE the entry-level
1270
+ // handler. Without this guard the click reaches the SVG's
1271
+ // `handleBackgroundClick`, which calls
1272
+ // `resolveFloorplanBackgroundSelection` → `clear-elements` (because
1273
+ // registry-driven items aren't in the legacy hit-test set) →
1274
+ // clearing the selection that pointer-down just set, so items
1275
+ // appear to "deselect themselves a fraction of a second after
1276
+ // clicking." Scoped to `onClick` so hover / drag / pointer events
1277
+ // still propagate normally inside the registry tree.
1278
+ <g
1279
+ className="floorplan-registry-layer"
1280
+ onClick={isOpeningPlacementActive ? undefined : handleClickStop}
1281
+ opacity={isAmbient ? 0.3 : undefined}
1282
+ style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
1283
+ >
1284
+ {/* Base pass — rank-sorted body geometry (polygons, paths, fills,
1285
+ strokes, hatches). Lower-rank kinds (zones) paint first so
1286
+ higher-rank kinds (slabs, then walls / items / shelves) layer
1287
+ on top in the expected document-order z-stack. */}
1288
+ <g className="floorplan-registry-base">
1289
+ {entries.map((entry) => (
1290
+ <FloorplanRegistryEntry
1291
+ activeDragId={handleIdForNode(activeDragId, entry.id)}
1292
+ activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
1293
+ floorplanVisible={floorplanVisible}
1294
+ geometryCacheRef={geometryCacheRef}
1295
+ hatchPatternId={renderCtx?.hatchPatternId}
1296
+ highlighted={highlightedIdSet.has(entry.id)}
1297
+ hovered={hoveredId === entry.id}
1298
+ hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1299
+ interactiveElevators={interactiveElevators}
1300
+ isMarqueeSelectionActive={isMarqueeSelectionActive}
1301
+ isOpeningPlacementActive={isOpeningPlacementActive}
1302
+ key={`base-${entry.id}`}
1303
+ levelDataCacheRef={levelDataCacheRef}
1304
+ levelNodeIdsByType={floorplanData.levelNodeIdsByType}
1305
+ moving={movingNode?.id === entry.id}
1306
+ node={entry.node}
1307
+ nodeId={entry.id}
1308
+ nodes={nodes}
1309
+ onClickStop={handleClickStop}
1310
+ onEntryPointerDown={handleEntryPointerDown}
1311
+ onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
1312
+ onHandleHoverChange={setHoveredHandleId}
1313
+ onHandleDoubleClick={commitAffordanceAction}
1314
+ onHandlePointerDown={startAffordanceDrag}
1315
+ onHoveredIdChange={setHoveredId}
1316
+ palette={palette}
1317
+ pass="base"
1318
+ sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
1319
+ selected={selectedIdSet.has(entry.id)}
1320
+ suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
1321
+ groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
1322
+ setMovingNode={setMovingNode}
1323
+ setMovingNodeOrigin={setMovingNodeOrigin}
1324
+ siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1325
+ unit={unit}
1326
+ unitsPerPixel={unitsPerPixel}
1327
+ visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
1328
+ ctxOverrides={entry.ctxOverrides}
1329
+ />
1330
+ ))}
1331
+ </g>
1332
+ {/* Overlay pass — interactive handles (vertex / midpoint / edge /
1333
+ move) and labels (text / dimensions). Painted after every base
1334
+ entry so polygon-editor chrome on a selected slab stays above
1335
+ neighbouring walls, and a zone name stays readable above the
1336
+ slab + wall geometry sitting on top of the zone. Each overlay
1337
+ still routes through the same selection-handling `<g>` so a
1338
+ click on a zone's name selects the zone. */}
1339
+ <g className="floorplan-registry-overlay">
1340
+ {entries.map((entry) => (
1341
+ <FloorplanRegistryEntry
1342
+ activeDragId={handleIdForNode(activeDragId, entry.id)}
1343
+ activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
1344
+ floorplanVisible={floorplanVisible}
1345
+ geometryCacheRef={geometryCacheRef}
1346
+ hatchPatternId={renderCtx?.hatchPatternId}
1347
+ highlighted={highlightedIdSet.has(entry.id)}
1348
+ hovered={hoveredId === entry.id}
1349
+ hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1350
+ interactiveElevators={interactiveElevators}
1351
+ isMarqueeSelectionActive={isMarqueeSelectionActive}
1352
+ isOpeningPlacementActive={isOpeningPlacementActive}
1353
+ key={`overlay-${entry.id}`}
1354
+ levelDataCacheRef={levelDataCacheRef}
1355
+ levelNodeIdsByType={floorplanData.levelNodeIdsByType}
1356
+ moving={movingNode?.id === entry.id}
1357
+ node={entry.node}
1358
+ nodeId={entry.id}
1359
+ nodes={nodes}
1360
+ onClickStop={handleClickStop}
1361
+ onEntryPointerDown={handleEntryPointerDown}
1362
+ onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
1363
+ onHandleHoverChange={setHoveredHandleId}
1364
+ onHandleDoubleClick={commitAffordanceAction}
1365
+ onHandlePointerDown={startAffordanceDrag}
1366
+ onHoveredIdChange={setHoveredId}
1367
+ palette={palette}
1368
+ pass="overlay"
1369
+ sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
1370
+ selected={selectedIdSet.has(entry.id)}
1371
+ suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
1372
+ groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
1373
+ setMovingNode={setMovingNode}
1374
+ setMovingNodeOrigin={setMovingNodeOrigin}
1375
+ siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1376
+ unit={unit}
1377
+ unitsPerPixel={unitsPerPixel}
1378
+ visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
1379
+ ctxOverrides={entry.ctxOverrides}
1380
+ />
1381
+ ))}
1382
+ </g>
1383
+ {/* Dashed group bbox — shows what a group drag carries along while a
1384
+ multi-selection exists, rides the live delta mid-drag, and doubles
1385
+ as the group's whole-area drag handle. */}
1386
+ <FloorplanGroupSelectionBox
1387
+ onPointerDown={handleGroupBoxPointerDown}
1388
+ onRotatePointerDown={handleGroupBoxRotatePointerDown}
1389
+ palette={palette}
1390
+ unitsPerPixel={unitsPerPixel}
1391
+ />
1392
+ {/* Transient live-rotation readout — drawn last so the wedge + degree
1393
+ chip sit above all handle chrome while a rotate-arrow is dragged. */}
1394
+ {rotationOverlay && palette ? (
1395
+ <RotationAngleOverlay
1396
+ overlay={rotationOverlay}
1397
+ palette={palette}
1398
+ sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
1399
+ unitsPerPixel={unitsPerPixel}
1400
+ />
1401
+ ) : null}
1402
+ </g>
1403
+ )
1404
+ })
1405
+
1406
+ type FloorplanRegistryEntryProps = {
1407
+ activeDragId: string | null
1408
+ activeRotateNodeId: AnyNodeId | null
1409
+ ctxOverrides: FloorplanContextOverrides | undefined
1410
+ floorplanVisible: boolean
1411
+ geometryCacheRef: { current: Map<string, CacheEntry> }
1412
+ hatchPatternId: string | undefined
1413
+ highlighted: boolean
1414
+ hovered: boolean
1415
+ hoveredHandleId: string | null
1416
+ interactiveElevators: unknown
1417
+ isMarqueeSelectionActive: boolean
1418
+ isOpeningPlacementActive: boolean
1419
+ levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> }
1420
+ levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
1421
+ moving: boolean
1422
+ node: AnyNode
1423
+ nodeId: AnyNodeId
1424
+ nodes: Record<string, AnyNode>
1425
+ /** Selected member of a multi-selection: hide its per-node edit chrome. */
1426
+ suppressHandles: boolean
1427
+ /** Transformable member of a multi-selection: advertise drag-to-move. */
1428
+ groupMoveCursor: boolean
1429
+ onClickStop: (event: React.MouseEvent<SVGGElement>) => void
1430
+ onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
1431
+ onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
1432
+ onHandleHoverChange: (id: string | null) => void
1433
+ onHandleDoubleClick: (
1434
+ nodeId: AnyNodeId,
1435
+ affordance: string,
1436
+ payload: unknown,
1437
+ event: ReactMouseEvent<SVGElement>,
1438
+ ) => void
1439
+ onHandlePointerDown: (
1440
+ nodeId: AnyNodeId,
1441
+ handleId: string,
1442
+ affordance: string,
1443
+ payload: unknown,
1444
+ event: ReactPointerEvent<SVGGElement>,
1445
+ rotationPivot?: FloorplanPoint,
1446
+ ) => void
1447
+ onHoveredIdChange: (id: AnyNodeId | null) => void
1448
+ palette: FloorplanPalette | undefined
1449
+ pass: FloorplanRenderPass
1450
+ sceneRotationDeg: number
1451
+ selected: boolean
1452
+ setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
1453
+ setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
1454
+ siblingEpoch: number
1455
+ unit: 'metric' | 'imperial'
1456
+ unitsPerPixel: number
1457
+ visibilityRootId: AnyNodeId | undefined
1458
+ }
1459
+
1460
+ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1461
+ activeDragId,
1462
+ activeRotateNodeId,
1463
+ ctxOverrides,
1464
+ floorplanVisible,
1465
+ geometryCacheRef,
1466
+ hatchPatternId,
1467
+ highlighted,
1468
+ hovered,
1469
+ hoveredHandleId,
1470
+ interactiveElevators,
1471
+ isMarqueeSelectionActive,
1472
+ isOpeningPlacementActive,
1473
+ levelDataCacheRef,
1474
+ levelNodeIdsByType,
1475
+ moving,
1476
+ node,
1477
+ nodeId,
1478
+ nodes,
1479
+ suppressHandles,
1480
+ groupMoveCursor,
1481
+ onClickStop,
1482
+ onEntryPointerDown,
1483
+ onGroupMovePointerDown,
1484
+ onHandleHoverChange,
1485
+ onHandleDoubleClick,
1486
+ onHandlePointerDown,
1487
+ onHoveredIdChange,
1488
+ palette,
1489
+ pass,
1490
+ sceneRotationDeg,
1491
+ selected,
1492
+ setMovingNode,
1493
+ setMovingNodeOrigin,
1494
+ siblingEpoch,
1495
+ unit,
1496
+ unitsPerPixel,
1497
+ visibilityRootId,
1498
+ }: FloorplanRegistryEntryProps): React.ReactElement | null {
1499
+ const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
1500
+ const liveOverride = useLiveNodeOverrides((s) =>
1501
+ floorplanVisible ? s.overrides.get(nodeId) : undefined,
1502
+ )
1503
+ const liveOverrides = floorplanVisible
1504
+ ? useLiveNodeOverrides.getState().overrides
1505
+ : EMPTY_LIVE_OVERRIDES
1506
+
1507
+ const handlePointerDown = useCallback(
1508
+ (event: ReactPointerEvent<SVGGElement>) => onEntryPointerDown(nodeId, event),
1509
+ [nodeId, onEntryPointerDown],
1510
+ )
1511
+
1512
+ // Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
1513
+ // the highlight halo in 3D as well as registry floor-plan hover strokes.
1514
+ const handlePointerEnter = useCallback(() => {
1515
+ const node = useScene.getState().nodes[nodeId]
1516
+ onHoveredIdChange(
1517
+ node
1518
+ ? resolveSelectionProxyId(
1519
+ node,
1520
+ useScene.getState().nodes as Record<string, AnyNode | undefined>,
1521
+ )
1522
+ : nodeId,
1523
+ )
1524
+ }, [nodeId, onHoveredIdChange])
1525
+
1526
+ const handlePointerLeave = useCallback(() => {
1527
+ const node = useScene.getState().nodes[nodeId]
1528
+ const targetId = node
1529
+ ? resolveSelectionProxyId(
1530
+ node,
1531
+ useScene.getState().nodes as Record<string, AnyNode | undefined>,
1532
+ )
1533
+ : nodeId
1534
+ if (useViewer.getState().hoveredId === targetId) onHoveredIdChange(null)
1535
+ }, [nodeId, onHoveredIdChange])
1536
+
1537
+ const handleHandlePointerDown = useCallback(
1538
+ (
1539
+ affordance: string,
1540
+ payload: unknown,
1541
+ event: ReactPointerEvent<SVGGElement>,
1542
+ rotationPivot?: FloorplanPoint,
1543
+ ) => {
1544
+ onHandlePointerDown(
1545
+ nodeId,
1546
+ makeHandleId(nodeId, payload),
1547
+ affordance,
1548
+ payload,
1549
+ event,
1550
+ rotationPivot,
1551
+ )
1552
+ },
1553
+ [nodeId, onHandlePointerDown],
1554
+ )
1555
+
1556
+ const handleHandleDoubleClick = useCallback(
1557
+ (affordance: string, payload: unknown, event: ReactMouseEvent<SVGElement>) => {
1558
+ onHandleDoubleClick(nodeId, affordance, payload, event)
1559
+ },
1560
+ [nodeId, onHandleDoubleClick],
1561
+ )
1562
+
1563
+ const handleMoveHandlePointerDown = useCallback(
1564
+ (event: ReactPointerEvent<SVGGElement>) => {
1565
+ if (event.button !== 0) return
1566
+ const currentNode = useScene.getState().nodes[nodeId]
1567
+ if (!currentNode) return
1568
+ event.preventDefault()
1569
+ event.stopPropagation()
1570
+ suppressBoxSelectForPointer(event)
1571
+ // In a multi-selection the move dot drives the group session, matching
1572
+ // the body-drag gesture — the whole selection slides, not one member.
1573
+ if (onGroupMovePointerDown(nodeId, event)) return
1574
+ sfxEmitter.emit('sfx:item-pick')
1575
+ setMovingNode(currentNode as never)
1576
+ // Claim 2D ownership of this move at the source. `setMovingNode`
1577
+ // resets the origin to null, so this must follow it.
1578
+ setMovingNodeOrigin('2d')
1579
+ },
1580
+ [nodeId, onGroupMovePointerDown, setMovingNode, setMovingNodeOrigin],
1581
+ )
1582
+
1583
+ const cacheEntry = buildFloorplanEntryGeometry({
1584
+ ctxOverrides,
1585
+ geometryCache: geometryCacheRef.current,
1586
+ highlighted,
1587
+ hovered,
1588
+ interactiveElevators,
1589
+ levelDataCache: levelDataCacheRef.current,
1590
+ levelNodeIdsByType,
1591
+ live,
1592
+ liveOverride,
1593
+ liveOverrides,
1594
+ moving,
1595
+ node,
1596
+ nodeId,
1597
+ nodes,
1598
+ palette,
1599
+ selected,
1600
+ siblingEpoch,
1601
+ unit,
1602
+ visibilityRootId,
1603
+ })
1604
+ const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
1605
+ // Multi-selection shows highlight only: strip this member's edit handles /
1606
+ // dimension chrome (all of which live in the overlay pass) while keeping
1607
+ // its highlighted body geometry.
1608
+ const geometry =
1609
+ rawGeometry && suppressHandles && pass === 'overlay'
1610
+ ? stripHandleChrome(rawGeometry)
1611
+ : rawGeometry
1612
+ if (!geometry) return null
1613
+
1614
+ const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
1615
+ const entryPointerDown =
1616
+ isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handlePointerDown
1617
+
1618
+ return (
1619
+ <g
1620
+ className="floorplan-registry-entry"
1621
+ data-node-id={nodeId}
1622
+ onClick={entryClick}
1623
+ onPointerDown={entryPointerDown}
1624
+ onPointerEnter={handlePointerEnter}
1625
+ onPointerLeave={handlePointerLeave}
1626
+ style={groupMoveCursor ? MOVE_CURSOR_STYLE : POINTER_CURSOR_STYLE}
1627
+ >
1628
+ <InteractiveGeometry
1629
+ activeDragId={activeDragId}
1630
+ activeRotateNodeId={activeRotateNodeId}
1631
+ geometry={geometry}
1632
+ hatchPatternId={hatchPatternId}
1633
+ hoveredHandleId={hoveredHandleId}
1634
+ isMarqueeSelectionActive={isMarqueeSelectionActive}
1635
+ nodeId={nodeId}
1636
+ onHandleDoubleClick={handleHandleDoubleClick}
1637
+ onHandleHoverChange={onHandleHoverChange}
1638
+ onHandlePointerDown={handleHandlePointerDown}
1639
+ onMoveHandlePointerDown={handleMoveHandlePointerDown}
1640
+ palette={palette}
1641
+ sceneRotationDeg={sceneRotationDeg}
1642
+ unitsPerPixel={unitsPerPixel}
1643
+ />
1644
+ </g>
1645
+ )
1646
+ }, shallowPropsAreEqual)
1647
+
1648
+ type BuildFloorplanEntryGeometryArgs = {
1649
+ ctxOverrides: FloorplanContextOverrides | undefined
1650
+ geometryCache: Map<string, CacheEntry>
1651
+ highlighted: boolean
1652
+ hovered: boolean
1653
+ interactiveElevators: unknown
1654
+ levelDataCache: Map<string, LevelDataCacheEntry>
1655
+ levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
1656
+ live: LiveTransform | undefined
1657
+ liveOverride: LiveNodeOverrides | undefined
1658
+ liveOverrides: Map<string, LiveNodeOverrides>
1659
+ moving: boolean
1660
+ node: AnyNode
1661
+ nodeId: AnyNodeId
1662
+ nodes: Record<string, AnyNode>
1663
+ palette: FloorplanPalette | undefined
1664
+ selected: boolean
1665
+ siblingEpoch: number
1666
+ unit: 'metric' | 'imperial'
1667
+ visibilityRootId: AnyNodeId | undefined
1668
+ }
1669
+
1670
+ export function collectFloorplanDependencyNodes(
1671
+ def: AnyNodeDefinition,
1672
+ node: AnyNode,
1673
+ nodes: Record<string, AnyNode>,
1674
+ liveOverrides?: Map<string, LiveNodeOverrides>,
1675
+ ): AnyNode[] {
1676
+ return (def.floorplanDependencies?.(node) ?? []).flatMap((id) => {
1677
+ const dependency = nodes[id]
1678
+ if (!dependency) return []
1679
+ const dependencyOverride = liveOverrides?.get(dependency.id)
1680
+ const effectiveDependency = dependencyOverride
1681
+ ? ({ ...dependency, ...dependencyOverride } as AnyNode)
1682
+ : dependency
1683
+ const parent = dependency.parentId ? nodes[dependency.parentId] : undefined
1684
+ if (!parent) return [effectiveDependency]
1685
+ const parentOverride = liveOverrides?.get(parent.id)
1686
+ const effectiveParent = parentOverride ? ({ ...parent, ...parentOverride } as AnyNode) : parent
1687
+ return [effectiveDependency, effectiveParent]
1688
+ })
1689
+ }
1690
+
1691
+ function buildFloorplanEntryGeometry({
1692
+ ctxOverrides,
1693
+ geometryCache,
1694
+ highlighted,
1695
+ hovered,
1696
+ interactiveElevators,
1697
+ levelDataCache,
1698
+ levelNodeIdsByType,
1699
+ live,
1700
+ liveOverride,
1701
+ liveOverrides,
1702
+ moving,
1703
+ node,
1704
+ nodeId,
1705
+ nodes,
1706
+ palette,
1707
+ selected,
1708
+ siblingEpoch,
1709
+ unit,
1710
+ visibilityRootId,
1711
+ }: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
1712
+ const def = nodeRegistry.get(node.type)
1713
+ const builder = def?.floorplan
1714
+ if (!builder) return null
1715
+
1716
+ const visible = visibilityRootId
1717
+ ? isFloorplanHierarchyVisible(node, nodes, liveOverrides, visibilityRootId)
1718
+ : isFloorplanNodeVisible(node, liveOverride)
1719
+ if (!visible) {
1720
+ geometryCache.delete(nodeId)
1721
+ return null
1722
+ }
1723
+
1724
+ const dependsOnSiblingInputs = !!(
1725
+ def.floorplanDependsOnSiblings ||
1726
+ def.floorplanSiblingOverrides ||
1727
+ def.floorplanAffectedIds
1728
+ )
1729
+ const dependencyNodes = collectFloorplanDependencyNodes(def, node, nodes, liveOverrides)
1730
+ const deps: NodeDeps = {
1731
+ node,
1732
+ live,
1733
+ unit,
1734
+ selected,
1735
+ highlighted,
1736
+ hovered,
1737
+ moving,
1738
+ liveOverride,
1739
+ palette,
1740
+ siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
1741
+ // Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
1742
+ // committed state via `ctx`, so committed sibling edits still invalidate.
1743
+ committedNodes: dependsOnSiblingInputs ? nodes : null,
1744
+ dependencyNodes,
1745
+ interactiveElevators,
1746
+ }
1747
+ const cached = geometryCache.get(nodeId)
1748
+ if (cached && nodeDepsEqual(cached.deps, deps)) return cached
1749
+
1750
+ const applyLiveTransform = (sourceNode: AnyNode): AnyNode => {
1751
+ if (!live) return sourceNode
1752
+ const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position)
1753
+ const parentFrameProjection = nodeRegistry.get(sourceNode.type)?.capabilities?.movable
1754
+ ?.parentFrame?.floorplanLiveTransform
1755
+ if (parentFrameProjection) {
1756
+ return parentFrameProjection({ node: sourceNode, live })
1757
+ }
1758
+ if (sourceNode.type === 'door' || sourceNode.type === 'window') {
1759
+ const r = (sourceNode as { rotation?: unknown }).rotation
1760
+ return {
1761
+ ...sourceNode,
1762
+ position: live.position,
1763
+ rotation: Array.isArray(r)
1764
+ ? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
1765
+ : r,
1766
+ } as AnyNode
1767
+ }
1768
+ if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) {
1769
+ return applyPositionLiveTransform(sourceNode, live)
1770
+ }
1771
+ if (sourceNode.type === 'slab' || sourceNode.type === 'ceiling' || sourceNode.type === 'zone') {
1772
+ const dx = live.position[0]
1773
+ const dz = live.position[2]
1774
+ if (dx === 0 && dz === 0) return sourceNode
1775
+ const surface = sourceNode as {
1776
+ polygon: Array<[number, number]>
1777
+ holes?: Array<Array<[number, number]>>
1778
+ }
1779
+ return {
1780
+ ...sourceNode,
1781
+ polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
1782
+ holes: (surface.holes ?? []).map((h) =>
1783
+ h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
1784
+ ),
1785
+ } as AnyNode
1786
+ }
1787
+ return sourceNode
1788
+ }
1789
+
1790
+ const contextNodes = def.floorplanSiblingOverrides
1791
+ ? def.floorplanSiblingOverrides({
1792
+ nodeId,
1793
+ nodes,
1794
+ liveTransforms: useLiveTransforms.getState().transforms,
1795
+ liveOverrides,
1796
+ })
1797
+ : nodes
1798
+ const sourceNode = contextNodes !== nodes ? (contextNodes[nodeId] ?? node) : node
1799
+ const overrideNode = liveOverride ? ({ ...sourceNode, ...liveOverride } as AnyNode) : sourceNode
1800
+ const effectiveNode = applyLiveTransform(overrideNode)
1801
+ const levelData = getFloorplanLevelData(
1802
+ node.type,
1803
+ nodes,
1804
+ liveOverrides,
1805
+ levelNodeIdsByType,
1806
+ levelDataCache,
1807
+ )
1808
+ const viewState = {
1809
+ selected,
1810
+ unit,
1811
+ highlighted,
1812
+ hovered,
1813
+ moving,
1814
+ palette,
1815
+ }
1816
+ const resolveContextNode = <N = AnyNode>(rid: AnyNodeId): N | undefined => {
1817
+ const contextNode = contextNodes[rid]
1818
+ if (!contextNode) return undefined
1819
+ const contextOverride = liveOverrides.get(contextNode.id)
1820
+ return (contextOverride ? { ...contextNode, ...contextOverride } : contextNode) as N
1821
+ }
1822
+ const ctx: GeometryContext = ctxOverrides
1823
+ ? {
1824
+ resolve: resolveContextNode,
1825
+ children: ctxOverrides.children,
1826
+ siblings: ctxOverrides.siblings,
1827
+ parent: ctxOverrides.parent,
1828
+ levelData,
1829
+ viewState: palette
1830
+ ? {
1831
+ selected,
1832
+ unit,
1833
+ highlighted,
1834
+ hovered,
1835
+ moving,
1836
+ palette,
1837
+ }
1838
+ : undefined,
1839
+ }
1840
+ : {
1841
+ ...buildContext(effectiveNode, contextNodes, viewState, levelData),
1842
+ resolve: resolveContextNode,
1843
+ }
1844
+ const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
1845
+ effectiveNode,
1846
+ ctx,
1847
+ )
1848
+ const { base, overlay } = geometry
1849
+ ? splitFloorplanOverlay(geometry)
1850
+ : { base: null, overlay: null }
1851
+ const entry: CacheEntry = { deps, base, overlay, node: effectiveNode }
1852
+ geometryCache.set(nodeId, entry)
1853
+ return entry
1854
+ }
1855
+
1856
+ export function getFloorplanLevelData(
1857
+ type: string,
1858
+ nodes: Record<string, AnyNode>,
1859
+ liveOverrides: Map<string, LiveNodeOverrides>,
1860
+ levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>,
1861
+ levelDataCache: Map<string, LevelDataCacheEntry>,
1862
+ ): unknown {
1863
+ const def = nodeRegistry.get(type)
1864
+ if (!def?.computeFloorplanLevelData) return undefined
1865
+ const ids = levelNodeIdsByType.get(type)
1866
+ const sampleId = ids?.[0]
1867
+ if (!ids || !sampleId) return undefined
1868
+
1869
+ const cached = levelDataCache.get(type)
1870
+ if (
1871
+ cached &&
1872
+ cached.nodes === nodes &&
1873
+ cached.liveOverrides === liveOverrides &&
1874
+ cached.ids === ids
1875
+ ) {
1876
+ return cached.value
1877
+ }
1878
+
1879
+ const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook
1880
+ const contextNodes = def.floorplanSiblingOverrides
1881
+ ? def.floorplanSiblingOverrides({
1882
+ nodeId: sampleId,
1883
+ nodes,
1884
+ liveTransforms: useLiveTransforms.getState().transforms,
1885
+ liveOverrides,
1886
+ })
1887
+ : nodes
1888
+ const siblings: AnyNode[] = []
1889
+ for (const id of ids) {
1890
+ const sibling = contextNodes[id]
1891
+ if (sibling?.type === type) siblings.push(sibling)
1892
+ }
1893
+ const value = computeLevelData({ siblings, nodes: contextNodes })
1894
+ levelDataCache.set(type, { nodes, liveOverrides, ids, value })
1895
+ return value
1896
+ }
1897
+
1898
+ // ── Interactive geometry walker ──────────────────────────────────────
1899
+
1900
+ type InteractiveGeometryProps = {
1901
+ geometry: FloorplanGeometry
1902
+ unitsPerPixel: number
1903
+ palette: FloorplanPalette | undefined
1904
+ hatchPatternId: string | undefined
1905
+ hoveredHandleId: string | null
1906
+ activeDragId: string | null
1907
+ activeRotateNodeId: AnyNodeId | null
1908
+ isMarqueeSelectionActive: boolean
1909
+ nodeId: AnyNodeId
1910
+ sceneRotationDeg: number
1911
+ onHandleHoverChange: (id: string | null) => void
1912
+ onHandleDoubleClick: (
1913
+ affordance: string,
1914
+ payload: unknown,
1915
+ event: ReactMouseEvent<SVGElement>,
1916
+ ) => void
1917
+ onHandlePointerDown: (
1918
+ affordance: string,
1919
+ payload: unknown,
1920
+ event: ReactPointerEvent<SVGGElement>,
1921
+ // Forwarded only by rotate-arrow handles — the pivot the drag turns
1922
+ // the node around, used to drive the live angle wedge + degree chip.
1923
+ rotationPivot?: FloorplanPoint,
1924
+ ) => void
1925
+ onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
1926
+ }
1927
+
1928
+ const InteractiveGeometry = memo(function InteractiveGeometry({
1929
+ geometry,
1930
+ unitsPerPixel,
1931
+ palette,
1932
+ hatchPatternId,
1933
+ hoveredHandleId,
1934
+ activeDragId,
1935
+ activeRotateNodeId,
1936
+ isMarqueeSelectionActive,
1937
+ nodeId,
1938
+ sceneRotationDeg,
1939
+ onHandleDoubleClick,
1940
+ onHandleHoverChange,
1941
+ onHandlePointerDown,
1942
+ onMoveHandlePointerDown,
1943
+ }: InteractiveGeometryProps): React.ReactElement {
1944
+ return renderInteractive(geometry, 0)
1945
+
1946
+ function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement {
1947
+ switch (g.kind) {
1948
+ case 'group': {
1949
+ const transform = formatGroupTransform(g.transform)
1950
+ return (
1951
+ <g key={keyHint} transform={transform}>
1952
+ {g.children.map((child, i) => renderInteractive(child, i))}
1953
+ </g>
1954
+ )
1955
+ }
1956
+ case 'hatch': {
1957
+ if (!hatchPatternId) return <></>
1958
+ return (
1959
+ <polygon
1960
+ fill={`url(#${hatchPatternId})`}
1961
+ key={keyHint}
1962
+ opacity={g.opacity}
1963
+ pointerEvents="none"
1964
+ points={g.points.map(([x, y]) => `${x},${y}`).join(' ')}
1965
+ />
1966
+ )
1967
+ }
1968
+ case 'hit-line': {
1969
+ return (
1970
+ <line
1971
+ key={keyHint}
1972
+ pointerEvents={isMarqueeSelectionActive ? 'none' : (g.pointerEvents ?? 'stroke')}
1973
+ stroke="transparent"
1974
+ strokeLinecap="round"
1975
+ strokeWidth={g.strokeWidthPx * unitsPerPixel}
1976
+ style={{ cursor: g.cursor ?? 'pointer' }}
1977
+ vectorEffect="non-scaling-stroke"
1978
+ x1={g.x1}
1979
+ x2={g.x2}
1980
+ y1={g.y1}
1981
+ y2={g.y2}
1982
+ />
1983
+ )
1984
+ }
1985
+ case 'endpoint-handle': {
1986
+ if (!palette) return <></>
1987
+ const handleId = makeHandleId(nodeId, g.payload)
1988
+ const doubleClickAffordance = floorplanHandleDoubleClickAffordance(g)
1989
+ const isHovered = hoveredHandleId === handleId
1990
+ const isActive = activeDragId === handleId
1991
+ // Variant picks the colour-set. Endpoint dots use the orange
1992
+ // legacy palette; curve sagitta dots use the teal set so users
1993
+ // can tell them apart at a glance.
1994
+ const isCurve = g.variant === 'curve'
1995
+ const stroke = isCurve
1996
+ ? palette.curveHandleStroke
1997
+ : isActive
1998
+ ? palette.endpointHandleActiveStroke
1999
+ : palette.endpointHandleStroke
2000
+ const hoverStroke = isCurve
2001
+ ? palette.curveHandleHoverStroke
2002
+ : isActive
2003
+ ? palette.endpointHandleActiveStroke
2004
+ : palette.endpointHandleHoverStroke
2005
+ const fill = isCurve
2006
+ ? palette.curveHandleFill
2007
+ : isActive
2008
+ ? palette.endpointHandleActiveFill
2009
+ : palette.endpointHandleFill
2010
+ const outerRadius =
2011
+ (isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) *
2012
+ unitsPerPixel
2013
+ const dotRadius =
2014
+ (isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) *
2015
+ unitsPerPixel
2016
+ return (
2017
+ <g
2018
+ key={keyHint}
2019
+ onClick={(e) => e.stopPropagation()}
2020
+ onPointerEnter={() => onHandleHoverChange(handleId)}
2021
+ onPointerLeave={() => onHandleHoverChange(null)}
2022
+ >
2023
+ <circle
2024
+ cx={g.point[0]}
2025
+ cy={g.point[1]}
2026
+ fill="none"
2027
+ pointerEvents="none"
2028
+ r={outerRadius}
2029
+ stroke={hoverStroke}
2030
+ strokeOpacity={isActive ? 0.24 : 0.16}
2031
+ strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * unitsPerPixel}
2032
+ style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
2033
+ vectorEffect="non-scaling-stroke"
2034
+ />
2035
+ <circle
2036
+ cx={g.point[0]}
2037
+ cy={g.point[1]}
2038
+ fill="none"
2039
+ pointerEvents="none"
2040
+ r={outerRadius}
2041
+ stroke={hoverStroke}
2042
+ strokeOpacity={isActive ? 0.72 : 0.52}
2043
+ strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
2044
+ style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
2045
+ vectorEffect="non-scaling-stroke"
2046
+ />
2047
+ <circle
2048
+ cx={g.point[0]}
2049
+ cy={g.point[1]}
2050
+ fill={fill}
2051
+ fillOpacity={0.96}
2052
+ pointerEvents="none"
2053
+ r={outerRadius}
2054
+ stroke={stroke}
2055
+ strokeWidth="0.05"
2056
+ vectorEffect="non-scaling-stroke"
2057
+ />
2058
+ <circle
2059
+ cx={g.point[0]}
2060
+ cy={g.point[1]}
2061
+ fill={stroke}
2062
+ pointerEvents="none"
2063
+ r={dotRadius}
2064
+ vectorEffect="non-scaling-stroke"
2065
+ />
2066
+ <circle
2067
+ cx={g.point[0]}
2068
+ cy={g.point[1]}
2069
+ fill="transparent"
2070
+ onDoubleClick={
2071
+ doubleClickAffordance
2072
+ ? (event) => {
2073
+ event.preventDefault()
2074
+ event.stopPropagation()
2075
+ onHandleDoubleClick(doubleClickAffordance, g.payload, event)
2076
+ }
2077
+ : undefined
2078
+ }
2079
+ onPointerDown={(e) =>
2080
+ onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
2081
+ }
2082
+ pointerEvents="all"
2083
+ r={outerRadius}
2084
+ stroke="transparent"
2085
+ strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel}
2086
+ style={{ cursor: 'pointer' }}
2087
+ vectorEffect="non-scaling-stroke"
2088
+ />
2089
+ </g>
2090
+ )
2091
+ }
2092
+ case 'move-handle': {
2093
+ if (!palette) return <></>
2094
+ const moveHandleId = `${nodeId}:move`
2095
+ const isHovered = hoveredHandleId === moveHandleId
2096
+ // World-relative sizing: the move dot is anchored to the door,
2097
+ // not to the screen, so it grows when the user zooms in and
2098
+ // shrinks when they zoom out — same scaling rule as the door
2099
+ // footprint itself. Sizes are tuned for a ~0.9 m door at default
2100
+ // zoom; the ratios match the legacy 13/15/6/16/7/18 px stack.
2101
+ const baseRadius = 0.1
2102
+ const hoverRadius = 0.115
2103
+ const outerRadius = isHovered ? hoverRadius : baseRadius
2104
+ const dotRadius = 0.045
2105
+ const fillStroke = 0.005
2106
+ const glowStroke = 0.12
2107
+ const ringStroke = 0.055
2108
+ const hitStroke = 0.14
2109
+ // Same 5-circle stack as the orange endpoint dot — hover glow +
2110
+ // hover ring + filled outer + inner dot + transparent hit. On
2111
+ // pointer-down, the layer calls `setMovingNode(node)`, which
2112
+ // FloorplanRegistryMoveOverlay picks up and routes to the
2113
+ // kind's `def.floorplanMoveTarget`.
2114
+ return (
2115
+ <g
2116
+ key={keyHint}
2117
+ onClick={(e) => e.stopPropagation()}
2118
+ onPointerEnter={() => onHandleHoverChange(moveHandleId)}
2119
+ onPointerLeave={() => onHandleHoverChange(null)}
2120
+ >
2121
+ <circle
2122
+ cx={g.point[0]}
2123
+ cy={g.point[1]}
2124
+ fill="none"
2125
+ pointerEvents="none"
2126
+ r={outerRadius}
2127
+ stroke={palette.endpointHandleHoverStroke}
2128
+ strokeOpacity={0.16}
2129
+ strokeWidth={glowStroke}
2130
+ style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }}
2131
+ />
2132
+ <circle
2133
+ cx={g.point[0]}
2134
+ cy={g.point[1]}
2135
+ fill="none"
2136
+ pointerEvents="none"
2137
+ r={outerRadius}
2138
+ stroke={palette.endpointHandleHoverStroke}
2139
+ strokeOpacity={0.52}
2140
+ strokeWidth={ringStroke}
2141
+ style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }}
2142
+ />
2143
+ <circle
2144
+ cx={g.point[0]}
2145
+ cy={g.point[1]}
2146
+ fill={palette.endpointHandleFill}
2147
+ fillOpacity={0.96}
2148
+ pointerEvents="none"
2149
+ r={outerRadius}
2150
+ stroke={palette.endpointHandleStroke}
2151
+ strokeWidth={fillStroke}
2152
+ />
2153
+ <circle
2154
+ cx={g.point[0]}
2155
+ cy={g.point[1]}
2156
+ fill={palette.endpointHandleStroke}
2157
+ pointerEvents="none"
2158
+ r={dotRadius}
2159
+ />
2160
+ <circle
2161
+ cx={g.point[0]}
2162
+ cy={g.point[1]}
2163
+ fill="transparent"
2164
+ onPointerDown={(e) => onMoveHandlePointerDown(e as ReactPointerEvent<SVGGElement>)}
2165
+ pointerEvents="all"
2166
+ r={outerRadius}
2167
+ stroke="transparent"
2168
+ strokeWidth={hitStroke}
2169
+ style={{ cursor: 'move' }}
2170
+ />
2171
+ </g>
2172
+ )
2173
+ }
2174
+ case 'rotate-arrow': {
2175
+ if (!palette) return <></>
2176
+ // 2D counterpart of the 3D `arc-resize` rotate gizmo. Local
2177
+ // frame: +X is the radial-outward direction (away from the
2178
+ // pivot); the arc bows in that direction with arrowheads on
2179
+ // each end pointing tangentially in opposite directions —
2180
+ // "rotate either way."
2181
+ const handleId = makeHandleId(nodeId, g.payload)
2182
+ const isHovered = hoveredHandleId === handleId || activeRotateNodeId === nodeId
2183
+ // Arc geometry (all values precomputed for a 72° arc of
2184
+ // radius 0.13 — comparable footprint to `move-arrow`).
2185
+ const R = 0.13
2186
+ const halfSpan = Math.PI / 5
2187
+ const cosH = Math.cos(halfSpan)
2188
+ const sinH = Math.sin(halfSpan)
2189
+ const endY = R * sinH
2190
+ const headLen = 0.06
2191
+ const headHalfBase = 0.045
2192
+ // End-1 (top) arrowhead — tip along CCW tangent.
2193
+ const t1x = -sinH * headLen
2194
+ const t1y = endY + cosH * headLen
2195
+ const b1ax = cosH * headHalfBase
2196
+ const b1ay = endY + sinH * headHalfBase
2197
+ const b1bx = -cosH * headHalfBase
2198
+ const b1by = endY - sinH * headHalfBase
2199
+ // End-2 (bottom) arrowhead — mirror of End-1.
2200
+ const t2x = -sinH * headLen
2201
+ const t2y = -endY - cosH * headLen
2202
+ const b2ax = cosH * headHalfBase
2203
+ const b2ay = -endY - sinH * headHalfBase
2204
+ const b2bx = -cosH * headHalfBase
2205
+ const b2by = -endY + sinH * headHalfBase
2206
+ const arcPath = `M 0 ${-endY} A ${R} ${R} 0 0 1 0 ${endY}`
2207
+ const head1 = `M ${t1x} ${t1y} L ${b1ax} ${b1ay} L ${b1bx} ${b1by} Z`
2208
+ const head2 = `M ${t2x} ${t2y} L ${b2ax} ${b2ay} L ${b2bx} ${b2by} Z`
2209
+ const fill = isHovered ? '#a5b4fc' : '#8381ed'
2210
+ const strokeWidthPx = isHovered ? 2.4 : 1.8
2211
+ const angleDeg = (g.angle * 180) / Math.PI
2212
+ const affordance = g.affordance
2213
+ const payload = g.payload
2214
+ const pivot = g.pivot
2215
+ return (
2216
+ <g
2217
+ key={keyHint}
2218
+ onClick={(e) => e.stopPropagation()}
2219
+ transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${angleDeg})`}
2220
+ >
2221
+ <path
2222
+ d={arcPath}
2223
+ fill="none"
2224
+ pointerEvents="none"
2225
+ stroke={fill}
2226
+ strokeLinecap="round"
2227
+ strokeWidth={strokeWidthPx}
2228
+ vectorEffect="non-scaling-stroke"
2229
+ />
2230
+ <path d={head1} fill={fill} pointerEvents="none" />
2231
+ <path d={head2} fill={fill} pointerEvents="none" />
2232
+ {/* Hit target — fat invisible stroke along the arc + filled
2233
+ triangles at the heads so the user can grab anywhere on
2234
+ the visible icon. */}
2235
+ <path
2236
+ d={arcPath}
2237
+ fill="none"
2238
+ onPointerDown={(e) =>
2239
+ onHandlePointerDown(
2240
+ affordance,
2241
+ payload,
2242
+ e as ReactPointerEvent<SVGPathElement>,
2243
+ pivot,
2244
+ )
2245
+ }
2246
+ onPointerEnter={() => onHandleHoverChange(handleId)}
2247
+ onPointerLeave={() => onHandleHoverChange(null)}
2248
+ pointerEvents="stroke"
2249
+ stroke="transparent"
2250
+ strokeWidth={0.06}
2251
+ style={{ cursor: 'grab' }}
2252
+ />
2253
+ <path
2254
+ d={`${head1} ${head2}`}
2255
+ fill="transparent"
2256
+ onPointerDown={(e) =>
2257
+ onHandlePointerDown(
2258
+ affordance,
2259
+ payload,
2260
+ e as ReactPointerEvent<SVGPathElement>,
2261
+ pivot,
2262
+ )
2263
+ }
2264
+ onPointerEnter={() => onHandleHoverChange(handleId)}
2265
+ onPointerLeave={() => onHandleHoverChange(null)}
2266
+ pointerEvents="fill"
2267
+ style={{ cursor: 'grab' }}
2268
+ />
2269
+ </g>
2270
+ )
2271
+ }
2272
+ case 'move-arrow': {
2273
+ if (!palette) return <></>
2274
+ // Affordance-routed arrows (door width-resize) get a per-payload
2275
+ // handle id so each side can hover independently; default
2276
+ // (move-flow) arrows share the node's :move id like the dot.
2277
+ const handleId = g.affordance ? makeHandleId(nodeId, g.payload) : `${nodeId}:move`
2278
+ const isHovered = hoveredHandleId === handleId
2279
+ // Arrow geometry in plan units (meters) — scales with the scene
2280
+ // so it shrinks on zoom-out and grows on zoom-in, matching the
2281
+ // wall it accompanies. Composed of a rectangular shaft + triangular
2282
+ // head, drawn as a single path for a clean fill + stroke outline.
2283
+ const sl = 0.1 // shaft length (shortened body)
2284
+ const hl = 0.12 // head length
2285
+ const sh = 0.04 // shaft half-height
2286
+ const hh = 0.1 // head half-height
2287
+ // Inset the shaft start so the arrow sits a little off the wall
2288
+ // body (matches the 3D `HANDLE_OFFSET`).
2289
+ const bi = 0.03 // base inset
2290
+ const arrowD = `M ${bi},${-sh} L ${bi + sl},${-sh} L ${bi + sl},${-hh} L ${bi + sl + hl},0 L ${bi + sl},${hh} L ${bi + sl},${sh} L ${bi},${sh} Z`
2291
+ // Indigo palette to match the 3D `WallMoveSideHandles` arrows
2292
+ // (`ARROW_COLOR` / `ARROW_HOVER_COLOR`) and the corner-sphere
2293
+ // accent in `floating-action-menu.tsx`.
2294
+ const fill = isHovered ? '#a5b4fc' : '#8381ed'
2295
+ const angleDeg = (g.angle * 180) / Math.PI
2296
+ const cursor = g.affordance ? 'ew-resize' : 'move'
2297
+ const affordance = g.affordance
2298
+ const payload = g.payload
2299
+ // No hover-grow: a scaling transform would enlarge the hit area
2300
+ // too, letting clicks just outside the visible arrow still start
2301
+ // a drag. Hover feedback is colour-only so the click region
2302
+ // always matches the painted arrow shape exactly.
2303
+ return (
2304
+ <g
2305
+ key={keyHint}
2306
+ onClick={(e) => e.stopPropagation()}
2307
+ transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${angleDeg})`}
2308
+ >
2309
+ <path d={arrowD} fill={fill} pointerEvents="none" />
2310
+ <path
2311
+ d={arrowD}
2312
+ fill="transparent"
2313
+ onPointerDown={(e) => {
2314
+ if (affordance) {
2315
+ onHandlePointerDown(affordance, payload, e as ReactPointerEvent<SVGGElement>)
2316
+ } else {
2317
+ onMoveHandlePointerDown(e as ReactPointerEvent<SVGGElement>)
2318
+ }
2319
+ }}
2320
+ onPointerEnter={() => onHandleHoverChange(handleId)}
2321
+ onPointerLeave={() => onHandleHoverChange(null)}
2322
+ pointerEvents="fill"
2323
+ style={{ cursor }}
2324
+ />
2325
+ </g>
2326
+ )
2327
+ }
2328
+ case 'edge-handle': {
2329
+ if (!palette) return <></>
2330
+ const handleId = makeHandleId(nodeId, g.payload)
2331
+ const isHovered = hoveredHandleId === handleId
2332
+ const isActive = activeDragId === handleId
2333
+ const showVisible = isHovered || isActive
2334
+ const stroke = isActive ? palette.endpointHandleActiveStroke : palette.selectedStroke
2335
+ // Stroke widths in screen pixels — non-scaling-stroke keeps the
2336
+ // hit area + glow consistent at every zoom.
2337
+ const glowWidthPx = 14
2338
+ const visibleWidthPx = 3
2339
+ const hitWidthPx = 18
2340
+ return (
2341
+ <g
2342
+ key={keyHint}
2343
+ onClick={(e) => e.stopPropagation()}
2344
+ onPointerEnter={() => onHandleHoverChange(handleId)}
2345
+ onPointerLeave={() => onHandleHoverChange(null)}
2346
+ >
2347
+ {/* Soft glow — visible only on hover / active. */}
2348
+ <line
2349
+ pointerEvents="none"
2350
+ stroke={stroke}
2351
+ strokeLinecap="round"
2352
+ strokeOpacity={0.18}
2353
+ strokeWidth={glowWidthPx * unitsPerPixel}
2354
+ style={{ opacity: showVisible ? 1 : 0, transition: HOVER_TRANSITION }}
2355
+ vectorEffect="non-scaling-stroke"
2356
+ x1={g.x1}
2357
+ x2={g.x2}
2358
+ y1={g.y1}
2359
+ y2={g.y2}
2360
+ />
2361
+ {/* Solid stroke on top — slightly more opaque when active. */}
2362
+ <line
2363
+ pointerEvents="none"
2364
+ stroke={stroke}
2365
+ strokeLinecap="round"
2366
+ strokeOpacity={isActive ? 0.95 : 0.82}
2367
+ strokeWidth={visibleWidthPx * unitsPerPixel}
2368
+ style={{ opacity: showVisible ? 1 : 0, transition: HOVER_TRANSITION }}
2369
+ vectorEffect="non-scaling-stroke"
2370
+ x1={g.x1}
2371
+ x2={g.x2}
2372
+ y1={g.y1}
2373
+ y2={g.y2}
2374
+ />
2375
+ {/* Transparent hit area along the edge. */}
2376
+ <line
2377
+ onPointerDown={(e) =>
2378
+ onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
2379
+ }
2380
+ pointerEvents="stroke"
2381
+ stroke="transparent"
2382
+ strokeLinecap="round"
2383
+ strokeWidth={hitWidthPx * unitsPerPixel}
2384
+ style={{ cursor: 'pointer' }}
2385
+ vectorEffect="non-scaling-stroke"
2386
+ x1={g.x1}
2387
+ x2={g.x2}
2388
+ y1={g.y1}
2389
+ y2={g.y2}
2390
+ />
2391
+ </g>
2392
+ )
2393
+ }
2394
+ case 'midpoint-handle': {
2395
+ if (!palette) return <></>
2396
+ const handleId = makeHandleId(nodeId, g.payload)
2397
+ const isHovered = hoveredHandleId === handleId
2398
+ const isActive = activeDragId === handleId
2399
+ const stroke = palette.endpointHandleStroke
2400
+ const hoverStroke = palette.endpointHandleHoverStroke
2401
+ // Slightly smaller than endpoint dots; hover-expanded.
2402
+ const baseRadiusPx = 6
2403
+ const hoverRadiusPx = 8
2404
+ const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel
2405
+ const plusHalf = 3 * unitsPerPixel
2406
+ return (
2407
+ <g
2408
+ key={keyHint}
2409
+ onClick={(e) => e.stopPropagation()}
2410
+ onPointerEnter={() => onHandleHoverChange(handleId)}
2411
+ onPointerLeave={() => onHandleHoverChange(null)}
2412
+ >
2413
+ <circle
2414
+ cx={g.point[0]}
2415
+ cy={g.point[1]}
2416
+ fill="none"
2417
+ pointerEvents="none"
2418
+ r={radius + 2 * unitsPerPixel}
2419
+ stroke={hoverStroke}
2420
+ strokeOpacity={0.16}
2421
+ strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
2422
+ style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
2423
+ vectorEffect="non-scaling-stroke"
2424
+ />
2425
+ <circle
2426
+ cx={g.point[0]}
2427
+ cy={g.point[1]}
2428
+ fill="#ffffff"
2429
+ fillOpacity={1}
2430
+ pointerEvents="none"
2431
+ r={radius}
2432
+ stroke={stroke}
2433
+ strokeOpacity={0.9}
2434
+ strokeWidth={1.4}
2435
+ vectorEffect="non-scaling-stroke"
2436
+ />
2437
+ {/* `+` icon — only when the user is close enough to see it
2438
+ clearly (hover or active state). Keeps the resting state
2439
+ visually quiet on busy polygons. */}
2440
+ <line
2441
+ pointerEvents="none"
2442
+ stroke={stroke}
2443
+ strokeLinecap="round"
2444
+ strokeWidth={1.6}
2445
+ vectorEffect="non-scaling-stroke"
2446
+ x1={g.point[0] - plusHalf}
2447
+ x2={g.point[0] + plusHalf}
2448
+ y1={g.point[1]}
2449
+ y2={g.point[1]}
2450
+ />
2451
+ <line
2452
+ pointerEvents="none"
2453
+ stroke={stroke}
2454
+ strokeLinecap="round"
2455
+ strokeWidth={1.6}
2456
+ vectorEffect="non-scaling-stroke"
2457
+ x1={g.point[0]}
2458
+ x2={g.point[0]}
2459
+ y1={g.point[1] - plusHalf}
2460
+ y2={g.point[1] + plusHalf}
2461
+ />
2462
+ <circle
2463
+ cx={g.point[0]}
2464
+ cy={g.point[1]}
2465
+ fill="transparent"
2466
+ onPointerDown={(e) =>
2467
+ onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
2468
+ }
2469
+ pointerEvents="all"
2470
+ r={radius + unitsPerPixel * 2}
2471
+ stroke="transparent"
2472
+ strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel}
2473
+ style={{ cursor: 'pointer' }}
2474
+ vectorEffect="non-scaling-stroke"
2475
+ />
2476
+ </g>
2477
+ )
2478
+ }
2479
+ case 'dimension-label': {
2480
+ if (!palette) return <></>
2481
+ // Flip the label upright relative to the SCREEN, not the local
2482
+ // coord system. The registry layer's parent `<g>` is rotated by
2483
+ // `sceneRotationDeg` (default 90° in the floor plan), so a label
2484
+ // we draw "upright" in local coords ends up sideways on screen.
2485
+ // Combine local angle + scene rotation, normalise to (-180, 180],
2486
+ // and flip by 180° if it falls outside (-90, 90] — that keeps
2487
+ // text reading left-to-right, top-to-bottom regardless of the
2488
+ // building's orientation.
2489
+ const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
2490
+
2491
+ const labelUnitsPerPixel = Math.max(unitsPerPixel, 1e-6)
2492
+ const outlined = g.appearance === 'outlined'
2493
+ const padX = labelUnitsPerPixel * 6
2494
+ const padY = labelUnitsPerPixel * 3
2495
+ const fontSize = labelUnitsPerPixel * (outlined ? 12 : 10)
2496
+ // Rough text width approximation — SVG can't measure text without
2497
+ // the DOM. 6.2px per char at 10px font keeps the plate visually
2498
+ // balanced for the short length strings ("3.24m", "1'2\"", etc.).
2499
+ const textWidth = g.text.length * labelUnitsPerPixel * 6.2
2500
+ const plateW = textWidth + padX * 2
2501
+ const plateH = fontSize + padY * 2
2502
+ return (
2503
+ <g
2504
+ key={keyHint}
2505
+ pointerEvents="none"
2506
+ transform={`translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`}
2507
+ >
2508
+ {outlined ? null : (
2509
+ <rect
2510
+ fill={palette.measurementLabelBackground}
2511
+ height={plateH}
2512
+ opacity={0.92}
2513
+ rx={labelUnitsPerPixel * 3}
2514
+ ry={labelUnitsPerPixel * 3}
2515
+ stroke={palette.measurementStroke}
2516
+ strokeWidth={labelUnitsPerPixel * 0.5}
2517
+ vectorEffect="non-scaling-stroke"
2518
+ width={plateW}
2519
+ x={-plateW / 2}
2520
+ y={-plateH / 2}
2521
+ />
2522
+ )}
2523
+ <text
2524
+ dominantBaseline="middle"
2525
+ fill={outlined ? '#ffffff' : palette.measurementLabelText}
2526
+ fontFamily={
2527
+ outlined
2528
+ ? 'system-ui, -apple-system, sans-serif'
2529
+ : 'ui-monospace, SFMono-Regular, Menlo, monospace'
2530
+ }
2531
+ fontSize={fontSize}
2532
+ fontWeight={outlined ? 500 : 600}
2533
+ paintOrder={outlined ? 'stroke' : undefined}
2534
+ stroke={outlined ? palette.measurementStroke : undefined}
2535
+ strokeLinecap={outlined ? 'round' : undefined}
2536
+ strokeLinejoin={outlined ? 'round' : undefined}
2537
+ strokeWidth={outlined ? fontSize * 0.35 : undefined}
2538
+ textAnchor="middle"
2539
+ x={0}
2540
+ y={0}
2541
+ >
2542
+ {g.text}
2543
+ </text>
2544
+ </g>
2545
+ )
2546
+ }
2547
+ case 'equal-spacing-badge': {
2548
+ // A distinct accent (Figma-style "=" rhythm) so equal spacing reads
2549
+ // apart from the orange placement dimensions. Same screen-upright flip
2550
+ // as the dimension-label case above.
2551
+ const accent = '#ec4899'
2552
+ let degrees = (g.angle * 180) / Math.PI
2553
+ let screenDegrees = degrees + sceneRotationDeg
2554
+ screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
2555
+ if (screenDegrees > 90) degrees -= 180
2556
+ else if (screenDegrees <= -90) degrees += 180
2557
+
2558
+ const label = `= ${g.text}`
2559
+ const padX = unitsPerPixel * 6
2560
+ const padY = unitsPerPixel * 3
2561
+ const fontSize = Math.max(unitsPerPixel * 10, 0.08)
2562
+ const textWidth = label.length * unitsPerPixel * 6.2
2563
+ const plateW = textWidth + padX * 2
2564
+ const plateH = fontSize + padY * 2
2565
+ return (
2566
+ <g
2567
+ key={keyHint}
2568
+ pointerEvents="none"
2569
+ transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${degrees})`}
2570
+ >
2571
+ <rect
2572
+ fill="#ffffff"
2573
+ height={plateH}
2574
+ opacity={0.95}
2575
+ rx={unitsPerPixel * 3}
2576
+ ry={unitsPerPixel * 3}
2577
+ stroke={accent}
2578
+ strokeWidth={unitsPerPixel * 0.75}
2579
+ vectorEffect="non-scaling-stroke"
2580
+ width={plateW}
2581
+ x={-plateW / 2}
2582
+ y={-plateH / 2}
2583
+ />
2584
+ <text
2585
+ dominantBaseline="middle"
2586
+ fill={accent}
2587
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
2588
+ fontSize={fontSize}
2589
+ fontWeight={700}
2590
+ textAnchor="middle"
2591
+ x={0}
2592
+ y={0}
2593
+ >
2594
+ {label}
2595
+ </text>
2596
+ </g>
2597
+ )
2598
+ }
2599
+ case 'dimension': {
2600
+ if (!palette) return <></>
2601
+ const stroke = g.stroke ?? palette.measurementStroke
2602
+ // Offset endpoints along the outward normal — this is where the
2603
+ // dimension line sits, parallel to the edge.
2604
+ const ox = g.offsetNormal[0] * g.offsetDistance
2605
+ const oy = g.offsetNormal[1] * g.offsetDistance
2606
+ const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy]
2607
+ const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy]
2608
+
2609
+ // Extension line endpoints — extend past the dimension line by
2610
+ // `extensionOvershoot` so the tip clears the dimension stroke.
2611
+ const eOvershoot = g.extensionOvershoot
2612
+ const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot)
2613
+ const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot)
2614
+ const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy]
2615
+ const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy]
2616
+
2617
+ const dx = dEnd[0] - dStart[0]
2618
+ const dy = dEnd[1] - dStart[1]
2619
+ const length = Math.hypot(dx, dy)
2620
+ if (length < 1e-6) return <></>
2621
+ const dirX = dx / length
2622
+ const dirY = dy / length
2623
+
2624
+ // Plan-unit constants matching the legacy `floorplan-
2625
+ // measurements-layer.tsx`. `strokeWidth` is intentionally a
2626
+ // raw value (not multiplied by `unitsPerPixel`) because every
2627
+ // stroke here uses `vectorEffect: non-scaling-stroke` — the
2628
+ // browser interprets it as screen-pixel-stable. Multiplying
2629
+ // by `unitsPerPixel` would shrink the strokes by ~100× and
2630
+ // make them invisible. Tick length, dash pattern, font size,
2631
+ // and the label gap stay in plan units (they're geometry,
2632
+ // not stroke width).
2633
+ const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2
2634
+ const perpX = -dirY * tickHalf
2635
+ const perpY = dirX * tickHalf
2636
+
2637
+ const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE
2638
+ const labelGap = 0.5 // plan units — gap in the dimension line for the label
2639
+ const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04)
2640
+
2641
+ const midX = (dStart[0] + dEnd[0]) / 2
2642
+ const midY = (dStart[1] + dEnd[1]) / 2
2643
+ const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf]
2644
+ const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf]
2645
+
2646
+ // Keep the label parallel to the dimension line, but decide the
2647
+ // 180° flip from the on-SCREEN angle, not the local one. The parent
2648
+ // `<g>` is rotated by `sceneRotationDeg` (default 90° in the floor
2649
+ // plan), so a label kept upright in local coords still renders
2650
+ // upside down for half of the wall orientations. Same fix as the
2651
+ // `dimension-label` case above.
2652
+ let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI
2653
+ let screenDeg = labelDeg + sceneRotationDeg
2654
+ screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
2655
+ if (screenDeg > 90) labelDeg -= 180
2656
+ else if (screenDeg <= -90) labelDeg += 180
2657
+
2658
+ return (
2659
+ <g key={keyHint} pointerEvents="none">
2660
+ {/* Extension lines (dashed). */}
2661
+ <line
2662
+ stroke={stroke}
2663
+ strokeDasharray="0.08 0.12"
2664
+ strokeLinecap="round"
2665
+ strokeOpacity={0.95}
2666
+ strokeWidth={1.35}
2667
+ vectorEffect="non-scaling-stroke"
2668
+ x1={g.start[0]}
2669
+ x2={eStartTip[0]}
2670
+ y1={g.start[1]}
2671
+ y2={eStartTip[1]}
2672
+ />
2673
+ <line
2674
+ stroke={stroke}
2675
+ strokeDasharray="0.08 0.12"
2676
+ strokeLinecap="round"
2677
+ strokeOpacity={0.95}
2678
+ strokeWidth={1.35}
2679
+ vectorEffect="non-scaling-stroke"
2680
+ x1={g.end[0]}
2681
+ x2={eEndTip[0]}
2682
+ y1={g.end[1]}
2683
+ y2={eEndTip[1]}
2684
+ />
2685
+ {/* Dimension line: two halves with the label in between. */}
2686
+ <line
2687
+ stroke={stroke}
2688
+ strokeLinecap="round"
2689
+ strokeWidth={1.35}
2690
+ vectorEffect="non-scaling-stroke"
2691
+ x1={dStart[0]}
2692
+ x2={gapStart[0]}
2693
+ y1={dStart[1]}
2694
+ y2={gapStart[1]}
2695
+ />
2696
+ <line
2697
+ stroke={stroke}
2698
+ strokeLinecap="round"
2699
+ strokeWidth={1.35}
2700
+ vectorEffect="non-scaling-stroke"
2701
+ x1={gapEnd[0]}
2702
+ x2={dEnd[0]}
2703
+ y1={gapEnd[1]}
2704
+ y2={dEnd[1]}
2705
+ />
2706
+ {/* End ticks. */}
2707
+ <line
2708
+ stroke={stroke}
2709
+ strokeLinecap="round"
2710
+ strokeWidth={1.35}
2711
+ vectorEffect="non-scaling-stroke"
2712
+ x1={dStart[0] - perpX}
2713
+ x2={dStart[0] + perpX}
2714
+ y1={dStart[1] - perpY}
2715
+ y2={dStart[1] + perpY}
2716
+ />
2717
+ <line
2718
+ stroke={stroke}
2719
+ strokeLinecap="round"
2720
+ strokeWidth={1.35}
2721
+ vectorEffect="non-scaling-stroke"
2722
+ x1={dEnd[0] - perpX}
2723
+ x2={dEnd[0] + perpX}
2724
+ y1={dEnd[1] - perpY}
2725
+ y2={dEnd[1] + perpY}
2726
+ />
2727
+ {/* Rotated label centered in the gap. */}
2728
+ <text
2729
+ dominantBaseline="central"
2730
+ fill={stroke}
2731
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
2732
+ fontSize={fontSize}
2733
+ fontWeight={600}
2734
+ textAnchor="middle"
2735
+ transform={`rotate(${labelDeg} ${midX} ${midY})`}
2736
+ x={midX}
2737
+ y={midY}
2738
+ >
2739
+ {g.text}
2740
+ </text>
2741
+ </g>
2742
+ )
2743
+ }
2744
+ case 'text': {
2745
+ if (!g.upright) return <FloorplanGeometryRenderer geometry={g} key={keyHint} />
2746
+ // Counter-rotate by the scene rotation so the label reads
2747
+ // horizontally on screen even when the floor-plan view is
2748
+ // rotated (default `sceneRotationDeg` is 90°).
2749
+ return (
2750
+ <g key={keyHint} transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}>
2751
+ <text
2752
+ dominantBaseline={g.dominantBaseline ?? 'middle'}
2753
+ fill={g.fill ?? '#171717'}
2754
+ fontFamily={g.fontFamily}
2755
+ fontSize={g.fontSize}
2756
+ fontWeight={g.fontWeight}
2757
+ opacity={g.opacity}
2758
+ paintOrder={g.paintOrder}
2759
+ stroke={g.stroke}
2760
+ strokeLinecap={g.stroke ? 'round' : undefined}
2761
+ strokeLinejoin={g.stroke ? 'round' : undefined}
2762
+ strokeWidth={g.strokeWidth}
2763
+ textAnchor={g.textAnchor ?? 'start'}
2764
+ x={0}
2765
+ y={0}
2766
+ >
2767
+ {g.text}
2768
+ </text>
2769
+ </g>
2770
+ )
2771
+ }
2772
+ default:
2773
+ return (
2774
+ <FloorplanGeometryRenderer
2775
+ geometry={g}
2776
+ key={keyHint}
2777
+ pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
2778
+ />
2779
+ )
2780
+ }
2781
+ }
2782
+ }, shallowPropsAreEqual)
2783
+
2784
+ // ── Helpers ──────────────────────────────────────────────────────────
2785
+
2786
+ function shallowPropsAreEqual<T extends object>(a: T, b: T): boolean {
2787
+ const aKeys = Object.keys(a) as Array<keyof T>
2788
+ const bKeys = Object.keys(b) as Array<keyof T>
2789
+ if (aKeys.length !== bKeys.length) return false
2790
+ for (const key of aKeys) {
2791
+ if (!Object.is(a[key], b[key])) return false
2792
+ }
2793
+ return true
2794
+ }
2795
+
2796
+ function handleIdForNode(handleId: string | null, nodeId: AnyNodeId): string | null {
2797
+ if (!handleId) return null
2798
+ return handleId === nodeId || handleId.startsWith(`${nodeId}:`) ? handleId : null
2799
+ }
2800
+
2801
+ function applyPositionLiveTransform(
2802
+ node: AnyNode,
2803
+ live: { position: [number, number, number]; rotation: number },
2804
+ ): AnyNode {
2805
+ const currentRotation = (node as { rotation?: unknown }).rotation
2806
+ const rotation = Array.isArray(currentRotation)
2807
+ ? ([
2808
+ (currentRotation[0] as number) ?? 0,
2809
+ live.rotation,
2810
+ (currentRotation[2] as number) ?? 0,
2811
+ ] as [number, number, number])
2812
+ : typeof currentRotation === 'number'
2813
+ ? live.rotation
2814
+ : currentRotation
2815
+
2816
+ return {
2817
+ ...node,
2818
+ position: live.position,
2819
+ ...(rotation !== undefined ? { rotation } : {}),
2820
+ parentId: null,
2821
+ } as AnyNode
2822
+ }
2823
+
2824
+ export function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean {
2825
+ const overrideVisible = liveOverride?.visible
2826
+ if (typeof overrideVisible === 'boolean') return overrideVisible
2827
+ return (node as { visible?: boolean }).visible !== false
2828
+ }
2829
+
2830
+ function isFloorplanHierarchyVisible(
2831
+ node: AnyNode,
2832
+ nodes: Record<string, AnyNode>,
2833
+ liveOverrides: Map<string, LiveNodeOverrides>,
2834
+ rootId: AnyNodeId,
2835
+ ): boolean {
2836
+ let current: AnyNode | undefined = node
2837
+ const seen = new Set<AnyNodeId>()
2838
+ while (current) {
2839
+ if (seen.has(current.id)) return true
2840
+ seen.add(current.id)
2841
+ if (!isFloorplanNodeVisible(current, liveOverrides.get(current.id))) return false
2842
+ if (current.id === rootId) return true
2843
+ const parentId = current.parentId as AnyNodeId | null
2844
+ if (!parentId) return true
2845
+ current = nodes[parentId]
2846
+ }
2847
+ return true
2848
+ }
2849
+
2850
+ export function buildContext(
2851
+ node: AnyNode,
2852
+ nodes: Record<string, AnyNode>,
2853
+ viewState: {
2854
+ selected: boolean
2855
+ unit: 'metric' | 'imperial'
2856
+ highlighted: boolean
2857
+ hovered: boolean
2858
+ moving: boolean
2859
+ palette: FloorplanPalette | undefined
2860
+ },
2861
+ levelData?: unknown,
2862
+ ): GeometryContext {
2863
+ const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
2864
+
2865
+ const childIds = (node as unknown as { children?: AnyNodeId[] }).children
2866
+ const children: AnyNode[] = Array.isArray(childIds)
2867
+ ? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined)
2868
+ : []
2869
+
2870
+ const parentId = node.parentId as AnyNodeId | null
2871
+ const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null
2872
+
2873
+ let siblings: AnyNode[] = []
2874
+ if (parent) {
2875
+ const parentChildIds = (parent as unknown as { children?: AnyNodeId[] }).children
2876
+ if (Array.isArray(parentChildIds)) {
2877
+ for (const sid of parentChildIds) {
2878
+ if (sid === node.id) continue
2879
+ const s = nodes[sid]
2880
+ if (s && s.type === node.type) siblings.push(s)
2881
+ }
2882
+ } else {
2883
+ siblings = Object.values(nodes).filter(
2884
+ (n) => n !== node && n.type === node.type && n.parentId === parentId,
2885
+ )
2886
+ }
2887
+ }
2888
+
2889
+ return {
2890
+ resolve,
2891
+ children,
2892
+ siblings,
2893
+ parent,
2894
+ levelData,
2895
+ viewState: viewState.palette
2896
+ ? {
2897
+ selected: viewState.selected,
2898
+ unit: viewState.unit,
2899
+ highlighted: viewState.highlighted,
2900
+ hovered: viewState.hovered,
2901
+ moving: viewState.moving,
2902
+ palette: viewState.palette,
2903
+ }
2904
+ : undefined,
2905
+ }
2906
+ }
2907
+
2908
+ /**
2909
+ * Stable id for a handle on a node, derived from the node id + opaque
2910
+ * payload. Used to track hover / active visual state when multiple
2911
+ * handles belong to the same node (start vs end endpoint, multiple
2912
+ * vertices of a polygon, etc.).
2913
+ */
2914
+ function makeHandleId(nodeId: AnyNodeId, payload: unknown): string {
2915
+ if (payload == null) return `${nodeId}`
2916
+ if (typeof payload === 'object') {
2917
+ // Stable JSON serialisation of common shapes — endpoint discriminator,
2918
+ // vertex index, etc. Don't try to handle arbitrarily-deep payloads.
2919
+ try {
2920
+ return `${nodeId}:${JSON.stringify(payload)}`
2921
+ } catch {
2922
+ return `${nodeId}`
2923
+ }
2924
+ }
2925
+ return `${nodeId}:${String(payload)}`
2926
+ }
2927
+
2928
+ export function floorplanHandleDoubleClickAffordance(
2929
+ geometry: FloorplanGeometry,
2930
+ ): 'delete-vertex' | null {
2931
+ return geometry.kind === 'endpoint-handle' && geometry.affordance === 'move-vertex'
2932
+ ? 'delete-vertex'
2933
+ : null
2934
+ }
2935
+
2936
+ /**
2937
+ * Geometry kinds that always render in the overlay pass — interactive
2938
+ * handles and node labels. These need to sit above every kind's base
2939
+ * geometry regardless of the owning node's z-bucket so that:
2940
+ * - polygon edit handles on a selected slab don't get hidden by the
2941
+ * walls / items resting on top of the slab,
2942
+ * - a zone's name stays legible above the slab covering the zone, and
2943
+ * - measurement labels never get clipped by structural fills.
2944
+ */
2945
+ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
2946
+ 'text',
2947
+ 'endpoint-handle',
2948
+ 'midpoint-handle',
2949
+ 'edge-handle',
2950
+ 'move-handle',
2951
+ 'move-arrow',
2952
+ 'rotate-arrow',
2953
+ 'dimension',
2954
+ 'dimension-label',
2955
+ 'equal-spacing-badge',
2956
+ ])
2957
+
2958
+ /**
2959
+ * Walk a `FloorplanGeometry` tree and split it into two trees: one with
2960
+ * only "base" primitives (polygons, paths, fills, strokes) and one with
2961
+ * only "overlay" primitives (handles, labels — see `OVERLAY_KINDS`).
2962
+ *
2963
+ * Groups recurse: a `kind: 'group'` is split into a base group and an
2964
+ * overlay group, both carrying the same `transform` so nested rotations
2965
+ * / translations apply in both passes. Empty groups collapse to `null`
2966
+ * so the caller can skip emitting an `<g>` when there's nothing to draw.
2967
+ */
2968
+ export function splitFloorplanOverlay(g: FloorplanGeometry): {
2969
+ base: FloorplanGeometry | null
2970
+ overlay: FloorplanGeometry | null
2971
+ } {
2972
+ if (OVERLAY_KINDS.has(g.kind)) {
2973
+ return { base: null, overlay: g }
2974
+ }
2975
+ if (g.kind === 'group') {
2976
+ const baseChildren: FloorplanGeometry[] = []
2977
+ const overlayChildren: FloorplanGeometry[] = []
2978
+ for (const child of g.children) {
2979
+ const split = splitFloorplanOverlay(child)
2980
+ if (split.base) baseChildren.push(split.base)
2981
+ if (split.overlay) overlayChildren.push(split.overlay)
2982
+ }
2983
+ const base: FloorplanGeometry | null =
2984
+ baseChildren.length > 0
2985
+ ? { kind: 'group', children: baseChildren, transform: g.transform }
2986
+ : null
2987
+ const overlay: FloorplanGeometry | null =
2988
+ overlayChildren.length > 0
2989
+ ? { kind: 'group', children: overlayChildren, transform: g.transform }
2990
+ : null
2991
+ return { base, overlay }
2992
+ }
2993
+ return { base: g, overlay: null }
2994
+ }
2995
+
2996
+ /**
2997
+ * Per-node edit chrome hidden while a multi-selection is active: the group is
2998
+ * manipulated as one rigid piece (drag to move, R/T to rotate), so individual
2999
+ * handles / dimension labels on each member would mislead. `text` stays —
3000
+ * zone names are identification, not editing chrome.
3001
+ */
3002
+ const HANDLE_CHROME_KINDS = new Set<FloorplanGeometry['kind']>([
3003
+ 'endpoint-handle',
3004
+ 'midpoint-handle',
3005
+ 'edge-handle',
3006
+ 'move-handle',
3007
+ 'move-arrow',
3008
+ 'rotate-arrow',
3009
+ 'dimension',
3010
+ 'dimension-label',
3011
+ 'equal-spacing-badge',
3012
+ ])
3013
+
3014
+ function stripHandleChrome(g: FloorplanGeometry): FloorplanGeometry | null {
3015
+ if (HANDLE_CHROME_KINDS.has(g.kind)) return null
3016
+ if (g.kind === 'group') {
3017
+ const children = g.children
3018
+ .map(stripHandleChrome)
3019
+ .filter((c): c is FloorplanGeometry => c !== null)
3020
+ if (children.length === 0) return null
3021
+ return { kind: 'group', children, transform: g.transform }
3022
+ }
3023
+ return g
3024
+ }
3025
+
3026
+ // Stable string key for a wall endpoint, rounded to 1 mm so floating-point
3027
+ // drift collapses while distinct corners stay distinct.
3028
+ function endpointKey(x: number, y: number): string {
3029
+ return `${Math.round(x * 1000)},${Math.round(y * 1000)}`
3030
+ }
3031
+
3032
+ // Given the sibling-dependent nodes with a live drag in flight, the set of
3033
+ // floor-plan geometries that must rebuild this frame. A node's geometry depends
3034
+ // on more than its own data:
3035
+ // - a wall's miters depend on the walls meeting at each of its endpoints, so a
3036
+ // dragged wall invalidates the walls at its old AND new junctions, plus its
3037
+ // own door/window children (their cuts are drawn into it);
3038
+ // - a door/window cut is drawn into its host wall, so it invalidates that wall;
3039
+ // - a gutter join depends on sibling gutters under the same roof.
3040
+ // Everything else stays cached, so dragging one wall/opening rebuilds a handful
3041
+ // of geometries rather than every wall + opening on the level.
3042
+ export function computeAffectedSiblingIds(
3043
+ liveFlaggedIds: readonly AnyNodeId[],
3044
+ nodes: Record<string, AnyNode>,
3045
+ liveOverrides: Map<string, Record<string, unknown>>,
3046
+ ): Set<AnyNodeId> {
3047
+ const affected = new Set<AnyNodeId>()
3048
+ if (liveFlaggedIds.length === 0) return affected
3049
+
3050
+ // Junction map (committed wall endpoint → wall ids), built lazily on first use.
3051
+ let junctions: Map<string, AnyNodeId[]> | null = null
3052
+ const wallsAtPoint = (x: number, y: number): AnyNodeId[] => {
3053
+ if (!junctions) {
3054
+ junctions = new Map()
3055
+ for (const id in nodes) {
3056
+ const n = nodes[id]
3057
+ if (n?.type !== 'wall') continue
3058
+ const w = n as unknown as { start: [number, number]; end: [number, number] }
3059
+ for (const [px, py] of [w.start, w.end]) {
3060
+ const key = endpointKey(px, py)
3061
+ const arr = junctions.get(key)
3062
+ if (arr) arr.push(id as AnyNodeId)
3063
+ else junctions.set(key, [id as AnyNodeId])
3064
+ }
3065
+ }
3066
+ }
3067
+ return junctions.get(endpointKey(x, y)) ?? []
3068
+ }
3069
+
3070
+ for (const id of liveFlaggedIds) {
3071
+ const node = nodes[id]
3072
+ if (!node) continue
3073
+ affected.add(id)
3074
+ const def = nodeRegistry.get(node.type)
3075
+ const extraAffectedIds = def?.floorplanAffectedIds?.({
3076
+ nodeId: id,
3077
+ node,
3078
+ nodes: nodes as Record<AnyNodeId, AnyNode>,
3079
+ liveTransforms: useLiveTransforms.getState().transforms,
3080
+ liveOverrides,
3081
+ })
3082
+ if (extraAffectedIds) {
3083
+ for (const extraId of extraAffectedIds) affected.add(extraId)
3084
+ }
3085
+ if (node.type === 'wall') {
3086
+ const w = node as unknown as {
3087
+ start: [number, number]
3088
+ end: [number, number]
3089
+ children?: AnyNodeId[]
3090
+ }
3091
+ // Use the live (override-merged) endpoints as well as the committed ones,
3092
+ // so walls at both the wall's old and new junctions get fresh miters.
3093
+ const ov = liveOverrides.get(id) as
3094
+ | { start?: [number, number]; end?: [number, number] }
3095
+ | undefined
3096
+ const points: [number, number][] = [w.start, w.end]
3097
+ if (ov?.start) points.push(ov.start)
3098
+ if (ov?.end) points.push(ov.end)
3099
+ for (const [px, py] of points) {
3100
+ for (const wid of wallsAtPoint(px, py)) affected.add(wid)
3101
+ }
3102
+ if (Array.isArray(w.children)) {
3103
+ for (const cid of w.children) {
3104
+ const child = nodes[cid]
3105
+ if (child?.type === 'door' || child?.type === 'window') affected.add(cid)
3106
+ }
3107
+ }
3108
+ } else if (node.type === 'door' || node.type === 'window') {
3109
+ const hostId = (node as { parentId?: string }).parentId
3110
+ if (hostId) affected.add(hostId as AnyNodeId)
3111
+ const liveHostId = (liveOverrides.get(id) as { parentId?: string } | undefined)?.parentId
3112
+ if (liveHostId) affected.add(liveHostId as AnyNodeId)
3113
+ } else if (node.type === 'gutter') {
3114
+ const roofId = (node as { parentId?: string }).parentId
3115
+ if (roofId) {
3116
+ for (const sid in nodes) {
3117
+ const s = nodes[sid]
3118
+ if (s?.type === 'gutter' && (s as { parentId?: string }).parentId === roofId) {
3119
+ affected.add(sid as AnyNodeId)
3120
+ }
3121
+ }
3122
+ }
3123
+ }
3124
+ }
3125
+ return affected
3126
+ }
3127
+
3128
+ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
3129
+ const keys: Array<keyof NodeDeps> = [
3130
+ 'node',
3131
+ 'live',
3132
+ 'unit',
3133
+ 'selected',
3134
+ 'highlighted',
3135
+ 'hovered',
3136
+ 'moving',
3137
+ 'liveOverride',
3138
+ 'palette',
3139
+ 'siblingEpoch',
3140
+ 'committedNodes',
3141
+ 'dependencyNodes',
3142
+ 'interactiveElevators',
3143
+ ]
3144
+ for (const key of keys) {
3145
+ if (!depsValueEqual(a[key], b[key])) return false
3146
+ }
3147
+ return true
3148
+ }
3149
+
3150
+ function depsValueEqual(a: unknown, b: unknown): boolean {
3151
+ if (Array.isArray(a) || Array.isArray(b)) {
3152
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
3153
+ if (a.length !== b.length) return false
3154
+ for (let i = 0; i < a.length; i++) {
3155
+ if (!Object.is(a[i], b[i])) return false
3156
+ }
3157
+ return true
3158
+ }
3159
+ return Object.is(a, b)
3160
+ }
3161
+
3162
+ /**
3163
+ * Z-order bucket for floor-plan rendering. Lower rank = painted first =
3164
+ * sits under everything with a higher rank. SVG renders in document
3165
+ * order, so an earlier entry in the array ends up beneath a later one.
3166
+ *
3167
+ * Three buckets today:
3168
+ * 0 — `zone`: conceptual area regions, always under everything else.
3169
+ * 1 — `slab` / `ceiling`: the floor / ceiling surface; sits over the
3170
+ * zone but under any structural / furniture geometry placed on it.
3171
+ * 2 — every other kind (walls, items, shelves, columns, stairs, …):
3172
+ * structure + furniture, painted on top.
3173
+ *
3174
+ * Sort is stable in modern JS engines, so siblings within the same
3175
+ * bucket keep their DFS order (= scene tree order).
3176
+ */
3177
+ export function floorplanLayerRank(type: string): number {
3178
+ switch (type) {
3179
+ case 'zone':
3180
+ return 0
3181
+ case 'slab':
3182
+ case 'ceiling':
3183
+ return 1
3184
+ default:
3185
+ return 2
3186
+ }
3187
+ }
3188
+
3189
+ function deepEqual(a: unknown, b: unknown): boolean {
3190
+ if (a === b) return true
3191
+ if (Array.isArray(a) && Array.isArray(b)) {
3192
+ if (a.length !== b.length) return false
3193
+ for (let i = 0; i < a.length; i++) {
3194
+ if (!deepEqual(a[i], b[i])) return false
3195
+ }
3196
+ return true
3197
+ }
3198
+ if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
3199
+ const aKeys = Object.keys(a as Record<string, unknown>)
3200
+ const bKeys = Object.keys(b as Record<string, unknown>)
3201
+ if (aKeys.length !== bKeys.length) return false
3202
+ for (const key of aKeys) {
3203
+ if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
3204
+ return false
3205
+ }
3206
+ }
3207
+ return true
3208
+ }
3209
+ return false
3210
+ }
3211
+
3212
+ const ROTATION_WEDGE_COLOR = '#8381ed'
3213
+ const ROTATION_WEDGE_SEGMENTS = 48
3214
+
3215
+ /**
3216
+ * Live rotation readout for the floor plan — the 2D twin of the 3D rotate
3217
+ * gizmo's wedge. Draws a filled sector + outline swept from the pointer's
3218
+ * bearing at grab (`startAngle`) to its current bearing (`endAngle`) around
3219
+ * the pivot, plus an upright degree chip at the wedge midpoint. All geometry
3220
+ * is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads
3221
+ * horizontally regardless of the building's on-screen orientation.
3222
+ */
3223
+ export function RotationAngleOverlay({
3224
+ overlay,
3225
+ palette,
3226
+ unitsPerPixel,
3227
+ sceneRotationDeg,
3228
+ }: {
3229
+ overlay: RotationOverlayState
3230
+ palette: Pick<
3231
+ FloorplanPalette,
3232
+ 'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke'
3233
+ >
3234
+ unitsPerPixel: number
3235
+ sceneRotationDeg: number
3236
+ }): React.ReactElement {
3237
+ const { pivot, startAngle, endAngle, radius, sweep } = overlay
3238
+ const span = endAngle - startAngle
3239
+ const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS))
3240
+ let d = `M ${pivot[0]} ${pivot[1]}`
3241
+ for (let i = 0; i <= count; i++) {
3242
+ const a = startAngle + (span * i) / count
3243
+ d += ` L ${pivot[0] + Math.cos(a) * radius} ${pivot[1] + Math.sin(a) * radius}`
3244
+ }
3245
+ d += ' Z'
3246
+
3247
+ const midAngle = startAngle + span / 2
3248
+ const labelDist = radius + unitsPerPixel * 14
3249
+ const lx = pivot[0] + Math.cos(midAngle) * labelDist
3250
+ const ly = pivot[1] + Math.sin(midAngle) * labelDist
3251
+
3252
+ const text = `${Math.round((sweep * 180) / Math.PI)}°`
3253
+ const padX = unitsPerPixel * 6
3254
+ const padY = unitsPerPixel * 3
3255
+ const fontSize = Math.max(unitsPerPixel * 10, 0.08)
3256
+ const textWidth = text.length * unitsPerPixel * 6.2
3257
+ const plateW = textWidth + padX * 2
3258
+ const plateH = fontSize + padY * 2
3259
+
3260
+ return (
3261
+ <g className="floorplan-rotation-readout" pointerEvents="none">
3262
+ <path d={d} fill={ROTATION_WEDGE_COLOR} fillOpacity={0.18} stroke="none" />
3263
+ <path
3264
+ d={d}
3265
+ fill="none"
3266
+ stroke={ROTATION_WEDGE_COLOR}
3267
+ strokeLinejoin="round"
3268
+ strokeOpacity={0.95}
3269
+ strokeWidth={1.8}
3270
+ vectorEffect="non-scaling-stroke"
3271
+ />
3272
+ {/* Counter-rotate the scene transform so the chip stays horizontal. */}
3273
+ <g transform={`translate(${lx} ${ly}) rotate(${-sceneRotationDeg})`}>
3274
+ <rect
3275
+ fill={palette.measurementLabelBackground}
3276
+ height={plateH}
3277
+ opacity={0.92}
3278
+ rx={unitsPerPixel * 3}
3279
+ ry={unitsPerPixel * 3}
3280
+ stroke={palette.measurementStroke}
3281
+ strokeWidth={unitsPerPixel * 0.5}
3282
+ vectorEffect="non-scaling-stroke"
3283
+ width={plateW}
3284
+ x={-plateW / 2}
3285
+ y={-plateH / 2}
3286
+ />
3287
+ <text
3288
+ dominantBaseline="middle"
3289
+ fill={palette.measurementLabelText}
3290
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
3291
+ fontSize={fontSize}
3292
+ fontWeight={600}
3293
+ textAnchor="middle"
3294
+ x={0}
3295
+ y={0}
3296
+ >
3297
+ {text}
3298
+ </text>
3299
+ </g>
3300
+ </g>
3301
+ )
3302
+ }
3303
+
3304
+ function formatGroupTransform(t?: {
3305
+ translate?: readonly [number, number]
3306
+ rotate?: number
3307
+ }): string | undefined {
3308
+ if (!t) return undefined
3309
+ const parts: string[] = []
3310
+ if (t.translate) parts.push(`translate(${t.translate[0]} ${t.translate[1]})`)
3311
+ if (t.rotate !== undefined) parts.push(`rotate(${(t.rotate * 180) / Math.PI})`)
3312
+ return parts.length > 0 ? parts.join(' ') : undefined
3313
+ }
3314
+
3315
+ function swallowNextClick(timeoutMs = 0) {
3316
+ const swallowClick = (event: MouseEvent) => {
3317
+ event.stopPropagation()
3318
+ event.preventDefault()
3319
+ window.removeEventListener('click', swallowClick, true)
3320
+ }
3321
+ window.addEventListener('click', swallowClick, true)
3322
+ setTimeout(() => {
3323
+ window.removeEventListener('click', swallowClick, true)
3324
+ }, timeoutMs)
3325
+ }