@pascal-app/editor 1.0.1 → 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 (498) hide show
  1. package/package.json +16 -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 +221 -128
  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 +37 -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 +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -12,6 +12,7 @@ import {
12
12
  type FloorplanPoint,
13
13
  type FloorplanScope,
14
14
  type GeometryContext,
15
+ hidesDescendants,
15
16
  isNodeKindEnabled,
16
17
  isRegistryMovable,
17
18
  kindsWithFloorplanScope,
@@ -22,6 +23,7 @@ import {
22
23
  resolveBuildingForLevel,
23
24
  resolveSelectionProxyId,
24
25
  resumeSceneHistory,
26
+ type SlabNode,
25
27
  useInteractive,
26
28
  useLiveNodeOverrides,
27
29
  useLiveTransforms,
@@ -42,6 +44,14 @@ import {
42
44
  } from 'react'
43
45
  import { useShallow } from 'zustand/react/shallow'
44
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'
45
55
  import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
46
56
  import {
47
57
  canDirectRotateNode,
@@ -52,6 +62,7 @@ import {
52
62
  snapDirectRotationDelta,
53
63
  } from '../../../lib/direct-manipulation'
54
64
  import { createEditorApi } from '../../../lib/editor-api'
65
+ import { isRoomOwnedPlate, roomOwnedPlateDrillTarget } from '../../../lib/floor-footprints'
55
66
  import {
56
67
  type FloorplanAnnotationVisibility,
57
68
  filterFloorplanAnnotationGeometry,
@@ -85,13 +96,21 @@ import {
85
96
  isIdle,
86
97
  tangentReshapeScope,
87
98
  } from '../../../lib/interaction/scope'
88
- 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'
89
108
  import { sfxEmitter } from '../../../lib/sfx-bus'
90
109
  import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
91
110
  import { paintZoneMembership } from '../../../lib/units'
92
111
  import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
93
112
  import useDrawingView from '../../../store/use-drawing-view'
94
- import useEditor, { isAngleSnapActive } from '../../../store/use-editor'
113
+ import useEditor, { isAngleSnapActive, type Mode } from '../../../store/use-editor'
95
114
  import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
96
115
  import useFloorplanMode from '../../../store/use-floorplan-mode'
97
116
  import useInteractionScope, {
@@ -100,8 +119,13 @@ import useInteractionScope, {
100
119
  useMovingNode,
101
120
  } from '../../../store/use-interaction-scope'
102
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'
103
124
  import { startGroupPickUp } from '../../editor/group-actions'
104
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'
105
129
  import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
106
130
  import {
107
131
  FloorplanGroupSelectionBox,
@@ -119,6 +143,7 @@ import {
119
143
  resolveSvgAnnotationCollisions,
120
144
  svgAnnotationLabelId,
121
145
  } from './floorplan-annotation-layout'
146
+ import { FloorplanDimensionEditOverlay } from './floorplan-dimension-edit-overlay'
122
147
  import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
123
148
  import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
124
149
  import {
@@ -178,22 +203,19 @@ const ScaleAwareFloorplanGroupSelectionBox = memo(function ScaleAwareFloorplanGr
178
203
  return <FloorplanGroupSelectionBox {...props} unitsPerPixel={unitsPerPixel} />
179
204
  })
180
205
 
181
- /**
182
- * Snapshot of node fields captured at drag-start, used by the single-undo
183
- * dance to revert untracked before re-applying as a single tracked
184
- * change. The dispatcher only knows about the `affectedIds` the
185
- * affordance declares; it captures whatever fields exist on each node by
186
- * cloning the full record minus the registry-managed `id` / `type`.
187
- */
188
- type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
189
-
190
206
  type ActiveDrag = {
191
207
  pointerId: number
192
208
  captureTarget: Element
193
209
  /** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */
194
210
  handleId: string
195
211
  session: FloorplanAffordanceSession
196
- 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
197
219
  historyPaused: boolean
198
220
  /**
199
221
  * Last plan point handed to `session.apply` (the grab point until the first
@@ -217,7 +239,6 @@ type ActiveDrag = {
217
239
  }
218
240
 
219
241
  type FloorplanAffordanceCancelEffects = {
220
- restoreSnapshots: (snapshots: NodeSnapshot[]) => void
221
242
  resumeHistory: () => void
222
243
  clearPreview: (id: AnyNodeId) => void
223
244
  clearSnapFeedback: () => void
@@ -241,7 +262,7 @@ export function cancelFloorplanAffordanceDrag(
241
262
  drag.captureTarget.releasePointerCapture?.(drag.pointerId)
242
263
  }
243
264
 
244
- effects.restoreSnapshots(drag.snapshots)
265
+ drag.writes.revert()
245
266
  if (drag.historyPaused) {
246
267
  effects.resumeHistory()
247
268
  drag.historyPaused = false
@@ -377,6 +398,7 @@ type CacheEntry = {
377
398
  deps: NodeDeps
378
399
  base: FloorplanGeometry | null
379
400
  overlay: FloorplanGeometry | null
401
+ handles: FloorplanGeometry | null
380
402
  node: AnyNode
381
403
  }
382
404
 
@@ -432,7 +454,7 @@ type FloorplanLevelDataHook = (args: {
432
454
  nodes: Record<string, AnyNode>
433
455
  }) => unknown
434
456
 
435
- type FloorplanRenderPass = 'base' | 'overlay'
457
+ type FloorplanRenderPass = 'base' | 'overlay' | 'handles'
436
458
 
437
459
  const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const
438
460
  // Group members advertise the drag-to-move-the-selection gesture.
@@ -440,49 +462,48 @@ const MOVE_CURSOR_STYLE = { cursor: 'move' } as const
440
462
  const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
441
463
 
442
464
  export function isFloorplanOpeningPlacementState({
443
- phase,
444
465
  mode,
445
466
  tool,
446
467
  movingNodeHasWallOpeningPlacement,
447
468
  }: {
448
- phase: string
449
469
  mode: string
450
470
  tool: string | null
451
471
  movingNodeHasWallOpeningPlacement: boolean
452
472
  }): boolean {
453
473
  return (
454
- (phase === 'structure' && mode === 'build' && (tool === 'door' || tool === 'window')) ||
474
+ (mode === 'build' && (tool === 'door' || tool === 'window')) ||
455
475
  movingNodeHasWallOpeningPlacement
456
476
  )
457
477
  }
458
478
 
459
- function isFloorplanOpeningPlacementActiveNow(): boolean {
460
- const { phase, mode, tool } = useEditor.getState()
479
+ /**
480
+ * Whether a press on an entry belongs to the active tool instead of selecting
481
+ * the entry. Build tools own the plan the way their 3D tools own the canvas,
482
+ * where selection only runs in select mode: a wall drawn onto another wall
483
+ * must place its point (T-junction), not select the wall under the cursor.
484
+ */
485
+ export function floorplanEntryYieldsToTool(state: {
486
+ mode: Mode
487
+ openingPlacement: boolean
488
+ }): boolean {
489
+ return state.mode === 'build' || state.openingPlacement
490
+ }
491
+
492
+ function floorplanEntryYieldsToToolNow(): boolean {
493
+ const { mode, tool } = useEditor.getState()
461
494
  const movingNode = getMovingNode()
462
- return isFloorplanOpeningPlacementState({
463
- phase,
495
+ return floorplanEntryYieldsToTool({
464
496
  mode,
465
- tool,
466
- movingNodeHasWallOpeningPlacement:
467
- movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement,
497
+ openingPlacement: isFloorplanOpeningPlacementState({
498
+ mode,
499
+ tool,
500
+ movingNodeHasWallOpeningPlacement:
501
+ movingNode != null &&
502
+ !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement,
503
+ }),
468
504
  })
469
505
  }
470
506
 
471
- function snapshotNode(node: AnyNode): NodeSnapshot {
472
- // Shallow-clone every non-id, non-type field. Arrays / vec tuples are
473
- // deep-cloned to detach from the live store reference.
474
- const data: Record<string, unknown> = {}
475
- for (const [key, value] of Object.entries(node)) {
476
- if (key === 'id' || key === 'type' || key === 'object' || key === 'parentId') continue
477
- data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
478
- }
479
- return { id: node.id, data }
480
- }
481
-
482
- function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
483
- return snapshots.map((s) => ({ id: s.id, data: s.data }))
484
- }
485
-
486
507
  // Stable empty sentinel used by per-entry builders while the floor plan is
487
508
  // hidden; committed scene edits still flow through `useScene`.
488
509
  const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
@@ -546,6 +567,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
546
567
  }, [selectedLevelId, ambientBuildingSourceId, nodes])
547
568
 
548
569
  const levelId = selectedLevelId ?? ambientLevelId
570
+ useRoomRecords(levelId)
549
571
  const isAmbient = !selectedLevelId && !!ambientLevelId
550
572
  const activeBuildingId = useMemo(
551
573
  () => (levelId ? resolveBuildingForLevel(levelId as AnyNodeId, nodes) : null),
@@ -558,27 +580,25 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
558
580
  const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0
559
581
  const setMovingNode = useEditor((s) => s.setMovingNode)
560
582
  const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
561
- // Door / window placement (both build and move) needs the SVG's
562
- // background click handler to run — it finds the closest wall via
563
- // `findClosestWallPoint` and emits `wall:click` for the door / window
564
- // tool. When the user clicks *on top of* a wall in this mode, the
583
+ // Build tools and door / window placement (both build and move) need the
584
+ // SVG's background handlers to run — wall drafting places its point there,
585
+ // and opening placement finds the closest wall via `findClosestWallPoint`
586
+ // and emits `wall:click`. When the user clicks *on top of* a wall, the
565
587
  // wall's registry entry would otherwise swallow the click via
566
- // `handleClickStop` / `handleSelect`, so the placement never fires.
567
- // Pass clicks through in that case.
588
+ // `handleClickStop` / `handleSelect`, so the tool never sees it. Pass
589
+ // clicks through in that case (`floorplanEntryYieldsToToolNow`).
568
590
  const editorMode = useEditor((s) => s.mode)
569
- const structureLayer = useEditor((s) => s.structureLayer)
570
591
  const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
571
592
  const endpointReshape = useEndpointReshape()
572
593
  const isMarqueeSelectionActive =
573
594
  editorMode === 'select' &&
574
595
  floorplanSelectionTool === 'marquee' &&
575
- structureLayer !== 'zones' &&
576
596
  !movingNode &&
577
597
  !endpointReshape
578
598
  // While the floor plan is not on screen (pure 3D view), per-entry live
579
599
  // selectors freeze to `undefined` so drag publishes do not re-render the
580
600
  // hidden floor-plan tree.
581
- const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
601
+ const floorplanVisible = useEditor((s) => isViewVisible(s, VIEW_2D))
582
602
  const drawingType = useDrawingView((s) => s.drawingType)
583
603
  const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility)
584
604
  const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference)
@@ -679,12 +699,37 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
679
699
  swallowNextClick(200)
680
700
  return
681
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
+ }
682
713
  const currentSelectedIds = useViewer.getState().selection.selectedIds
683
714
  let nextSelectedIds: string[]
684
715
  if (options.shouldToggle) {
685
- nextSelectedIds = currentSelectedIds.includes(id)
686
- ? currentSelectedIds.filter((selectedId) => selectedId !== id)
687
- : [...currentSelectedIds, id]
716
+ if (currentSelectedIds.includes(id)) {
717
+ // A room's slab and ceiling overlap in the plan and only the slab
718
+ // takes the click, so removing one removes the other too (a marquee
719
+ // picks both). 3D keeps single toggles: each is clickable there.
720
+ const nodes = useScene.getState().nodes
721
+ const node = nodes[id as AnyNodeId]
722
+ const counterparts = node
723
+ ? (getFloorplanNodeExtension(nodeRegistry.get(node.type))?.selectionCounterparts?.({
724
+ node,
725
+ nodes,
726
+ }) ?? [])
727
+ : []
728
+ const removed = new Set<string>([id, ...counterparts])
729
+ nextSelectedIds = currentSelectedIds.filter((selectedId) => !removed.has(selectedId))
730
+ } else {
731
+ nextSelectedIds = [...currentSelectedIds, id]
732
+ }
688
733
  } else if (options.isolateMember) {
689
734
  nextSelectedIds = [id]
690
735
  } else {
@@ -722,18 +767,21 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
722
767
  )
723
768
 
724
769
  const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
725
- if (isFloorplanOpeningPlacementActiveNow()) return
770
+ if (floorplanEntryYieldsToToolNow() || !isIdle(useInteractionScope.getState().scope)) return
726
771
  event.stopPropagation()
727
772
  }, [])
728
773
 
729
774
  const startDirectMoveDrag = useCallback(
730
775
  (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
731
- if (event.button !== 0) return false
776
+ if (event.button !== 0 || event.altKey) return false
732
777
 
733
778
  const node = useScene.getState().nodes[id]
734
779
  if (!node || !isRegistryMovable(node.type)) return false
735
780
  const currentSelectedIds = useViewer.getState().selection.selectedIds
736
- const allowPlainDrag = nodeRegistry.get(node.type)?.capabilities?.movable?.directDrag === true
781
+ const definition = nodeRegistry.get(node.type)
782
+ const allowPlainDrag =
783
+ getFloorplanNodeExtension(definition)?.directDrag === true ||
784
+ definition?.capabilities?.movable?.directDrag === true
737
785
  const commandModifier = event.metaKey || event.ctrlKey
738
786
  if (
739
787
  !shouldStartDirectMoveDrag({
@@ -980,7 +1028,35 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
980
1028
  // the stores at event time. Commit clears the interaction scope before
981
1029
  // React paints the next frame; a render-time `undefined` handler leaves
982
1030
  // a short dead zone where the first post-placement selection is lost.
983
- if (isFloorplanOpeningPlacementActiveNow()) 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
+ }
984
1060
  if (startDirectMoveDrag(id, event)) return
985
1061
  if (startDirectRotateDrag(id, event)) return
986
1062
  if (startGroupMoveDrag(id, event)) return
@@ -1206,6 +1282,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1206
1282
  if (event.button !== 0) return
1207
1283
  if (movingNode) return
1208
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
+
1209
1295
  const sceneNodes = useScene.getState().nodes
1210
1296
  const node = sceneNodes[nodeId]
1211
1297
  if (!node) return
@@ -1231,13 +1317,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1231
1317
  sceneApi: createSceneApi(useScene),
1232
1318
  })
1233
1319
 
1234
- const snapshots: NodeSnapshot[] = []
1235
- for (const id of session.affectedIds) {
1236
- const n = sceneNodes[id]
1237
- if (n) snapshots.push(snapshotNode(n))
1238
- }
1239
-
1240
1320
  pauseSceneHistory(useScene)
1321
+ const writes = createSessionWrites()
1322
+ writes.watch(session.affectedIds)
1241
1323
 
1242
1324
  // Rotation readout setup. The wedge radius tracks the grab distance
1243
1325
  // from the pivot (≈ the handle's orbit), nudged inward so the swept
@@ -1269,7 +1351,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1269
1351
  captureTarget,
1270
1352
  handleId,
1271
1353
  session,
1272
- snapshots,
1354
+ writes,
1273
1355
  historyPaused: true,
1274
1356
  lastPlanPoint: initialPlanPoint,
1275
1357
  rotation,
@@ -1302,8 +1384,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1302
1384
  cancelFloorplanAffordanceDrag(
1303
1385
  dragRef,
1304
1386
  {
1305
- restoreSnapshots: (snapshots) =>
1306
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots)),
1307
1387
  resumeHistory: () => resumeSceneHistory(useScene),
1308
1388
  clearPreview: (id) => {
1309
1389
  useLiveNodeOverrides.getState().clear(id)
@@ -1329,15 +1409,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1329
1409
  if (!planPoint) return
1330
1410
 
1331
1411
  drag.lastPlanPoint = planPoint
1332
- drag.session.apply({
1333
- planPoint,
1334
- modifiers: {
1335
- shiftKey: event.shiftKey,
1336
- altKey: event.altKey,
1337
- ctrlKey: event.ctrlKey,
1338
- metaKey: event.metaKey,
1339
- },
1340
- })
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
+ )
1341
1423
 
1342
1424
  // Live rotation readout. Sweep from the bearing at grab to the
1343
1425
  // current pointer bearing around the pivot — the same measurement
@@ -1374,11 +1456,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1374
1456
  // Sessions with a `commit` hook own their atomic write (e.g.
1375
1457
  // affordances that publish to `useLiveNodeOverrides` during
1376
1458
  // `apply()` and never touch scene mid-drag). Mirrors the move
1377
- // overlay's `session.commit` path — revert untracked (no-op when
1378
- // the session never wrote to scene), resume history, then let
1379
- // 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.
1380
1462
  if (commitValid && drag.session.commit) {
1381
- useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
1463
+ drag.writes.revert()
1382
1464
  if (drag.historyPaused) {
1383
1465
  resumeSceneHistory(useScene)
1384
1466
  drag.historyPaused = false
@@ -1395,37 +1477,23 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1395
1477
  }
1396
1478
 
1397
1479
  // Legacy compatibility for sessions that still wrote preview state into
1398
- // `useScene` during `apply()`: capture the final state BEFORE the revert
1399
- // so we know what to re-apply post-resume. New sessions should provide a
1400
- // `commit()` hook and preview through live overrides/transforms instead.
1401
- const sceneNodes = useScene.getState().nodes
1402
- const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
1403
- for (const snap of drag.snapshots) {
1404
- const current = sceneNodes[snap.id]
1405
- if (!current) continue
1406
- const data: Record<string, unknown> = {}
1407
- let changed = false
1408
- for (const [key, before] of Object.entries(snap.data)) {
1409
- const after = (current as unknown as Record<string, unknown>)[key]
1410
- if (!deepEqual(before, after)) {
1411
- data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
1412
- changed = true
1413
- }
1414
- }
1415
- if (changed) finalUpdates.push({ id: snap.id, data })
1416
- }
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
1417
1485
 
1418
- if (commitValid && finalUpdates.length > 0) {
1486
+ if (commitValid && changed) {
1419
1487
  // Legacy single-undo dance (mirrors the old 3D move-endpoint-tool):
1420
- // 1. Revert to baseline while history is still paused (untracked).
1488
+ // 1. Take back the session's writes while history is still paused.
1421
1489
  // 2. Resume history.
1422
- // 3. Re-apply the final state — recorded as one tracked change.
1423
- useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
1490
+ // 3. Re-apply them — recorded as one tracked change.
1491
+ drag.writes.revert()
1424
1492
  if (drag.historyPaused) {
1425
1493
  resumeSceneHistory(useScene)
1426
1494
  drag.historyPaused = false
1427
1495
  }
1428
- useScene.getState().updateNodes(finalUpdates)
1496
+ useScene.getState().applyNodeChanges(changes)
1429
1497
  commitPerfAction()
1430
1498
  sfxEmitter.emit('sfx:structure-build')
1431
1499
  } else {
@@ -1433,7 +1501,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1433
1501
  // resume without committing. Also clear any live overrides
1434
1502
  // the session published (no-op when the session writes to
1435
1503
  // scene directly).
1436
- useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
1504
+ drag.writes.revert()
1437
1505
  if (drag.historyPaused) {
1438
1506
  resumeSceneHistory(useScene)
1439
1507
  drag.historyPaused = false
@@ -1469,15 +1537,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1469
1537
  ) {
1470
1538
  return
1471
1539
  }
1472
- drag.session.apply({
1473
- planPoint: drag.lastPlanPoint,
1474
- modifiers: {
1475
- shiftKey: event.shiftKey,
1476
- altKey: event.altKey,
1477
- ctrlKey: event.ctrlKey,
1478
- metaKey: event.metaKey,
1479
- },
1480
- })
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
+ )
1481
1551
  }
1482
1552
 
1483
1553
  window.addEventListener('pointermove', onPointerMove)
@@ -1507,6 +1577,48 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1507
1577
 
1508
1578
  const palette = renderCtx?.palette
1509
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
+
1510
1622
  return (
1511
1623
  // The outer wrapper stops `click` events that escape an entry's
1512
1624
  // `onClick={handleClickStop}`. The base+overlay split means
@@ -1532,48 +1644,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1532
1644
  strokes, hatches). Lower-rank kinds (zones) paint first so
1533
1645
  higher-rank kinds (slabs, then walls / items / shelves) layer
1534
1646
  on top in the expected document-order z-stack. */}
1535
- <g className="floorplan-registry-base">
1536
- {entries.map((entry) => (
1537
- <FloorplanRegistryEntry
1538
- activeDragId={handleIdForNode(activeDragId, entry.id)}
1539
- annotationVisibility={annotationVisibility}
1540
- floorplanMode={floorplanMode}
1541
- floorplanVisible={floorplanVisible}
1542
- geometryCacheRef={geometryCacheRef}
1543
- hatchPatternId={renderCtx?.hatchPatternId}
1544
- hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1545
- interactiveElevators={interactiveElevators}
1546
- isMarqueeSelectionActive={isMarqueeSelectionActive}
1547
- key={`base-${entry.id}`}
1548
- levelDataCacheRef={levelDataCacheRef}
1549
- levelNodeIdsByType={floorplanData.levelNodeIdsByType}
1550
- moving={movingNode?.id === entry.id}
1551
- node={entry.node}
1552
- nodeId={entry.id}
1553
- nodes={nodes}
1554
- onClickStop={handleClickStop}
1555
- onEntryPointerDown={handleEntryPointerDown}
1556
- onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
1557
- onHandleHoverChange={setHoveredHandleId}
1558
- onHandleDoubleClick={commitAffordanceAction}
1559
- onHandlePointerDown={startAffordanceDrag}
1560
- palette={palette}
1561
- unitFocus={unitFocus}
1562
- pass="base"
1563
- sceneRotationDeg={sceneRotationDeg}
1564
- setMovingNode={setMovingNode}
1565
- setMovingNodeOrigin={setMovingNodeOrigin}
1566
- siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1567
- unit={unit}
1568
- metricNotation={metricNotation}
1569
- wallDimensionReference={effectiveWallDimensionReference}
1570
- visibilityRootId={
1571
- entry.visibilityRootId ?? (entry.ctxOverrides ? undefined : (levelId as AnyNodeId))
1572
- }
1573
- ctxOverrides={entry.ctxOverrides}
1574
- />
1575
- ))}
1576
- </g>
1647
+ <g className="floorplan-registry-base">{renderEntries('base')}</g>
1648
+ <RoomHighlight2D levelId={levelId} />
1649
+ <RoomLabels2D levelId={levelId} />
1650
+ <FloorRegionControls2D levelId={levelId} />
1577
1651
  {/* Overlay pass — interactive handles (vertex / midpoint / edge /
1578
1652
  move) and labels (text / dimensions). Painted after every base
1579
1653
  entry so polygon-editor chrome on a selected slab stays above
@@ -1581,49 +1655,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1581
1655
  slab + wall geometry sitting on top of the zone. Each overlay
1582
1656
  still routes through the same selection-handling `<g>` so a
1583
1657
  click on a zone's name selects the zone. */}
1584
- <g className="floorplan-registry-overlay">
1585
- {entries.map((entry) => (
1586
- <FloorplanRegistryEntry
1587
- activeDragId={handleIdForNode(activeDragId, entry.id)}
1588
- annotationVisibility={annotationVisibility}
1589
- floorplanMode={floorplanMode}
1590
- floorplanVisible={floorplanVisible}
1591
- geometryCacheRef={geometryCacheRef}
1592
- hatchPatternId={renderCtx?.hatchPatternId}
1593
- hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1594
- interactiveElevators={interactiveElevators}
1595
- isMarqueeSelectionActive={isMarqueeSelectionActive}
1596
- key={`overlay-${entry.id}`}
1597
- levelDataCacheRef={levelDataCacheRef}
1598
- levelNodeIdsByType={floorplanData.levelNodeIdsByType}
1599
- moving={movingNode?.id === entry.id}
1600
- node={entry.node}
1601
- nodeId={entry.id}
1602
- nodes={nodes}
1603
- onClickStop={handleClickStop}
1604
- onEntryPointerDown={handleEntryPointerDown}
1605
- onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
1606
- onHandleHoverChange={setHoveredHandleId}
1607
- onHandleDoubleClick={commitAffordanceAction}
1608
- onHandlePointerDown={startAffordanceDrag}
1609
- palette={palette}
1610
- unitFocus={unitFocus}
1611
- pass="overlay"
1612
- sceneRotationDeg={sceneRotationDeg}
1613
- setMovingNode={setMovingNode}
1614
- setMovingNodeOrigin={setMovingNodeOrigin}
1615
- siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1616
- unit={unit}
1617
- metricNotation={metricNotation}
1618
- wallDimensionReference={effectiveWallDimensionReference}
1619
- visibilityRootId={
1620
- entry.visibilityRootId ?? (entry.ctxOverrides ? undefined : (levelId as AnyNodeId))
1621
- }
1622
- ctxOverrides={entry.ctxOverrides}
1623
- />
1624
- ))}
1625
- </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} />
1626
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 />
1627
1668
  {/* Dashed group bbox — shows what a group drag carries along while a
1628
1669
  multi-selection exists, rides the live delta mid-drag, and doubles
1629
1670
  as the group's whole-area drag handle. */}
@@ -1988,16 +2029,24 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1988
2029
  }: FloorplanRegistryEntryProps): React.ReactElement | null {
1989
2030
  const selected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
1990
2031
  const highlighted = useViewer((state) => state.previewSelectedIds.includes(nodeId))
1991
- const suppressHandles = useViewer(
2032
+ const multiSelected = useViewer(
1992
2033
  (state) =>
1993
2034
  state.selection.selectedIds.length > 1 && state.selection.selectedIds.includes(nodeId),
1994
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
1995
2041
  const selectedLevelId = useViewer((state) => state.selection.levelId)
1996
2042
  const selectionProxyId = resolveSelectionProxyId(
1997
2043
  node,
1998
2044
  nodes as Record<string, AnyNode | undefined>,
1999
2045
  )
2000
- 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)
2001
2050
  const setHoveredId = useViewer((state) => state.setHoveredId)
2002
2051
  const referencedAnnotationRole = useViewer((state) =>
2003
2052
  floorplanEntryReferencedAnnotationRole(node, nodes, new Set(state.selection.selectedIds)),
@@ -2006,7 +2055,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2006
2055
  state.activeRotateNodeId === nodeId ? nodeId : null,
2007
2056
  )
2008
2057
  const groupMoveCursor =
2009
- suppressHandles && classifyParticipant(node, selectedLevelId, nodes) !== null
2058
+ multiSelected && classifyParticipant(node, selectedLevelId, nodes) !== null
2010
2059
  const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
2011
2060
  const liveOverride = useLiveNodeOverrides((s) =>
2012
2061
  floorplanVisible ? s.overrides.get(nodeId) : undefined,
@@ -2031,25 +2080,55 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2031
2080
 
2032
2081
  // Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
2033
2082
  // the highlight halo in 3D as well as registry floor-plan hover strokes.
2034
- const handlePointerEnter = useCallback(() => {
2035
- const currentNode = useScene.getState().nodes[nodeId]
2036
- setHoveredId(
2037
- currentNode
2038
- ? resolveSelectionProxyId(
2039
- currentNode,
2040
- useScene.getState().nodes as Record<string, AnyNode | undefined>,
2041
- )
2042
- : nodeId,
2043
- )
2044
- }, [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
+ )
2045
2120
 
2046
2121
  const handlePointerLeave = useCallback(() => {
2122
+ useEditor.getState().setHoveredRoom(null)
2047
2123
  const node = useScene.getState().nodes[nodeId]
2048
2124
  const targetId = node
2049
- ? resolveSelectionProxyId(
2050
- node,
2051
- useScene.getState().nodes as Record<string, AnyNode | undefined>,
2052
- )
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
+ )
2053
2132
  : nodeId
2054
2133
  if (useViewer.getState().hoveredId === targetId) setHoveredId(null)
2055
2134
  }, [nodeId, setHoveredId])
@@ -2125,7 +2204,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2125
2204
  wallDimensionReference,
2126
2205
  visibilityRootId,
2127
2206
  })
2128
- const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
2207
+ const rawGeometry = cacheEntry ? cacheEntry[pass] : null
2129
2208
  const visibleGeometry = rawGeometry
2130
2209
  ? filterFloorplanAnnotationGeometry(rawGeometry, presentationVisibility)
2131
2210
  : null
@@ -2133,7 +2212,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2133
2212
  // dimension chrome (all of which live in the overlay pass) while keeping
2134
2213
  // its highlighted body geometry.
2135
2214
  const geometry =
2136
- visibleGeometry && suppressHandles && pass === 'overlay'
2215
+ visibleGeometry && suppressHandles && pass !== 'base'
2137
2216
  ? stripHandleChrome(visibleGeometry)
2138
2217
  : visibleGeometry
2139
2218
  if (!geometry) return null
@@ -2148,6 +2227,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
2148
2227
  onClick={entryClick}
2149
2228
  onPointerDown={entryPointerDown}
2150
2229
  onPointerEnter={handlePointerEnter}
2230
+ onPointerMove={handlePointerEnter}
2151
2231
  onPointerLeave={handlePointerLeave}
2152
2232
  style={groupMoveCursor ? MOVE_CURSOR_STYLE : POINTER_CURSOR_STYLE}
2153
2233
  >
@@ -2366,6 +2446,7 @@ export function buildFloorplanEntryGeometry({
2366
2446
  selected,
2367
2447
  unit,
2368
2448
  metricNotation,
2449
+ roomLabelOverlay: true,
2369
2450
  wallDimensionReference,
2370
2451
  highlighted,
2371
2452
  hovered,
@@ -2391,6 +2472,7 @@ export function buildFloorplanEntryGeometry({
2391
2472
  automaticDimensions,
2392
2473
  metricNotation,
2393
2474
  purpose: 'edit',
2475
+ roomLabelOverlay: true,
2394
2476
  wallDimensionReference,
2395
2477
  }),
2396
2478
  viewState: palette
@@ -2432,10 +2514,13 @@ export function buildFloorplanEntryGeometry({
2432
2514
  transform: ctxOverrides.outputTransform,
2433
2515
  }
2434
2516
  : unprojectedGeometry
2435
- const { base, overlay } = geometry
2517
+ const { base, overlay: overlayWithHandles } = geometry
2436
2518
  ? splitFloorplanOverlay(geometry)
2437
2519
  : { base: null, overlay: null }
2438
- 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 }
2439
2524
  geometryCache.set(nodeId, entry)
2440
2525
  return entry
2441
2526
  }
@@ -2888,12 +2973,13 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
2888
2973
  // body (matches the 3D `HANDLE_OFFSET`).
2889
2974
  const bi = 0.03 // base inset
2890
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`
2891
- // Indigo palette to match the 3D `WallMoveSideHandles` arrows
2976
+ // Indigo palette to match the 3D wall push arrows
2892
2977
  // (`ARROW_COLOR` / `ARROW_HOVER_COLOR`) and the corner-sphere
2893
2978
  // accent in `floating-action-menu.tsx`.
2894
2979
  const fill = isHovered ? '#a5b4fc' : '#8381ed'
2895
2980
  const angleDeg = (g.angle * 180) / Math.PI
2896
- const cursor = g.affordance ? 'ew-resize' : 'move'
2981
+ const cursor =
2982
+ g.affordance === WALL_PUSH_AFFORDANCE ? 'grab' : g.affordance ? 'ew-resize' : 'move'
2897
2983
  const affordance = g.affordance
2898
2984
  const payload = g.payload
2899
2985
  // No hover-grow: a scaling transform would enlarge the hit area
@@ -3334,15 +3420,26 @@ export function isFloorplanHierarchyVisible(
3334
3420
  liveOverrides: Map<string, LiveNodeOverrides>,
3335
3421
  rootId: AnyNodeId,
3336
3422
  ): boolean {
3423
+ // The root is checked on its own because a site-scoped node can be declared
3424
+ // on the Site without a `parentId` link. A Site's flag never reaches the
3425
+ // nodes on it; see `hidesDescendants`.
3337
3426
  const root = nodes[rootId]
3338
- if (root && !isFloorplanNodeVisible(root, liveOverrides.get(root.id))) return false
3427
+ if (
3428
+ root &&
3429
+ root.id !== node.id &&
3430
+ hidesDescendants(root) &&
3431
+ !isFloorplanNodeVisible(root, liveOverrides.get(root.id))
3432
+ ) {
3433
+ return false
3434
+ }
3339
3435
 
3340
3436
  let current: AnyNode | undefined = node
3341
3437
  const seen = new Set<AnyNodeId>()
3342
3438
  while (current) {
3343
3439
  if (seen.has(current.id)) return true
3344
3440
  seen.add(current.id)
3345
- if (!isFloorplanNodeVisible(current, liveOverrides.get(current.id))) return false
3441
+ const reaches = current.id === node.id || hidesDescendants(current)
3442
+ if (reaches && !isFloorplanNodeVisible(current, liveOverrides.get(current.id))) return false
3346
3443
  if (current.id === rootId) return true
3347
3444
  const parentId = current.parentId as AnyNodeId | null
3348
3445
  if (!parentId) return true
@@ -3469,6 +3566,42 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
3469
3566
  return { base: g, overlay: null }
3470
3567
  }
3471
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
+
3472
3605
  /**
3473
3606
  * Per-node edit chrome hidden while a multi-selection is active: the group is
3474
3607
  * manipulated as one rigid piece (drag to move, R/T to rotate), so individual
@@ -3657,29 +3790,6 @@ function depsValueEqual(a: unknown, b: unknown): boolean {
3657
3790
  */
3658
3791
  export { floorplanLayerRank }
3659
3792
 
3660
- function deepEqual(a: unknown, b: unknown): boolean {
3661
- if (a === b) return true
3662
- if (Array.isArray(a) && Array.isArray(b)) {
3663
- if (a.length !== b.length) return false
3664
- for (let i = 0; i < a.length; i++) {
3665
- if (!deepEqual(a[i], b[i])) return false
3666
- }
3667
- return true
3668
- }
3669
- if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
3670
- const aKeys = Object.keys(a as Record<string, unknown>)
3671
- const bKeys = Object.keys(b as Record<string, unknown>)
3672
- if (aKeys.length !== bKeys.length) return false
3673
- for (const key of aKeys) {
3674
- if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
3675
- return false
3676
- }
3677
- }
3678
- return true
3679
- }
3680
- return false
3681
- }
3682
-
3683
3793
  const ROTATION_WEDGE_COLOR = '#8381ed'
3684
3794
  const ROTATION_WEDGE_SEGMENTS = 48
3685
3795