@pascal-app/editor 1.0.3 → 1.0.4

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 (440) hide show
  1. package/package.json +11 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +202 -57
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +36 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -23,6 +23,7 @@ import {
23
23
  resolveBuildingForLevel,
24
24
  resolveSelectionProxyId,
25
25
  resumeSceneHistory,
26
+ type SlabNode,
26
27
  useInteractive,
27
28
  useLiveNodeOverrides,
28
29
  useLiveTransforms,
@@ -43,6 +44,14 @@ import {
43
44
  } from 'react'
44
45
  import { useShallow } from 'zustand/react/shallow'
45
46
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
47
+ import {
48
+ hoverRoomFromHit,
49
+ resolvePlanRoomHit,
50
+ roomPickingEnabled,
51
+ selectionModifiersFromEvent,
52
+ useRoomRecords,
53
+ } from '../../../hooks/use-selected-room'
54
+ import { useCeilingEditSession } from '../../../lib/ceiling-edit-session'
46
55
  import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
47
56
  import {
48
57
  canDirectRotateNode,
@@ -53,6 +62,7 @@ import {
53
62
  snapDirectRotationDelta,
54
63
  } from '../../../lib/direct-manipulation'
55
64
  import { createEditorApi } from '../../../lib/editor-api'
65
+ import { isRoomOwnedPlate, roomOwnedPlateDrillTarget } from '../../../lib/floor-footprints'
56
66
  import {
57
67
  type FloorplanAnnotationVisibility,
58
68
  filterFloorplanAnnotationGeometry,
@@ -86,7 +96,15 @@ import {
86
96
  isIdle,
87
97
  tangentReshapeScope,
88
98
  } from '../../../lib/interaction/scope'
89
- import { emitCanvasNodeSelection } from '../../../lib/selection-routing'
99
+ import { runFloorplanWallPush, WALL_PUSH_AFFORDANCE } from '../../../lib/room-handle-drag'
100
+ import { selectRoom, selectRoomFromHit } from '../../../lib/room-selection-commands'
101
+ import { clickZoneArea, hoverZoneArea } from '../../../lib/room-zone-routing'
102
+ import {
103
+ emitCanvasNodeSelection,
104
+ enterBuildingFromPlanHit,
105
+ resolveCanvasBuildingId,
106
+ } from '../../../lib/selection-routing'
107
+ import { createSessionWrites, type SessionWrites } from '../../../lib/session-writes'
90
108
  import { sfxEmitter } from '../../../lib/sfx-bus'
91
109
  import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
92
110
  import { paintZoneMembership } from '../../../lib/units'
@@ -101,8 +119,13 @@ import useInteractionScope, {
101
119
  useMovingNode,
102
120
  } from '../../../store/use-interaction-scope'
103
121
  import { expandSessionSelectionForNode } from '../../../store/use-session-groups'
122
+ import { isViewVisible, VIEW_2D } from '../../../store/view-layout'
123
+ import { FloorRegionControls2D } from '../../editor/floor-region-controls'
104
124
  import { startGroupPickUp } from '../../editor/group-actions'
105
125
  import { classifyParticipant } from '../../editor/group-transform-shared'
126
+ import { RoomHandleDragPreview2D, RoomHandles2D } from '../../editor/room-handles-2d'
127
+ import { RoomHighlight2D } from '../../editor/room-highlight'
128
+ import { RoomLabels2D } from '../../editor/room-labels'
106
129
  import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
107
130
  import {
108
131
  FloorplanGroupSelectionBox,
@@ -120,6 +143,7 @@ import {
120
143
  resolveSvgAnnotationCollisions,
121
144
  svgAnnotationLabelId,
122
145
  } from './floorplan-annotation-layout'
146
+ import { FloorplanDimensionEditOverlay } from './floorplan-dimension-edit-overlay'
123
147
  import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
124
148
  import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
125
149
  import {
@@ -179,22 +203,19 @@ const ScaleAwareFloorplanGroupSelectionBox = memo(function ScaleAwareFloorplanGr
179
203
  return <FloorplanGroupSelectionBox {...props} unitsPerPixel={unitsPerPixel} />
180
204
  })
181
205
 
182
- /**
183
- * Snapshot of node fields captured at drag-start, used by the single-undo
184
- * dance to revert untracked before re-applying as a single tracked
185
- * change. The dispatcher only knows about the `affectedIds` the
186
- * affordance declares; it captures whatever fields exist on each node by
187
- * cloning the full record minus the registry-managed `id` / `type`.
188
- */
189
- type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
190
-
191
206
  type ActiveDrag = {
192
207
  pointerId: number
193
208
  captureTarget: Element
194
209
  /** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */
195
210
  handleId: string
196
211
  session: FloorplanAffordanceSession
197
- snapshots: NodeSnapshot[]
212
+ /**
213
+ * What the session itself wrote to the scene during `apply()` (legacy
214
+ * sessions only; override-only sessions write nothing). Cancel and commit
215
+ * take back exactly these writes, so a collaborator's edit to the same node
216
+ * mid-drag survives.
217
+ */
218
+ writes: SessionWrites
198
219
  historyPaused: boolean
199
220
  /**
200
221
  * Last plan point handed to `session.apply` (the grab point until the first
@@ -218,7 +239,6 @@ type ActiveDrag = {
218
239
  }
219
240
 
220
241
  type FloorplanAffordanceCancelEffects = {
221
- restoreSnapshots: (snapshots: NodeSnapshot[]) => void
222
242
  resumeHistory: () => void
223
243
  clearPreview: (id: AnyNodeId) => void
224
244
  clearSnapFeedback: () => void
@@ -242,7 +262,7 @@ export function cancelFloorplanAffordanceDrag(
242
262
  drag.captureTarget.releasePointerCapture?.(drag.pointerId)
243
263
  }
244
264
 
245
- effects.restoreSnapshots(drag.snapshots)
265
+ drag.writes.revert()
246
266
  if (drag.historyPaused) {
247
267
  effects.resumeHistory()
248
268
  drag.historyPaused = false
@@ -378,6 +398,7 @@ type CacheEntry = {
378
398
  deps: NodeDeps
379
399
  base: FloorplanGeometry | null
380
400
  overlay: FloorplanGeometry | null
401
+ handles: FloorplanGeometry | null
381
402
  node: AnyNode
382
403
  }
383
404
 
@@ -433,7 +454,7 @@ type FloorplanLevelDataHook = (args: {
433
454
  nodes: Record<string, AnyNode>
434
455
  }) => unknown
435
456
 
436
- type FloorplanRenderPass = 'base' | 'overlay'
457
+ type FloorplanRenderPass = 'base' | 'overlay' | 'handles'
437
458
 
438
459
  const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const
439
460
  // Group members advertise the drag-to-move-the-selection gesture.
@@ -441,18 +462,16 @@ const MOVE_CURSOR_STYLE = { cursor: 'move' } as const
441
462
  const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
442
463
 
443
464
  export function isFloorplanOpeningPlacementState({
444
- phase,
445
465
  mode,
446
466
  tool,
447
467
  movingNodeHasWallOpeningPlacement,
448
468
  }: {
449
- phase: string
450
469
  mode: string
451
470
  tool: string | null
452
471
  movingNodeHasWallOpeningPlacement: boolean
453
472
  }): boolean {
454
473
  return (
455
- (phase === 'structure' && mode === 'build' && (tool === 'door' || tool === 'window')) ||
474
+ (mode === 'build' && (tool === 'door' || tool === 'window')) ||
456
475
  movingNodeHasWallOpeningPlacement
457
476
  )
458
477
  }
@@ -471,12 +490,11 @@ export function floorplanEntryYieldsToTool(state: {
471
490
  }
472
491
 
473
492
  function floorplanEntryYieldsToToolNow(): boolean {
474
- const { phase, mode, tool } = useEditor.getState()
493
+ const { mode, tool } = useEditor.getState()
475
494
  const movingNode = getMovingNode()
476
495
  return floorplanEntryYieldsToTool({
477
496
  mode,
478
497
  openingPlacement: isFloorplanOpeningPlacementState({
479
- phase,
480
498
  mode,
481
499
  tool,
482
500
  movingNodeHasWallOpeningPlacement:
@@ -486,21 +504,6 @@ function floorplanEntryYieldsToToolNow(): boolean {
486
504
  })
487
505
  }
488
506
 
489
- function snapshotNode(node: AnyNode): NodeSnapshot {
490
- // Shallow-clone every non-id, non-type field. Arrays / vec tuples are
491
- // deep-cloned to detach from the live store reference.
492
- const data: Record<string, unknown> = {}
493
- for (const [key, value] of Object.entries(node)) {
494
- if (key === 'id' || key === 'type' || key === 'object' || key === 'parentId') continue
495
- data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
496
- }
497
- return { id: node.id, data }
498
- }
499
-
500
- function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
501
- return snapshots.map((s) => ({ id: s.id, data: s.data }))
502
- }
503
-
504
507
  // Stable empty sentinel used by per-entry builders while the floor plan is
505
508
  // hidden; committed scene edits still flow through `useScene`.
506
509
  const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
@@ -564,6 +567,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
564
567
  }, [selectedLevelId, ambientBuildingSourceId, nodes])
565
568
 
566
569
  const levelId = selectedLevelId ?? ambientLevelId
570
+ useRoomRecords(levelId)
567
571
  const isAmbient = !selectedLevelId && !!ambientLevelId
568
572
  const activeBuildingId = useMemo(
569
573
  () => (levelId ? resolveBuildingForLevel(levelId as AnyNodeId, nodes) : null),
@@ -584,19 +588,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
584
588
  // `handleClickStop` / `handleSelect`, so the tool never sees it. Pass
585
589
  // clicks through in that case (`floorplanEntryYieldsToToolNow`).
586
590
  const editorMode = useEditor((s) => s.mode)
587
- const structureLayer = useEditor((s) => s.structureLayer)
588
591
  const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
589
592
  const endpointReshape = useEndpointReshape()
590
593
  const isMarqueeSelectionActive =
591
594
  editorMode === 'select' &&
592
595
  floorplanSelectionTool === 'marquee' &&
593
- structureLayer !== 'zones' &&
594
596
  !movingNode &&
595
597
  !endpointReshape
596
598
  // While the floor plan is not on screen (pure 3D view), per-entry live
597
599
  // selectors freeze to `undefined` so drag publishes do not re-render the
598
600
  // hidden floor-plan tree.
599
- const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
601
+ const floorplanVisible = useEditor((s) => isViewVisible(s, VIEW_2D))
600
602
  const drawingType = useDrawingView((s) => s.drawingType)
601
603
  const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility)
602
604
  const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference)
@@ -697,6 +699,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
697
699
  swallowNextClick(200)
698
700
  return
699
701
  }
702
+ // A zone's fill or label takes the same rule as its pill (`clickZoneArea`).
703
+ if (clickedNode?.type === 'zone') {
704
+ clickZoneArea(clickedNode.id, {
705
+ meta: false,
706
+ ctrl: false,
707
+ shift: options.shouldToggle,
708
+ alt: options.isolateMember,
709
+ })
710
+ swallowNextClick(200)
711
+ return
712
+ }
700
713
  const currentSelectedIds = useViewer.getState().selection.selectedIds
701
714
  let nextSelectedIds: string[]
702
715
  if (options.shouldToggle) {
@@ -754,13 +767,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
754
767
  )
755
768
 
756
769
  const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
757
- if (floorplanEntryYieldsToToolNow()) return
770
+ if (floorplanEntryYieldsToToolNow() || !isIdle(useInteractionScope.getState().scope)) return
758
771
  event.stopPropagation()
759
772
  }, [])
760
773
 
761
774
  const startDirectMoveDrag = useCallback(
762
775
  (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
763
- if (event.button !== 0) return false
776
+ if (event.button !== 0 || event.altKey) return false
764
777
 
765
778
  const node = useScene.getState().nodes[id]
766
779
  if (!node || !isRegistryMovable(node.type)) return false
@@ -1015,7 +1028,35 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1015
1028
  // the stores at event time. Commit clears the interaction scope before
1016
1029
  // React paints the next frame; a render-time `undefined` handler leaves
1017
1030
  // a short dead zone where the first post-placement selection is lost.
1018
- if (floorplanEntryYieldsToToolNow()) return
1031
+ if (floorplanEntryYieldsToToolNow() || !isIdle(useInteractionScope.getState().scope)) return
1032
+ if (event.button === 0 && enterBuildingFromPlanHit(id)) {
1033
+ event.stopPropagation()
1034
+ swallowNextClick(200)
1035
+ return
1036
+ }
1037
+ const point = clientToPlan(event.clientX, event.clientY)
1038
+ if (
1039
+ event.button === 0 &&
1040
+ point &&
1041
+ roomPickingEnabled() &&
1042
+ selectRoomFromHit(resolvePlanRoomHit(id, point), selectionModifiersFromEvent(event), id)
1043
+ ) {
1044
+ event.stopPropagation()
1045
+ swallowNextClick(200)
1046
+ return
1047
+ }
1048
+ // A room-owned plate past its room: the same routing as the 3D click.
1049
+ const plate = useScene.getState().nodes[id]
1050
+ if (event.button === 0 && point && roomPickingEnabled() && isRoomOwnedPlate(plate)) {
1051
+ const footprint = roomOwnedPlateDrillTarget(useScene.getState().nodes, plate as SlabNode)
1052
+ if (footprint) handleSelect(footprint.id as AnyNodeId, event)
1053
+ else {
1054
+ const hit = resolvePlanRoomHit(id, point)
1055
+ if (hit) selectRoom(hit)
1056
+ event.stopPropagation()
1057
+ }
1058
+ return
1059
+ }
1019
1060
  if (startDirectMoveDrag(id, event)) return
1020
1061
  if (startDirectRotateDrag(id, event)) return
1021
1062
  if (startGroupMoveDrag(id, event)) return
@@ -1241,6 +1282,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1241
1282
  if (event.button !== 0) return
1242
1283
  if (movingNode) return
1243
1284
 
1285
+ // A wall push arrow runs the same push drag as the 3D arrows (its own
1286
+ // gesture, preview and commit), not a registry affordance session.
1287
+ if (affordance === WALL_PUSH_AFFORDANCE) {
1288
+ event.preventDefault()
1289
+ event.stopPropagation()
1290
+ suppressBoxSelectForPointer(event)
1291
+ runFloorplanWallPush(payload, event.clientX, event.clientY)
1292
+ return
1293
+ }
1294
+
1244
1295
  const sceneNodes = useScene.getState().nodes
1245
1296
  const node = sceneNodes[nodeId]
1246
1297
  if (!node) return
@@ -1266,13 +1317,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1266
1317
  sceneApi: createSceneApi(useScene),
1267
1318
  })
1268
1319
 
1269
- const snapshots: NodeSnapshot[] = []
1270
- for (const id of session.affectedIds) {
1271
- const n = sceneNodes[id]
1272
- if (n) snapshots.push(snapshotNode(n))
1273
- }
1274
-
1275
1320
  pauseSceneHistory(useScene)
1321
+ const writes = createSessionWrites()
1322
+ writes.watch(session.affectedIds)
1276
1323
 
1277
1324
  // Rotation readout setup. The wedge radius tracks the grab distance
1278
1325
  // from the pivot (≈ the handle's orbit), nudged inward so the swept
@@ -1304,7 +1351,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1304
1351
  captureTarget,
1305
1352
  handleId,
1306
1353
  session,
1307
- snapshots,
1354
+ writes,
1308
1355
  historyPaused: true,
1309
1356
  lastPlanPoint: initialPlanPoint,
1310
1357
  rotation,
@@ -1337,8 +1384,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1337
1384
  cancelFloorplanAffordanceDrag(
1338
1385
  dragRef,
1339
1386
  {
1340
- restoreSnapshots: (snapshots) =>
1341
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots)),
1342
1387
  resumeHistory: () => resumeSceneHistory(useScene),
1343
1388
  clearPreview: (id) => {
1344
1389
  useLiveNodeOverrides.getState().clear(id)
@@ -1364,15 +1409,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1364
1409
  if (!planPoint) return
1365
1410
 
1366
1411
  drag.lastPlanPoint = planPoint
1367
- drag.session.apply({
1368
- planPoint,
1369
- modifiers: {
1370
- shiftKey: event.shiftKey,
1371
- altKey: event.altKey,
1372
- ctrlKey: event.ctrlKey,
1373
- metaKey: event.metaKey,
1374
- },
1375
- })
1412
+ drag.writes.record(() =>
1413
+ drag.session.apply({
1414
+ planPoint,
1415
+ modifiers: {
1416
+ shiftKey: event.shiftKey,
1417
+ altKey: event.altKey,
1418
+ ctrlKey: event.ctrlKey,
1419
+ metaKey: event.metaKey,
1420
+ },
1421
+ }),
1422
+ )
1376
1423
 
1377
1424
  // Live rotation readout. Sweep from the bearing at grab to the
1378
1425
  // current pointer bearing around the pivot — the same measurement
@@ -1409,11 +1456,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1409
1456
  // Sessions with a `commit` hook own their atomic write (e.g.
1410
1457
  // affordances that publish to `useLiveNodeOverrides` during
1411
1458
  // `apply()` and never touch scene mid-drag). Mirrors the move
1412
- // overlay's `session.commit` path — revert untracked (no-op when
1413
- // the session never wrote to scene), resume history, then let
1414
- // the session do the tracked write.
1459
+ // overlay's `session.commit` path — take back what the session wrote
1460
+ // (nothing, for those), resume history, then let the session do the
1461
+ // tracked write.
1415
1462
  if (commitValid && drag.session.commit) {
1416
- useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
1463
+ drag.writes.revert()
1417
1464
  if (drag.historyPaused) {
1418
1465
  resumeSceneHistory(useScene)
1419
1466
  drag.historyPaused = false
@@ -1430,37 +1477,23 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1430
1477
  }
1431
1478
 
1432
1479
  // Legacy compatibility for sessions that still wrote preview state into
1433
- // `useScene` during `apply()`: capture the final state BEFORE the revert
1434
- // so we know what to re-apply post-resume. New sessions should provide a
1435
- // `commit()` hook and preview through live overrides/transforms instead.
1436
- const sceneNodes = useScene.getState().nodes
1437
- const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
1438
- for (const snap of drag.snapshots) {
1439
- const current = sceneNodes[snap.id]
1440
- if (!current) continue
1441
- const data: Record<string, unknown> = {}
1442
- let changed = false
1443
- for (const [key, before] of Object.entries(snap.data)) {
1444
- const after = (current as unknown as Record<string, unknown>)[key]
1445
- if (!deepEqual(before, after)) {
1446
- data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
1447
- changed = true
1448
- }
1449
- }
1450
- if (changed) finalUpdates.push({ id: snap.id, data })
1451
- }
1480
+ // `useScene` during `apply()`: the fields they wrote are re-applied as
1481
+ // one tracked change after the untracked revert. New sessions should
1482
+ // provide a `commit()` hook and preview through live overrides/transforms.
1483
+ const changes = drag.writes.changes()
1484
+ const changed = changes.create.length + changes.update.length + changes.delete.length > 0
1452
1485
 
1453
- if (commitValid && finalUpdates.length > 0) {
1486
+ if (commitValid && changed) {
1454
1487
  // Legacy single-undo dance (mirrors the old 3D move-endpoint-tool):
1455
- // 1. Revert to baseline while history is still paused (untracked).
1488
+ // 1. Take back the session's writes while history is still paused.
1456
1489
  // 2. Resume history.
1457
- // 3. Re-apply the final state — recorded as one tracked change.
1458
- useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
1490
+ // 3. Re-apply them — recorded as one tracked change.
1491
+ drag.writes.revert()
1459
1492
  if (drag.historyPaused) {
1460
1493
  resumeSceneHistory(useScene)
1461
1494
  drag.historyPaused = false
1462
1495
  }
1463
- useScene.getState().updateNodes(finalUpdates)
1496
+ useScene.getState().applyNodeChanges(changes)
1464
1497
  commitPerfAction()
1465
1498
  sfxEmitter.emit('sfx:structure-build')
1466
1499
  } else {
@@ -1468,7 +1501,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1468
1501
  // resume without committing. Also clear any live overrides
1469
1502
  // the session published (no-op when the session writes to
1470
1503
  // scene directly).
1471
- useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
1504
+ drag.writes.revert()
1472
1505
  if (drag.historyPaused) {
1473
1506
  resumeSceneHistory(useScene)
1474
1507
  drag.historyPaused = false
@@ -1504,15 +1537,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1504
1537
  ) {
1505
1538
  return
1506
1539
  }
1507
- drag.session.apply({
1508
- planPoint: drag.lastPlanPoint,
1509
- modifiers: {
1510
- shiftKey: event.shiftKey,
1511
- altKey: event.altKey,
1512
- ctrlKey: event.ctrlKey,
1513
- metaKey: event.metaKey,
1514
- },
1515
- })
1540
+ drag.writes.record(() =>
1541
+ drag.session.apply({
1542
+ planPoint: drag.lastPlanPoint,
1543
+ modifiers: {
1544
+ shiftKey: event.shiftKey,
1545
+ altKey: event.altKey,
1546
+ ctrlKey: event.ctrlKey,
1547
+ metaKey: event.metaKey,
1548
+ },
1549
+ }),
1550
+ )
1516
1551
  }
1517
1552
 
1518
1553
  window.addEventListener('pointermove', onPointerMove)
@@ -1542,6 +1577,48 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1542
1577
 
1543
1578
  const palette = renderCtx?.palette
1544
1579
 
1580
+ const renderEntries = (pass: FloorplanRenderPass) =>
1581
+ entries.map((entry) => (
1582
+ <FloorplanRegistryEntry
1583
+ activeDragId={handleIdForNode(activeDragId, entry.id)}
1584
+ annotationVisibility={annotationVisibility}
1585
+ floorplanMode={floorplanMode}
1586
+ floorplanVisible={floorplanVisible}
1587
+ geometryCacheRef={geometryCacheRef}
1588
+ hatchPatternId={renderCtx?.hatchPatternId}
1589
+ hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1590
+ interactiveElevators={interactiveElevators}
1591
+ isMarqueeSelectionActive={isMarqueeSelectionActive}
1592
+ key={`${pass}-${entry.id}`}
1593
+ levelDataCacheRef={levelDataCacheRef}
1594
+ levelNodeIdsByType={floorplanData.levelNodeIdsByType}
1595
+ moving={movingNode?.id === entry.id}
1596
+ node={entry.node}
1597
+ nodeId={entry.id}
1598
+ nodes={nodes}
1599
+ onClickStop={handleClickStop}
1600
+ onEntryPointerDown={handleEntryPointerDown}
1601
+ onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
1602
+ onHandleHoverChange={setHoveredHandleId}
1603
+ onHandleDoubleClick={commitAffordanceAction}
1604
+ onHandlePointerDown={startAffordanceDrag}
1605
+ palette={palette}
1606
+ unitFocus={unitFocus}
1607
+ pass={pass}
1608
+ sceneRotationDeg={sceneRotationDeg}
1609
+ setMovingNode={setMovingNode}
1610
+ setMovingNodeOrigin={setMovingNodeOrigin}
1611
+ siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1612
+ unit={unit}
1613
+ metricNotation={metricNotation}
1614
+ wallDimensionReference={effectiveWallDimensionReference}
1615
+ visibilityRootId={
1616
+ entry.visibilityRootId ?? (entry.ctxOverrides ? undefined : (levelId as AnyNodeId))
1617
+ }
1618
+ ctxOverrides={entry.ctxOverrides}
1619
+ />
1620
+ ))
1621
+
1545
1622
  return (
1546
1623
  // The outer wrapper stops `click` events that escape an entry's
1547
1624
  // `onClick={handleClickStop}`. The base+overlay split means
@@ -1567,48 +1644,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1567
1644
  strokes, hatches). Lower-rank kinds (zones) paint first so
1568
1645
  higher-rank kinds (slabs, then walls / items / shelves) layer
1569
1646
  on top in the expected document-order z-stack. */}
1570
- <g className="floorplan-registry-base">
1571
- {entries.map((entry) => (
1572
- <FloorplanRegistryEntry
1573
- activeDragId={handleIdForNode(activeDragId, entry.id)}
1574
- annotationVisibility={annotationVisibility}
1575
- floorplanMode={floorplanMode}
1576
- floorplanVisible={floorplanVisible}
1577
- geometryCacheRef={geometryCacheRef}
1578
- hatchPatternId={renderCtx?.hatchPatternId}
1579
- hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1580
- interactiveElevators={interactiveElevators}
1581
- isMarqueeSelectionActive={isMarqueeSelectionActive}
1582
- key={`base-${entry.id}`}
1583
- levelDataCacheRef={levelDataCacheRef}
1584
- levelNodeIdsByType={floorplanData.levelNodeIdsByType}
1585
- moving={movingNode?.id === entry.id}
1586
- node={entry.node}
1587
- nodeId={entry.id}
1588
- nodes={nodes}
1589
- onClickStop={handleClickStop}
1590
- onEntryPointerDown={handleEntryPointerDown}
1591
- onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
1592
- onHandleHoverChange={setHoveredHandleId}
1593
- onHandleDoubleClick={commitAffordanceAction}
1594
- onHandlePointerDown={startAffordanceDrag}
1595
- palette={palette}
1596
- unitFocus={unitFocus}
1597
- pass="base"
1598
- sceneRotationDeg={sceneRotationDeg}
1599
- setMovingNode={setMovingNode}
1600
- setMovingNodeOrigin={setMovingNodeOrigin}
1601
- siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1602
- unit={unit}
1603
- metricNotation={metricNotation}
1604
- wallDimensionReference={effectiveWallDimensionReference}
1605
- visibilityRootId={
1606
- entry.visibilityRootId ?? (entry.ctxOverrides ? undefined : (levelId as AnyNodeId))
1607
- }
1608
- ctxOverrides={entry.ctxOverrides}
1609
- />
1610
- ))}
1611
- </g>
1647
+ <g className="floorplan-registry-base">{renderEntries('base')}</g>
1648
+ <RoomHighlight2D levelId={levelId} />
1649
+ <RoomLabels2D levelId={levelId} />
1650
+ <FloorRegionControls2D levelId={levelId} />
1612
1651
  {/* Overlay pass — interactive handles (vertex / midpoint / edge /
1613
1652
  move) and labels (text / dimensions). Painted after every base
1614
1653
  entry so polygon-editor chrome on a selected slab stays above
@@ -1616,49 +1655,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1616
1655
  slab + wall geometry sitting on top of the zone. Each overlay
1617
1656
  still routes through the same selection-handling `<g>` so a
1618
1657
  click on a zone's name selects the zone. */}
1619
- <g className="floorplan-registry-overlay">
1620
- {entries.map((entry) => (
1621
- <FloorplanRegistryEntry
1622
- activeDragId={handleIdForNode(activeDragId, entry.id)}
1623
- annotationVisibility={annotationVisibility}
1624
- floorplanMode={floorplanMode}
1625
- floorplanVisible={floorplanVisible}
1626
- geometryCacheRef={geometryCacheRef}
1627
- hatchPatternId={renderCtx?.hatchPatternId}
1628
- hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1629
- interactiveElevators={interactiveElevators}
1630
- isMarqueeSelectionActive={isMarqueeSelectionActive}
1631
- key={`overlay-${entry.id}`}
1632
- levelDataCacheRef={levelDataCacheRef}
1633
- levelNodeIdsByType={floorplanData.levelNodeIdsByType}
1634
- moving={movingNode?.id === entry.id}
1635
- node={entry.node}
1636
- nodeId={entry.id}
1637
- nodes={nodes}
1638
- onClickStop={handleClickStop}
1639
- onEntryPointerDown={handleEntryPointerDown}
1640
- onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
1641
- onHandleHoverChange={setHoveredHandleId}
1642
- onHandleDoubleClick={commitAffordanceAction}
1643
- onHandlePointerDown={startAffordanceDrag}
1644
- palette={palette}
1645
- unitFocus={unitFocus}
1646
- pass="overlay"
1647
- sceneRotationDeg={sceneRotationDeg}
1648
- setMovingNode={setMovingNode}
1649
- setMovingNodeOrigin={setMovingNodeOrigin}
1650
- siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1651
- unit={unit}
1652
- metricNotation={metricNotation}
1653
- wallDimensionReference={effectiveWallDimensionReference}
1654
- visibilityRootId={
1655
- entry.visibilityRootId ?? (entry.ctxOverrides ? undefined : (levelId as AnyNodeId))
1656
- }
1657
- ctxOverrides={entry.ctxOverrides}
1658
- />
1659
- ))}
1660
- </g>
1658
+ <g className="floorplan-registry-overlay">{renderEntries('overlay')}</g>
1659
+ {/* Handles pass — every grabbable handle above every node's overlay,
1660
+ so a neighbour's outline never covers a selected wall's corner. */}
1661
+ <g className="floorplan-registry-handles">{renderEntries('handles')}</g>
1662
+ <RoomHandles2D levelId={levelId} />
1663
+ <RoomHandleDragPreview2D levelId={levelId} />
1661
1664
  <FloorplanAnnotationLayoutResolver active={floorplanVisible} />
1665
+ {/* Click-to-type dimension labels. Portals its HTML input to
1666
+ document.body, so mounting it inside the SVG tree is safe. */}
1667
+ <FloorplanDimensionEditOverlay />
1662
1668
  {/* Dashed group bbox — shows what a group drag carries along while a
1663
1669
  multi-selection exists, rides the live delta mid-drag, and doubles
1664
1670
  as the group's whole-area drag handle. */}
@@ -2023,16 +2029,24 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2023
2029
  }: FloorplanRegistryEntryProps): React.ReactElement | null {
2024
2030
  const selected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
2025
2031
  const highlighted = useViewer((state) => state.previewSelectedIds.includes(nodeId))
2026
- const suppressHandles = useViewer(
2032
+ const multiSelected = useViewer(
2027
2033
  (state) =>
2028
2034
  state.selection.selectedIds.length > 1 && state.selection.selectedIds.includes(nodeId),
2029
2035
  )
2036
+ // A ceiling's vertex / edge handles belong to its Edit ceiling session.
2037
+ const ceilingWithoutSession = useCeilingEditSession(
2038
+ (state) => node.type === 'ceiling' && state.session?.ceilingId !== nodeId,
2039
+ )
2040
+ const suppressHandles = multiSelected || ceilingWithoutSession
2030
2041
  const selectedLevelId = useViewer((state) => state.selection.levelId)
2031
2042
  const selectionProxyId = resolveSelectionProxyId(
2032
2043
  node,
2033
2044
  nodes as Record<string, AnyNode | undefined>,
2034
2045
  )
2035
- const hovered = useViewer((state) => state.hoveredId === selectionProxyId)
2046
+ const phase = useEditor((state) => state.phase)
2047
+ const hoverTargetId =
2048
+ phase === 'site' ? (resolveCanvasBuildingId(node, nodes) ?? selectionProxyId) : selectionProxyId
2049
+ const hovered = useViewer((state) => state.hoveredId === hoverTargetId)
2036
2050
  const setHoveredId = useViewer((state) => state.setHoveredId)
2037
2051
  const referencedAnnotationRole = useViewer((state) =>
2038
2052
  floorplanEntryReferencedAnnotationRole(node, nodes, new Set(state.selection.selectedIds)),
@@ -2041,7 +2055,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2041
2055
  state.activeRotateNodeId === nodeId ? nodeId : null,
2042
2056
  )
2043
2057
  const groupMoveCursor =
2044
- suppressHandles && classifyParticipant(node, selectedLevelId, nodes) !== null
2058
+ multiSelected && classifyParticipant(node, selectedLevelId, nodes) !== null
2045
2059
  const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
2046
2060
  const liveOverride = useLiveNodeOverrides((s) =>
2047
2061
  floorplanVisible ? s.overrides.get(nodeId) : undefined,
@@ -2066,25 +2080,55 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2066
2080
 
2067
2081
  // Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
2068
2082
  // the highlight halo in 3D as well as registry floor-plan hover strokes.
2069
- const handlePointerEnter = useCallback(() => {
2070
- const currentNode = useScene.getState().nodes[nodeId]
2071
- setHoveredId(
2072
- currentNode
2073
- ? resolveSelectionProxyId(
2074
- currentNode,
2075
- useScene.getState().nodes as Record<string, AnyNode | undefined>,
2076
- )
2077
- : nodeId,
2078
- )
2079
- }, [nodeId, setHoveredId])
2083
+ const handlePointerEnter = useCallback(
2084
+ (event: ReactPointerEvent<SVGGElement>) => {
2085
+ if (floorplanEntryYieldsToToolNow() || !isIdle(useInteractionScope.getState().scope)) return
2086
+ const currentNode = useScene.getState().nodes[nodeId]
2087
+ if (currentNode && useEditor.getState().phase === 'site') {
2088
+ const buildingId = resolveCanvasBuildingId(currentNode, useScene.getState().nodes)
2089
+ if (buildingId) {
2090
+ setHoveredId(buildingId)
2091
+ return
2092
+ }
2093
+ }
2094
+ if (currentNode?.type === 'zone' && roomPickingEnabled()) {
2095
+ hoverZoneArea(nodeId, selectionModifiersFromEvent(event))
2096
+ return
2097
+ }
2098
+ const point = clientToPlan(event.clientX, event.clientY)
2099
+ if (
2100
+ point &&
2101
+ roomPickingEnabled() &&
2102
+ hoverRoomFromHit(
2103
+ resolvePlanRoomHit(nodeId, point),
2104
+ selectionModifiersFromEvent(event),
2105
+ nodeId,
2106
+ )
2107
+ )
2108
+ return
2109
+ setHoveredId(
2110
+ currentNode
2111
+ ? resolveSelectionProxyId(
2112
+ currentNode,
2113
+ useScene.getState().nodes as Record<string, AnyNode | undefined>,
2114
+ )
2115
+ : nodeId,
2116
+ )
2117
+ },
2118
+ [nodeId, setHoveredId],
2119
+ )
2080
2120
 
2081
2121
  const handlePointerLeave = useCallback(() => {
2122
+ useEditor.getState().setHoveredRoom(null)
2082
2123
  const node = useScene.getState().nodes[nodeId]
2083
2124
  const targetId = node
2084
- ? resolveSelectionProxyId(
2085
- node,
2086
- useScene.getState().nodes as Record<string, AnyNode | undefined>,
2087
- )
2125
+ ? useEditor.getState().phase === 'site'
2126
+ ? (resolveCanvasBuildingId(node, useScene.getState().nodes) ??
2127
+ resolveSelectionProxyId(node, useScene.getState().nodes))
2128
+ : resolveSelectionProxyId(
2129
+ node,
2130
+ useScene.getState().nodes as Record<string, AnyNode | undefined>,
2131
+ )
2088
2132
  : nodeId
2089
2133
  if (useViewer.getState().hoveredId === targetId) setHoveredId(null)
2090
2134
  }, [nodeId, setHoveredId])
@@ -2160,7 +2204,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2160
2204
  wallDimensionReference,
2161
2205
  visibilityRootId,
2162
2206
  })
2163
- const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
2207
+ const rawGeometry = cacheEntry ? cacheEntry[pass] : null
2164
2208
  const visibleGeometry = rawGeometry
2165
2209
  ? filterFloorplanAnnotationGeometry(rawGeometry, presentationVisibility)
2166
2210
  : null
@@ -2168,7 +2212,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2168
2212
  // dimension chrome (all of which live in the overlay pass) while keeping
2169
2213
  // its highlighted body geometry.
2170
2214
  const geometry =
2171
- visibleGeometry && suppressHandles && pass === 'overlay'
2215
+ visibleGeometry && suppressHandles && pass !== 'base'
2172
2216
  ? stripHandleChrome(visibleGeometry)
2173
2217
  : visibleGeometry
2174
2218
  if (!geometry) return null
@@ -2183,6 +2227,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2183
2227
  onClick={entryClick}
2184
2228
  onPointerDown={entryPointerDown}
2185
2229
  onPointerEnter={handlePointerEnter}
2230
+ onPointerMove={handlePointerEnter}
2186
2231
  onPointerLeave={handlePointerLeave}
2187
2232
  style={groupMoveCursor ? MOVE_CURSOR_STYLE : POINTER_CURSOR_STYLE}
2188
2233
  >
@@ -2401,6 +2446,7 @@ export function buildFloorplanEntryGeometry({
2401
2446
  selected,
2402
2447
  unit,
2403
2448
  metricNotation,
2449
+ roomLabelOverlay: true,
2404
2450
  wallDimensionReference,
2405
2451
  highlighted,
2406
2452
  hovered,
@@ -2426,6 +2472,7 @@ export function buildFloorplanEntryGeometry({
2426
2472
  automaticDimensions,
2427
2473
  metricNotation,
2428
2474
  purpose: 'edit',
2475
+ roomLabelOverlay: true,
2429
2476
  wallDimensionReference,
2430
2477
  }),
2431
2478
  viewState: palette
@@ -2467,10 +2514,13 @@ export function buildFloorplanEntryGeometry({
2467
2514
  transform: ctxOverrides.outputTransform,
2468
2515
  }
2469
2516
  : unprojectedGeometry
2470
- const { base, overlay } = geometry
2517
+ const { base, overlay: overlayWithHandles } = geometry
2471
2518
  ? splitFloorplanOverlay(geometry)
2472
2519
  : { base: null, overlay: null }
2473
- const entry: CacheEntry = { deps, base, overlay, node: effectiveNode }
2520
+ const { rest: overlay, handles } = overlayWithHandles
2521
+ ? splitFloorplanHandles(overlayWithHandles)
2522
+ : { rest: null, handles: null }
2523
+ const entry: CacheEntry = { deps, base, overlay, handles, node: effectiveNode }
2474
2524
  geometryCache.set(nodeId, entry)
2475
2525
  return entry
2476
2526
  }
@@ -2923,12 +2973,13 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
2923
2973
  // body (matches the 3D `HANDLE_OFFSET`).
2924
2974
  const bi = 0.03 // base inset
2925
2975
  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`
2926
- // Indigo palette to match the 3D `WallMoveSideHandles` arrows
2976
+ // Indigo palette to match the 3D wall push arrows
2927
2977
  // (`ARROW_COLOR` / `ARROW_HOVER_COLOR`) and the corner-sphere
2928
2978
  // accent in `floating-action-menu.tsx`.
2929
2979
  const fill = isHovered ? '#a5b4fc' : '#8381ed'
2930
2980
  const angleDeg = (g.angle * 180) / Math.PI
2931
- const cursor = g.affordance ? 'ew-resize' : 'move'
2981
+ const cursor =
2982
+ g.affordance === WALL_PUSH_AFFORDANCE ? 'grab' : g.affordance ? 'ew-resize' : 'move'
2932
2983
  const affordance = g.affordance
2933
2984
  const payload = g.payload
2934
2985
  // No hover-grow: a scaling transform would enlarge the hit area
@@ -3515,6 +3566,42 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
3515
3566
  return { base: g, overlay: null }
3516
3567
  }
3517
3568
 
3569
+ /** The kinds a pointer grabs: they paint and hit-test above every node's outline. */
3570
+ const HANDLE_KINDS = new Set<FloorplanGeometry['kind']>([
3571
+ 'endpoint-handle',
3572
+ 'midpoint-handle',
3573
+ 'edge-handle',
3574
+ 'move-handle',
3575
+ 'move-arrow',
3576
+ 'rotate-arrow',
3577
+ ])
3578
+
3579
+ /**
3580
+ * Splits an overlay tree into its grabbable handles and the rest (labels,
3581
+ * dimensions, outlines). The handles render in a pass of their own after
3582
+ * every node's overlay: an outline painted later (a neighbouring wall's body)
3583
+ * would otherwise cover a selected wall's corner handle and take its press —
3584
+ * the 2D twin of the 3D handle hit priority.
3585
+ */
3586
+ export function splitFloorplanHandles(g: FloorplanGeometry): {
3587
+ rest: FloorplanGeometry | null
3588
+ handles: FloorplanGeometry | null
3589
+ } {
3590
+ if (HANDLE_KINDS.has(g.kind)) return { rest: null, handles: g }
3591
+ if (g.kind !== 'group') return { rest: g, handles: null }
3592
+ const rest: FloorplanGeometry[] = []
3593
+ const handles: FloorplanGeometry[] = []
3594
+ for (const child of g.children) {
3595
+ const split = splitFloorplanHandles(child)
3596
+ if (split.rest) rest.push(split.rest)
3597
+ if (split.handles) handles.push(split.handles)
3598
+ }
3599
+ return {
3600
+ rest: rest.length ? { ...g, children: rest } : null,
3601
+ handles: handles.length ? { ...g, children: handles } : null,
3602
+ }
3603
+ }
3604
+
3518
3605
  /**
3519
3606
  * Per-node edit chrome hidden while a multi-selection is active: the group is
3520
3607
  * manipulated as one rigid piece (drag to move, R/T to rotate), so individual
@@ -3703,29 +3790,6 @@ function depsValueEqual(a: unknown, b: unknown): boolean {
3703
3790
  */
3704
3791
  export { floorplanLayerRank }
3705
3792
 
3706
- function deepEqual(a: unknown, b: unknown): boolean {
3707
- if (a === b) return true
3708
- if (Array.isArray(a) && Array.isArray(b)) {
3709
- if (a.length !== b.length) return false
3710
- for (let i = 0; i < a.length; i++) {
3711
- if (!deepEqual(a[i], b[i])) return false
3712
- }
3713
- return true
3714
- }
3715
- if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
3716
- const aKeys = Object.keys(a as Record<string, unknown>)
3717
- const bKeys = Object.keys(b as Record<string, unknown>)
3718
- if (aKeys.length !== bKeys.length) return false
3719
- for (const key of aKeys) {
3720
- if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
3721
- return false
3722
- }
3723
- }
3724
- return true
3725
- }
3726
- return false
3727
- }
3728
-
3729
3793
  const ROTATION_WEDGE_COLOR = '#8381ed'
3730
3794
  const ROTATION_WEDGE_SEGMENTS = 48
3731
3795