@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
@@ -4,6 +4,7 @@ import {
4
4
  type BuildingNode,
5
5
  createSceneApi,
6
6
  emitter,
7
+ floorPlatePaintRefusal,
7
8
  type GridEvent,
8
9
  getEffectiveRoofSurfaceMaterial,
9
10
  getEffectiveSegmentSurfaceMaterial,
@@ -19,6 +20,7 @@ import {
19
20
  type RoofSegmentEvent,
20
21
  type RoofSegmentNode,
21
22
  resolveLevelId,
23
+ type SlabNode,
22
24
  type StairEvent,
23
25
  type StairSegmentEvent,
24
26
  type StairSurfaceMaterialRole,
@@ -27,17 +29,22 @@ import {
27
29
  useRegistryVersion,
28
30
  useScene,
29
31
  } from '@pascal-app/core'
30
-
31
32
  import {
32
33
  createMaterial,
33
- createMaterialFromPresetRef,
34
34
  getRoofMaterialArray,
35
35
  registerMaterialCacheCleanup,
36
+ resolveMaterialRef,
36
37
  useViewer,
37
38
  } from '@pascal-app/viewer'
38
39
  import { useThree } from '@react-three/fiber'
39
40
  import { useCallback, useEffect, useRef } from 'react'
40
41
  import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
42
+ import {
43
+ hoverRoomFromHit,
44
+ resolveEditorRoomHit,
45
+ roomPickingEnabled,
46
+ useRoomRecords,
47
+ } from '../../hooks/use-selected-room'
41
48
  import {
42
49
  canDirectMoveNode,
43
50
  canDirectRotateNode,
@@ -48,6 +55,11 @@ import {
48
55
  shouldStartDirectMoveDrag,
49
56
  } from '../../lib/direct-manipulation'
50
57
  import { createEditorApi } from '../../lib/editor-api'
58
+ import {
59
+ isFootprintConstructionHit,
60
+ isRoomOwnedPlate,
61
+ roomOwnedPlateDrillTarget,
62
+ } from '../../lib/floor-footprints'
51
63
  import { selectionEnabled } from '../../lib/interaction/scope'
52
64
  import {
53
65
  type ActivePaintMaterial,
@@ -56,25 +68,55 @@ import {
56
68
  hasActivePaintMaterial,
57
69
  resolveActivePaintMaterialFromSelection,
58
70
  } from '../../lib/material-paint'
71
+ import { paintCommitRoute } from '../../lib/paint-commit-route'
72
+ import { eyedropperMaterial, paintMaterialKey } from '../../lib/paint-eyedropper'
73
+ import {
74
+ openingPartHidden,
75
+ paintPassesThrough,
76
+ wallHitInOpening,
77
+ } from '../../lib/paint-pass-through'
59
78
  import {
60
79
  combinePaintPreviews,
61
80
  createPaintPreviewOwner,
62
81
  type PaintPreviewCleanup,
63
82
  } from '../../lib/paint-preview-owner'
64
83
  import {
65
- availablePaintScopes,
84
+ bindPaintPickHold,
85
+ isPaintErasing,
86
+ isPaintPicking,
87
+ paintPickReturnMode,
88
+ paintRegionModeActive,
89
+ usePaintRegionMode,
90
+ } from '../../lib/paint-region-mode'
91
+ import {
66
92
  commitPaintScopeFanout,
93
+ effectivePaintScope,
67
94
  nodeSlotRoles,
68
95
  type PaintHoverInfo,
96
+ paintHoverInfo,
97
+ paintScopeRole,
98
+ paintSurfaceLabel,
69
99
  resolvePaintScopeTargets,
70
100
  slotDisplayLabel,
71
101
  type WallPaintHit,
72
102
  } from '../../lib/paint-scope'
103
+ import {
104
+ ceilingAffectedSurfaces,
105
+ mergePaintSurfaces,
106
+ paintSurfaceMeshes,
107
+ plateAffectedSurfaces,
108
+ platePreviewSurfaces,
109
+ usePaintOutline,
110
+ } from '../../lib/plate-paint-affected'
73
111
  import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
112
+ import { sameRoom } from '../../lib/room-selection'
113
+ import { selectRoom, shouldInterceptRoom } from '../../lib/room-selection-commands'
114
+ import { roomKeyForZone } from '../../lib/room-zone-routing'
74
115
  import {
75
116
  emitCanvasNodeSelection,
117
+ enterBuildingFromCanvas,
118
+ resolveCanvasBuildingId,
76
119
  resolveCanvasSelectionNode,
77
- resolveNodeSelectionTarget,
78
120
  resolveSelectedIdsForNodeClick,
79
121
  type SelectionModifierKeys,
80
122
  selectionModifiersFromEvent,
@@ -88,7 +130,7 @@ import {
88
130
  zoneAtWorldPoint,
89
131
  } from '../../lib/units'
90
132
  import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
91
- import useEditor, { type MaterialTargetRole } from './../../store/use-editor'
133
+ import useEditor, { type MaterialTargetRole, type Phase } from './../../store/use-editor'
92
134
  import useInteractionScope, {
93
135
  getEditingHole,
94
136
  getMovingNode,
@@ -100,6 +142,7 @@ import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/b
100
142
  import { armGroupMove3d } from './group-move-3d'
101
143
  import { classifyParticipant } from './group-transform-shared'
102
144
  import { swallowNextClick } from './node-arrow-handles'
145
+ import { RoomHighlight3D } from './room-highlight'
103
146
  import { setEditorThreeContext } from './three-context-bridge'
104
147
 
105
148
  const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -111,24 +154,6 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => {
111
154
  return nodeLevelId === currentLevelId
112
155
  }
113
156
 
114
- type SelectableNodeType =
115
- | 'wall'
116
- | 'fence'
117
- | 'item'
118
- | 'column'
119
- | 'building'
120
- | 'elevator'
121
- | 'zone'
122
- | 'slab'
123
- | 'ceiling'
124
- | 'roof'
125
- | 'roof-segment'
126
- | 'stair'
127
- | 'stair-segment'
128
- | 'spawn'
129
- | 'window'
130
- | 'door'
131
-
132
157
  type PaintInteraction = {
133
158
  key: string
134
159
  apply: (() => void) | null
@@ -141,7 +166,6 @@ type PaintInteraction = {
141
166
  }
142
167
 
143
168
  interface SelectionStrategy {
144
- types: SelectableNodeType[]
145
169
  handleSelect: (
146
170
  node: AnyNode,
147
171
  nativeEvent?: any,
@@ -183,6 +207,18 @@ export const resolveBuildingId = (
183
207
  return null
184
208
  }
185
209
 
210
+ /**
211
+ * Furnishings: picking one leaves the room context (a sofa is not part of the
212
+ * room's shell). Door and window catalog items are openings, not furnishings.
213
+ */
214
+ function isFurnishingNode(node: AnyNode): boolean {
215
+ if (node.type === 'item') {
216
+ const category = (node as ItemNode).asset.category
217
+ return category !== 'door' && category !== 'window'
218
+ }
219
+ return nodeRegistry.get(node.type)?.category === 'furnish'
220
+ }
221
+
186
222
  function resolveStairMaterialTarget(
187
223
  event: StairEvent | StairSegmentEvent,
188
224
  ): StairSurfaceMaterialRole | null {
@@ -233,6 +269,14 @@ function resolveRoofMaterialTarget(
233
269
  return null
234
270
  }
235
271
 
272
+ function isCeilingGridHit(event: NodeEvent): boolean {
273
+ return (
274
+ !event.viaHandle &&
275
+ event.node.type === 'ceiling' &&
276
+ getEventObject(event)?.name === 'ceiling-grid'
277
+ )
278
+ }
279
+
236
280
  function getEventObject(event: NodeEvent): Object3D {
237
281
  const eventWithObject = event as NodeEvent & { object?: Object3D }
238
282
  return eventWithObject.object ?? event.nativeEvent.object
@@ -309,6 +353,31 @@ function meshSlotRoles(node: AnyNode): string[] {
309
353
  const roofSelectionWorldPoint = new Vector3()
310
354
  const wallPaintWorldPoint = new Vector3()
311
355
 
356
+ const roomHitPoint = new Vector3()
357
+ const roomHitXZ: [number, number] = [0, 0]
358
+ function roomForEvent(event: NodeEvent) {
359
+ if (event.node.type !== 'wall' && event.node.type !== 'slab' && event.node.type !== 'ceiling')
360
+ return null
361
+ // The floor edge band and the foundation belong to the footprint, not the
362
+ // room above: the plate takes the pick and opens Floor & foundation.
363
+ if (isFootprintConstructionHit(event.node, event.object)) return null
364
+ const levelId = resolveLevelId(event.node, useScene.getState().nodes)
365
+ if (!levelId) return null
366
+ if (event.viaHandle && event.node.type === 'ceiling') {
367
+ roomHitXZ[0] = event.position[0]
368
+ roomHitXZ[1] = event.position[2]
369
+ return resolveEditorRoomHit(event.node, levelId, roomHitXZ, '3d')
370
+ }
371
+ const level = sceneRegistry.nodes.get(levelId)
372
+ if (!level) return null
373
+ level.updateWorldMatrix(true, false)
374
+ roomHitPoint.set(...event.position)
375
+ level.worldToLocal(roomHitPoint)
376
+ roomHitXZ[0] = roomHitPoint.x
377
+ roomHitXZ[1] = roomHitPoint.z
378
+ return resolveEditorRoomHit(event.node, levelId, roomHitXZ, '3d')
379
+ }
380
+
312
381
  function resolveWallPaintHit(event: NodeEvent): WallPaintHit | undefined {
313
382
  const wall = event.node
314
383
  if (wall.type !== 'wall') return undefined
@@ -368,7 +437,11 @@ function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode |
368
437
  return bestSegment?.node ?? firstSegment
369
438
  }
370
439
 
371
- function resolveSelectModeNodeTarget(event: NodeEvent): AnyNode {
440
+ function resolveSelectModeNodeTarget(event: NodeEvent, drill = false): AnyNode {
441
+ if (!drill && event.node.type === 'stair-segment' && event.node.parentId) {
442
+ const parent = useScene.getState().nodes[event.node.parentId as AnyNodeId]
443
+ if (parent?.type === 'stair') return parent
444
+ }
372
445
  if (event.node.type === 'roof') {
373
446
  if (
374
447
  shouldPreserveSelectedRoofHostTarget({
@@ -420,6 +493,8 @@ function applyRoofPaintPreview(
420
493
  useViewer.getState().textures,
421
494
  useViewer.getState().colorPreset,
422
495
  useViewer.getState().sceneTheme,
496
+ null,
497
+ useScene.getState().materials,
423
498
  )
424
499
  if (!previewMaterial) return null
425
500
 
@@ -443,13 +518,12 @@ function applyRoofSegmentPaintPreview(
443
518
  ...node,
444
519
  ...buildRoofSegmentSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
445
520
  }
521
+ const sceneMaterials = useScene.getState().materials
446
522
  const resolveSlot = (r: 'top' | 'edge' | 'wall'): Material | null => {
447
523
  const parentSpec = parent ? getEffectiveRoofSurfaceMaterial(parent, r) : undefined
448
524
  const spec = getEffectiveSegmentSurfaceMaterial(previewNode, r, parentSpec)
449
- if (typeof spec.materialPreset === 'string') {
450
- const resolved = createMaterialFromPresetRef(spec.materialPreset)
451
- if (resolved) return resolved
452
- }
525
+ const resolved = resolveMaterialRef(spec.materialPreset, sceneMaterials)
526
+ if (resolved) return resolved
453
527
  if (spec.material !== undefined) return createMaterial(spec.material)
454
528
  return null
455
529
  }
@@ -457,7 +531,9 @@ function applyRoofSegmentPaintPreview(
457
531
  const wall = resolveSlot('wall')
458
532
  const top = resolveSlot('top')
459
533
  if (!(edge || wall || top)) return null
460
- const fallback = parent ? getRoofMaterialArray(parent) : null
534
+ const fallback = parent
535
+ ? getRoofMaterialArray(parent, undefined, undefined, undefined, undefined, null, sceneMaterials)
536
+ : null
461
537
  const fb = (n: number) => fallback?.[n] ?? null
462
538
  // Per-role only, then the parent's themed slot — matches the renderer so the
463
539
  // preview never bleeds a painted surface onto the segment's other surfaces.
@@ -504,7 +580,7 @@ const HIGHLIGHT_PROFILES = {
504
580
  } as const
505
581
 
506
582
  type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
507
- type HoverHighlightMode = 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
583
+ type HoverHighlightMode = 'default' | 'delete' | 'paint-ready' | 'erase-ready' | 'paint-disabled'
508
584
 
509
585
  type HighlightableMaterial = Material & {
510
586
  color?: Color
@@ -611,9 +687,8 @@ const computeNextIds = (
611
687
  })
612
688
  }
613
689
 
614
- const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
690
+ const SELECTION_STRATEGIES: Record<Phase, SelectionStrategy> = {
615
691
  site: {
616
- types: ['building'],
617
692
  handleSelect: (node) => {
618
693
  useViewer.getState().setSelection({ buildingId: (node as BuildingNode).id })
619
694
  },
@@ -623,24 +698,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
623
698
  isValid: (node) => node.type === 'building',
624
699
  },
625
700
 
626
- structure: {
627
- types: [
628
- 'wall',
629
- 'fence',
630
- 'item',
631
- 'column',
632
- 'elevator',
633
- 'zone',
634
- 'slab',
635
- 'ceiling',
636
- 'roof',
637
- 'roof-segment',
638
- 'stair',
639
- 'stair-segment',
640
- 'spawn',
641
- 'window',
642
- 'door',
643
- ],
701
+ building: {
644
702
  handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
645
703
  const { selection, setSelection } = useViewer.getState()
646
704
  const nodes = useScene.getState().nodes
@@ -662,10 +720,15 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
662
720
  updates.buildingId = buildingId
663
721
  }
664
722
 
723
+ if (node.type === 'zone' && !useViewer.getState().focusedUnitId) {
724
+ const room = roomKeyForZone(node.id)
725
+ if (room) {
726
+ if (!sameRoom(useEditor.getState().room, room)) selectRoom(room)
727
+ return
728
+ }
729
+ }
665
730
  if (node.type === 'zone') {
666
731
  updates.zoneId = node.id
667
- // Don't reset selectedIds in structure phase for zone, but if we changed level, it might reset them via hierarchy guard.
668
- // Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
669
732
  setSelection(updates)
670
733
  } else {
671
734
  updates.selectedIds = computeNextIds(
@@ -679,21 +742,12 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
679
742
  }
680
743
  },
681
744
  handleDeselect: () => {
682
- const structureLayer = useEditor.getState().structureLayer
683
- if (structureLayer === 'zones') {
684
- useViewer.getState().setSelection({ zoneId: null })
685
- } else {
686
- useViewer.getState().setSelection({ selectedIds: [] })
687
- }
745
+ useEditor.getState().clearRoom()
746
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
688
747
  },
689
748
  isValid: (node) => {
690
749
  if (!isNodeInCurrentLevel(node)) return false
691
- const structureLayer = useEditor.getState().structureLayer
692
- if (structureLayer === 'zones') {
693
- if (node.type === 'zone') return true
694
- return false
695
- }
696
- if (
750
+ return (
697
751
  node.type === 'wall' ||
698
752
  node.type === 'fence' ||
699
753
  node.type === 'column' ||
@@ -704,75 +758,36 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
704
758
  node.type === 'roof-segment' ||
705
759
  node.type === 'stair' ||
706
760
  node.type === 'stair-segment' ||
707
- node.type === 'spawn'
708
- )
709
- return true
710
- if (node.type === 'item') {
711
- return (
712
- (node as ItemNode).asset.category === 'door' ||
713
- (node as ItemNode).asset.category === 'window'
714
- )
715
- }
716
- if (node.type === 'window' || node.type === 'door') return true
717
-
718
- // Registry-driven: any kind whose NodeDefinition declares the
719
- // `selectable` capability is also selectable in structure phase. Phase 4
720
- // makes this the only path and deletes the hardcoded chain above.
721
- if (isRegistrySelectable(node.type)) return true
722
-
723
- return false
724
- },
725
- },
726
-
727
- furnish: {
728
- types: ['item'],
729
- handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
730
- const { selection, setSelection } = useViewer.getState()
731
- const nodes = useScene.getState().nodes
732
- const nodeLevelId = resolveLevelId(node, nodes)
733
- const buildingId = resolveBuildingId(nodeLevelId, nodes)
734
-
735
- const updates: any = {}
736
- if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
737
- updates.levelId = nodeLevelId
738
- }
739
- if (buildingId && buildingId !== selection.buildingId) {
740
- updates.buildingId = buildingId
741
- }
742
-
743
- updates.selectedIds = computeNextIds(
744
- node,
745
- selection.selectedIds,
746
- nativeEvent,
747
- modifierKeys,
748
- baseSelectedIds,
761
+ node.type === 'spawn' ||
762
+ node.type === 'window' ||
763
+ node.type === 'door' ||
764
+ node.type === 'item' ||
765
+ isRegistrySelectable(node.type)
749
766
  )
750
- setSelection(updates)
751
- },
752
- handleDeselect: () => {
753
- useViewer.getState().setSelection({ selectedIds: [] })
754
- },
755
- isValid: (node) => {
756
- if (!isNodeInCurrentLevel(node)) return false
757
- // Item: door/window-category items belong to structure phase, not furnish.
758
- if (node.type === 'item') {
759
- const item = node as ItemNode
760
- return item.asset.category !== 'door' && item.asset.category !== 'window'
761
- }
762
- // Registry-driven kinds with `category: 'furnish'` (shelf today,
763
- // future furniture kinds): selectable in furnish phase if their
764
- // definition declares the `selectable` capability. Without this
765
- // branch, shelf clicks routed to furnish phase via resolveNodeSelectionTarget
766
- // would be rejected here — single-click selection broken.
767
- const def = nodeRegistry.get(node.type)
768
- if (def && def.category === 'furnish' && def.capabilities.selectable) return true
769
- return false
770
767
  },
771
768
  },
772
769
  }
773
770
 
774
771
  export const SelectionManager = () => {
775
- const phase = useEditor((s) => s.phase)
772
+ const roomLevelId = useViewer((s) => s.selection.levelId)
773
+ useRoomRecords(roomLevelId)
774
+ useEffect(
775
+ () =>
776
+ useViewer.subscribe((state, previous) => {
777
+ if (
778
+ state.selection.levelId !== previous.selection.levelId ||
779
+ (state.selection.zoneId !== previous.selection.zoneId && state.selection.zoneId) ||
780
+ (state.selection.selectedIds !== previous.selection.selectedIds &&
781
+ state.selection.selectedIds.some((id) => {
782
+ const node = useScene.getState().nodes[id as AnyNodeId]
783
+ return node && isFurnishingNode(node)
784
+ }))
785
+ ) {
786
+ useEditor.getState().clearRoom()
787
+ }
788
+ }),
789
+ [],
790
+ )
776
791
  const mode = useEditor((s) => s.mode)
777
792
  // The canvas element — cursor styling must land here, not on `document.body`:
778
793
  // the editor wraps the canvas in a div with a custom `cursor: url(...)`, which
@@ -825,6 +840,18 @@ export const SelectionManager = () => {
825
840
  // The last hover event, replayed when the application scope cycles so the
826
841
  // preview + chip update under a stationary cursor (Shift fires no pointer move).
827
842
  let lastEnterEvent: NodeEvent | null = null
843
+ // Paint hover never stops a pointer event. R3F keeps a hover that stopped
844
+ // propagation stopping every later move over that object, which would pin
845
+ // the pointer to a door or a wall it should pass through where it opens
846
+ // (see `paint-pass-through`). So the nearest surface that takes a pointer
847
+ // event claims it; farther ones see the claim and stand down.
848
+ let claimedEvent: unknown = null
849
+ let hoveredNodeId: string | null = null
850
+ const claim = (event: NodeEvent) => {
851
+ claimedEvent = event.nativeEvent.nativeEvent
852
+ }
853
+ const claimedByNearer = (event: NodeEvent) =>
854
+ claimedEvent !== null && claimedEvent === event.nativeEvent.nativeEvent
828
855
 
829
856
  const clearActivePreview = () => {
830
857
  activePreview?.restore()
@@ -835,6 +862,7 @@ export const SelectionManager = () => {
835
862
  useEditor.getState().activePaintMaterial ??
836
863
  resolveActivePaintMaterialFromSelection({
837
864
  nodes: useScene.getState().nodes,
865
+ materials: useScene.getState().materials,
838
866
  selectedId:
839
867
  useViewer.getState().selection.selectedIds.length === 1
840
868
  ? (useViewer.getState().selection.selectedIds[0] ?? null)
@@ -842,8 +870,50 @@ export const SelectionManager = () => {
842
870
  selectedMaterialTarget: useEditor.getState().selectedMaterialTarget,
843
871
  })
844
872
 
873
+ // The eyedropper: the hover shows what a click would take (the cursor
874
+ // swatch), the click takes it and returns to the sub-mode it came from.
875
+ const pickInteraction = (
876
+ node: AnyNode,
877
+ role: string | null,
878
+ event: NodeEvent,
879
+ materialIndex: number | null,
880
+ ): PaintInteraction => {
881
+ const picked = role
882
+ ? eyedropperMaterial({
883
+ node,
884
+ role,
885
+ nodes: useScene.getState().nodes,
886
+ materials: useScene.getState().materials,
887
+ hitObject: getEventObject(event),
888
+ materialIndex,
889
+ })
890
+ : null
891
+ return {
892
+ key: `pick:${node.id}:${role ?? 'none'}:${paintMaterialKey(picked)}`,
893
+ hoveredId: node.id as AnyNodeId,
894
+ hoverMode: picked ? 'paint-ready' : 'paint-disabled',
895
+ paintHover:
896
+ picked && role
897
+ ? { scopes: ['single'], slotLabel: paintSurfaceLabel(node, role), nodeNoun: node.type }
898
+ : null,
899
+ apply: picked
900
+ ? () => useEditor.getState().armMaterialPaint(picked, paintPickReturnMode())
901
+ : null,
902
+ preview: () => {
903
+ usePaintRegionMode.setState({ picked })
904
+ return () => {
905
+ if (usePaintRegionMode.getState().picked === picked)
906
+ usePaintRegionMode.setState({ picked: null })
907
+ }
908
+ },
909
+ }
910
+ }
911
+
845
912
  const resolvePaintInteraction = (event: NodeEvent): PaintInteraction | null => {
846
- const eraser = useEditor.getState().paintEraser
913
+ // A region sub-mode draws its own region before painting; the
914
+ // whole-surface hover and click stand down.
915
+ if (paintRegionModeActive('material-paint')) return null
916
+ const eraser = isPaintErasing()
847
917
  const activePaintMaterial = resolveActivePaintMaterial()
848
918
  const node = event.node
849
919
 
@@ -885,6 +955,25 @@ export const SelectionManager = () => {
885
955
  hitObject: getEventObject(event),
886
956
  ray: event.nativeEvent.ray,
887
957
  })
958
+ // Not claiming the hover lets the pointer event reach the surface behind:
959
+ // an empty door or window, or a wall's collision hit in one of its holes.
960
+ if (
961
+ paintPassesThrough(node, role) ||
962
+ ((node.type === 'door' || node.type === 'window') &&
963
+ openingPartHidden(
964
+ getRegisteredNodeObject(node.id),
965
+ event.nativeEvent.ray,
966
+ event.nativeEvent.intersections,
967
+ )) ||
968
+ (node.type === 'wall' &&
969
+ wallHitInOpening(
970
+ getRegisteredNodeObject(node.id),
971
+ event.nativeEvent.ray,
972
+ event.nativeEvent.distance,
973
+ ))
974
+ )
975
+ return null
976
+ if (isPaintPicking()) return pickInteraction(node, role, event, materialIndex ?? null)
888
977
  const compatible = role !== null && paintEnabled
889
978
  // Derive the node's slots (declared, else mesh tags) once — drives both
890
979
  // the chip's available scopes and the whole-object fan-out.
@@ -893,7 +982,13 @@ export const SelectionManager = () => {
893
982
  // / all matching / room). The scope is part of the key so cycling it
894
983
  // (Shift) re-keys the interaction → the preview re-applies for the new
895
984
  // spread instead of being deduped to the single-surface preview.
896
- const scope = useEditor.getState().paintScope
985
+ const hover = compatible && role ? paintHoverInfo(node, role, slotRoles) : null
986
+ // A scope carried over from another surface paints what the chip
987
+ // shows for this one: the narrowest when it isn't offered here.
988
+ const scope = effectivePaintScope(
989
+ useEditor.getState().paintScope,
990
+ hover?.scopes ?? ['single'],
991
+ )
897
992
  const wallHit = resolveWallPaintHit(event)
898
993
  const scopeTargets =
899
994
  compatible && role
@@ -907,30 +1002,44 @@ export const SelectionManager = () => {
907
1002
  wallHit,
908
1003
  })
909
1004
  : []
910
- const scopeTargetKey = scopeTargets
1005
+ // What the click changes on floor plates, fallbacks included (a room's
1006
+ // floor carries its unpainted steps): the outline. The preview draws
1007
+ // the same set, each follower through the surface it follows.
1008
+ const scopeRole = compatible && role ? paintScopeRole(node, role, scope) : null
1009
+ const sceneNodes = useScene.getState().nodes
1010
+ const affected = scopeRole
1011
+ ? (plateAffectedSurfaces(sceneNodes, node, scopeRole, { erasing: eraser }) ??
1012
+ ceilingAffectedSurfaces(node, scopeRole))
1013
+ : null
1014
+ const previewTargets = mergePaintSurfaces(
1015
+ scopeTargets,
1016
+ scopeRole
1017
+ ? (platePreviewSurfaces(sceneNodes, node, scopeRole) ??
1018
+ ceilingAffectedSurfaces(node, scopeRole))
1019
+ : null,
1020
+ )
1021
+ const scopeTargetKey = previewTargets
911
1022
  .map((target) => `${target.nodeId}:${target.role}`)
912
1023
  .sort()
913
1024
  .join(',')
914
1025
  return {
915
1026
  key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}:${scope}:${scopeTargetKey}`,
916
1027
  hoveredId: node.id as AnyNodeId,
917
- hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
918
- paintHover:
919
- compatible && role
920
- ? {
921
- scopes: availablePaintScopes({ node, slotRoles }),
922
- slotLabel: slotDisplayLabel(node, role),
923
- nodeNoun: node.type,
924
- }
925
- : null,
1028
+ hoverMode: compatible ? (eraser ? 'erase-ready' : 'paint-ready') : 'paint-disabled',
1029
+ paintHover: hover,
926
1030
  apply:
927
1031
  compatible && role
928
1032
  ? () => {
1033
+ // A surface with nothing of its own to paint (a footprint's
1034
+ // top where no room stands) says so instead of painting.
1035
+ const refusal = floorPlatePaintRefusal(useScene.getState().nodes, node, role)
1036
+ usePaintRegionMode.getState().setNotice(refusal?.message ?? null)
1037
+ if (refusal) return
929
1038
  // Spread targets are all the same slot-model kind, so one
930
- // batched commit writes them in a single undo step; the
931
- // single-surface case keeps the kind's own commit (covers
932
- // non-slot kinds too).
933
- if (scopeTargets.length > 1) {
1039
+ // batched commit writes them in a single undo step; only
1040
+ // the hovered surface itself keeps the kind's own commit
1041
+ // (covers non-slot kinds too) — see `paintCommitRoute`.
1042
+ if (paintCommitRoute(scopeTargets, node.id, role) === 'fanout') {
934
1043
  commitPaintScopeFanout(
935
1044
  scopeTargets,
936
1045
  paintSpec.material,
@@ -964,10 +1073,14 @@ export const SelectionManager = () => {
964
1073
  // hovered surface. Each target is the same kind, so its own
965
1074
  // paint capability builds the preview; restores combine.
966
1075
  const restores: PaintPreviewCleanup[] = []
967
- const sceneNodes = useScene.getState().nodes
1076
+ const liveNodes = useScene.getState().nodes
1077
+ if (affected) {
1078
+ usePaintOutline.setState({ surfaces: affected })
1079
+ restores.push(() => usePaintOutline.setState({ surfaces: null }))
1080
+ }
968
1081
  try {
969
- for (const target of scopeTargets) {
970
- const targetNode = sceneNodes[target.nodeId]
1082
+ for (const target of previewTargets) {
1083
+ const targetNode = liveNodes[target.nodeId]
971
1084
  const targetRoot = getRegisteredNodeObject(target.nodeId)
972
1085
  const targetCap = targetNode
973
1086
  ? nodeRegistry.get(targetNode.type)?.capabilities?.paint
@@ -975,6 +1088,8 @@ export const SelectionManager = () => {
975
1088
  if (!(targetNode && targetRoot && targetCap)) continue
976
1089
  const restore = targetCap.applyPreview({
977
1090
  node: targetNode,
1091
+ nodes: liveNodes,
1092
+ materials: useScene.getState().materials,
978
1093
  role: target.role,
979
1094
  material: paintSpec.material,
980
1095
  materialPreset: paintSpec.materialPreset,
@@ -1004,6 +1119,8 @@ export const SelectionManager = () => {
1004
1119
  if (roofNode?.type !== 'roof') return null
1005
1120
 
1006
1121
  const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
1122
+ if (isPaintPicking())
1123
+ return pickInteraction(isSegmentHit ? node : roofNode, role, event, null)
1007
1124
  const compatible = role !== null && paintEnabled
1008
1125
  // Painting directly on a segment (only possible in segment edit
1009
1126
  // mode, where the per-segment mesh is visible) writes to the
@@ -1015,7 +1132,7 @@ export const SelectionManager = () => {
1015
1132
  segmentTarget ? segmentTarget.id : roofNode.id
1016
1133
  }:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
1017
1134
  hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId,
1018
- hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
1135
+ hoverMode: compatible ? (eraser ? 'erase-ready' : 'paint-ready') : 'paint-disabled',
1019
1136
  // Roof isn't on the slot model (role-specific fields, custom commit),
1020
1137
  // so it offers only the single surface — but still labels it.
1021
1138
  paintHover:
@@ -1091,17 +1208,20 @@ export const SelectionManager = () => {
1091
1208
  previewOwner.wrap(resolvePaintInteraction(event))
1092
1209
 
1093
1210
  const onEnter = (event: NodeEvent) => {
1211
+ if (isCeilingGridHit(event)) return
1094
1212
  // A host-driven drag (handle resize/rotate) sets `inputDragging`.
1095
1213
  // useNodeEvents now emits hover events during such a drag so surface
1096
1214
  // move tools keep tracking the cursor — but paint preview must not fire
1097
1215
  // mid-drag, so gate on `inputDragging` here too.
1098
1216
  if (boxSelectHandled || useViewer.getState().inputDragging) return
1217
+ if (claimedByNearer(event)) return
1099
1218
 
1100
1219
  const interaction = getPaintInteraction(event)
1101
1220
  if (!interaction) return
1102
1221
 
1103
- event.stopPropagation()
1222
+ claim(event)
1104
1223
  lastEnterEvent = event
1224
+ hoveredNodeId = interaction.hoveredId
1105
1225
 
1106
1226
  // Drive the paint HUD off this hover: the interaction carries the scopes +
1107
1227
  // labels for the painted surface (`null` when it isn't paintable — no
@@ -1123,12 +1243,17 @@ export const SelectionManager = () => {
1123
1243
  }
1124
1244
 
1125
1245
  const onLeave = (event: NodeEvent) => {
1246
+ if (isCeilingGridHit(event)) return
1126
1247
  const interaction = getPaintInteraction(event)
1127
- if (!interaction) return
1248
+ // A surface behind the hovered one (under an opening that never stops
1249
+ // the pointer) leaving says nothing about the hover.
1250
+ if (!interaction || interaction.hoveredId !== hoveredNodeId) return
1128
1251
 
1129
1252
  // Leaving any surface → the HUD shows the "hover a surface" hint again.
1253
+ hoveredNodeId = null
1130
1254
  lastEnterEvent = null
1131
1255
  useEditor.getState().setPaintHover(null)
1256
+ usePaintOutline.setState({ surfaces: null })
1132
1257
 
1133
1258
  if (activePreview?.key !== interaction.key) {
1134
1259
  return
@@ -1142,17 +1267,22 @@ export const SelectionManager = () => {
1142
1267
  }
1143
1268
 
1144
1269
  const onClick = (event: NodeEvent) => {
1270
+ if (isCeilingGridHit(event)) return
1145
1271
  if (boxSelectHandled) return
1272
+ if (claimedByNearer(event)) return
1146
1273
 
1147
1274
  const interaction = getPaintInteraction(event)
1148
1275
  if (!interaction) return
1149
1276
 
1150
- event.stopPropagation()
1277
+ claim(event)
1151
1278
 
1152
1279
  if (!interaction.apply) {
1153
1280
  return
1154
1281
  }
1155
1282
 
1283
+ // Picking switches sub-mode synchronously and replays the hover, replacing
1284
+ // the event claim. Consume this click before a farther hit can paint.
1285
+ if (isPaintPicking()) event.stopPropagation()
1156
1286
  interaction.apply()
1157
1287
  sfxEmitter.emit('sfx:paint-apply')
1158
1288
  if (activePreview?.key === interaction.key) {
@@ -1202,11 +1332,22 @@ export const SelectionManager = () => {
1202
1332
  const unsubscribePaintScope = useEditor.subscribe((state, prev) => {
1203
1333
  if (state.paintScope === prev.paintScope || !lastEnterEvent) return
1204
1334
  clearActivePreview()
1335
+ claimedEvent = null
1336
+ onEnter(lastEnterEvent)
1337
+ })
1338
+ const releasePickHold = bindPaintPickHold(window)
1339
+ // So does switching the sub-mode (the eyedropper held on Alt, a mode icon).
1340
+ const unsubscribePaintMode = usePaintRegionMode.subscribe((state, prev) => {
1341
+ if (state.mode === prev.mode || !lastEnterEvent) return
1342
+ clearActivePreview()
1343
+ claimedEvent = null
1205
1344
  onEnter(lastEnterEvent)
1206
1345
  })
1207
1346
 
1208
1347
  return () => {
1209
1348
  unsubscribePaintScope()
1349
+ unsubscribePaintMode()
1350
+ releasePickHold()
1210
1351
  for (const type of subscribedKinds) {
1211
1352
  emitter.off(`${type}:enter` as any, onEnter as any)
1212
1353
  emitter.off(`${type}:move` as any, onEnter as any)
@@ -1215,6 +1356,7 @@ export const SelectionManager = () => {
1215
1356
  }
1216
1357
  clearActivePreview()
1217
1358
  useViewer.setState({ hoveredId: null })
1359
+ usePaintOutline.setState({ surfaces: null })
1218
1360
  setHoverHighlightMode('default')
1219
1361
  useEditor.getState().setPaintHover(null)
1220
1362
  }
@@ -1260,9 +1402,20 @@ export const SelectionManager = () => {
1260
1402
  if (movingNode || isCurveReshape) return
1261
1403
 
1262
1404
  const onPointerDown = (event: NodeEvent) => {
1405
+ if (isCeilingGridHit(event)) return
1263
1406
  if (!selectionEnabled(useInteractionScope.getState().scope)) return
1407
+ if (useEditor.getState().phase === 'site') return
1264
1408
  const pointer = pointerEventFromNodeEvent(event)
1265
- if (pointer.button !== 0) return
1409
+ if (pointer.button !== 0 || pointer.altKey) return
1410
+ if (
1411
+ roomPickingEnabled() &&
1412
+ shouldInterceptRoom(
1413
+ roomForEvent(event),
1414
+ selectionModifiersFromEvent(pointer),
1415
+ event.node.id,
1416
+ )
1417
+ )
1418
+ return
1266
1419
  const handleOwnsPointer = pointerEventHitsEditorHandle(event.nativeEvent)
1267
1420
 
1268
1421
  // Plain press on a transformable member of a multi-selection arms the
@@ -1577,6 +1730,7 @@ export const SelectionManager = () => {
1577
1730
  if (movingNode || isCurveReshape) return
1578
1731
 
1579
1732
  const onClick = (event: NodeEvent) => {
1733
+ if (isCeilingGridHit(event)) return
1580
1734
  // Skip if box-select just completed (drag ended over a node)
1581
1735
  if (boxSelectHandled) return
1582
1736
 
@@ -1588,8 +1742,11 @@ export const SelectionManager = () => {
1588
1742
  // body click so only the reshape tool handles the release. (Scoped to
1589
1743
  // `endpoint`: hole-edit relies on node clicks to exit, just below.)
1590
1744
  const activeScope = useInteractionScope.getState().scope
1591
- if (activeScope.kind === 'mesh-editing') return
1592
- if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return
1745
+ if (
1746
+ !selectionEnabled(activeScope) &&
1747
+ !(activeScope.kind === 'reshaping' && activeScope.reshape === 'hole')
1748
+ )
1749
+ return
1593
1750
 
1594
1751
  if (dispatchSceneAction(event.node, getEventObject(event))) {
1595
1752
  event.stopPropagation()
@@ -1606,21 +1763,14 @@ export const SelectionManager = () => {
1606
1763
  selectedIds: useViewer.getState().selection.selectedIds,
1607
1764
  })
1608
1765
 
1609
- // A ceiling is selectable only through its corner handles, never via
1610
- // the `ceiling-grid` body mesh. When the grid is revealed (ceiling
1611
- // selected, or an item placed beneath it) a top-down click hits the
1612
- // grid first; selecting the ceiling there both re-selects it as a
1613
- // no-op and stops propagation, blocking the hosted item below. By
1614
- // ignoring non-handle ceiling clicks (without stopping propagation)
1615
- // the click falls through to the item underneath.
1616
- if (node.type === 'ceiling' && !event.viaHandle) return
1617
-
1618
1766
  // Unit focus turns clicks inside a zone into the paint gesture:
1619
1767
  // membership toggles, the zone stays unselected, focus stays.
1620
1768
  const focusedUnitId = useViewer.getState().focusedUnitId
1621
1769
  if (focusedUnitId) {
1622
1770
  const zone =
1623
- node.type === 'zone' ? node : zoneAtWorldPoint(event.position[0], event.position[2])
1771
+ node.type === 'zone'
1772
+ ? node
1773
+ : zoneAtWorldPoint(event.position[0], event.position[2], event.position[1])
1624
1774
  if (zone) {
1625
1775
  event.stopPropagation()
1626
1776
  clickHandledRef.current = true
@@ -1632,35 +1782,42 @@ export const SelectionManager = () => {
1632
1782
  }
1633
1783
  }
1634
1784
 
1635
- let currentPhase = useEditor.getState().phase
1636
- let currentStructureLayer = useEditor.getState().structureLayer
1637
- const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
1638
-
1639
- // Auto-switch between zones, structure, and furnish when clicking elements on the same level.
1640
- // Also auto-switch from site phase when clicking structural/furnish elements (e.g. 2D floorplan).
1641
- if (currentPhase === 'structure' || currentPhase === 'furnish' || currentPhase === 'site') {
1642
- if (isNodeInCurrentLevel(node)) {
1643
- const target = resolveNodeSelectionTarget(node)
1644
- if (target) {
1645
- if (target.phase !== currentPhase) {
1646
- useEditor.getState().setPhase(target.phase)
1647
- currentPhase = target.phase
1648
- }
1785
+ // From site the house is one thing: a hit anywhere on it picks its
1786
+ // building and goes inside, where the next click picks a room or element.
1787
+ if (useEditor.getState().phase === 'site') {
1788
+ if (!enterBuildingFromCanvas(node)) return
1789
+ event.stopPropagation()
1790
+ clickHandledRef.current = true
1791
+ setTimeout(() => {
1792
+ clickHandledRef.current = false
1793
+ }, 50)
1794
+ return
1795
+ }
1649
1796
 
1650
- if (
1651
- target.phase === 'structure' &&
1652
- target.structureLayer &&
1653
- target.structureLayer !== currentStructureLayer
1654
- ) {
1655
- useEditor.getState().setStructureLayer(target.structureLayer)
1656
- currentStructureLayer = target.structureLayer
1657
- }
1658
- }
1797
+ if (isNodeInCurrentLevel(node) && roomPickingEnabled()) {
1798
+ const room = roomForEvent(event)
1799
+ if (
1800
+ room &&
1801
+ shouldInterceptRoom(
1802
+ room,
1803
+ selectionModifiersFromEvent(event.nativeEvent, modifierKeysRef.current),
1804
+ node.id,
1805
+ )
1806
+ ) {
1807
+ event.stopPropagation()
1808
+ clickHandledRef.current = true
1809
+ setTimeout(() => {
1810
+ clickHandledRef.current = false
1811
+ }, 50)
1812
+ selectRoom(room)
1813
+ return
1659
1814
  }
1815
+ useEditor.getState().setHoveredRoom(null)
1660
1816
  }
1661
1817
 
1662
- const activeStrategy = SELECTION_STRATEGIES[currentPhase]
1663
- if (activeStrategy?.isValid(node)) {
1818
+ const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
1819
+ const activeStrategy = SELECTION_STRATEGIES.building
1820
+ if (activeStrategy.isValid(node)) {
1664
1821
  event.stopPropagation()
1665
1822
  clickHandledRef.current = true
1666
1823
  // Reset the handled flag after a short delay so the grid:click that the
@@ -1673,13 +1830,22 @@ export const SelectionManager = () => {
1673
1830
  clickHandledRef.current = false
1674
1831
  }, 50)
1675
1832
 
1676
- let nodeToSelect = node
1677
- if (node.type === 'stair-segment' && node.parentId) {
1678
- const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
1679
- if (parentNode && parentNode.type === 'stair') {
1680
- nodeToSelect = parentNode
1833
+ // Room first: a raised or sunken room's plate and a mezzanine's plate
1834
+ // belong to their room, never selected on their own. Past the room (the
1835
+ // drill click, or Alt) a raised or sunken room continues to the
1836
+ // footprint floor it stands on, like a ground-level room; a mezzanine
1837
+ // stays on its mezzanine.
1838
+ let footprint: SlabNode | null = null
1839
+ if (roomPickingEnabled() && isRoomOwnedPlate(node)) {
1840
+ footprint = roomOwnedPlateDrillTarget(useScene.getState().nodes, node as SlabNode)
1841
+ if (!footprint) {
1842
+ const hit = roomForEvent(event)
1843
+ if (hit) selectRoom(hit)
1844
+ return
1681
1845
  }
1682
1846
  }
1847
+
1848
+ let nodeToSelect: AnyNode = footprint ?? node
1683
1849
  nodeToSelect = resolveCanvasSelectionNode({
1684
1850
  node: nodeToSelect,
1685
1851
  nodes: useScene.getState().nodes,
@@ -1701,13 +1867,15 @@ export const SelectionManager = () => {
1701
1867
  // Move button. The first (selecting) click can't hit this because the
1702
1868
  // node isn't yet in `selectedIdsBeforeRouting`.
1703
1869
  const nativeEvent = event.nativeEvent
1704
- const hasModifier = nativeEvent.shiftKey || isCommandModifier(nativeEvent)
1870
+ const hasModifier =
1871
+ nativeEvent.altKey || nativeEvent.shiftKey || isCommandModifier(nativeEvent)
1705
1872
  const isAlreadySole =
1706
1873
  selectedIdsBeforeRouting.length === 1 && selectedIdsBeforeRouting[0] === nodeToSelect.id
1707
1874
  if (
1708
1875
  useEditor.getState().mode !== 'delete' &&
1709
1876
  !hasModifier &&
1710
1877
  isAlreadySole &&
1878
+ !expandSessionSelectionForNode(nodeToSelect.id) &&
1711
1879
  !getMovingNode() &&
1712
1880
  canDirectMoveNode(nodeToSelect)
1713
1881
  ) {
@@ -1732,7 +1900,7 @@ export const SelectionManager = () => {
1732
1900
  // `capabilities.paint` route through this entry — wall,
1733
1901
  // chimney, dormer use it today. The legacy stair / roof /
1734
1902
  // single-surface arms below stay until they migrate too.
1735
- if (nodeToSelect.type === node.type) {
1903
+ if (!footprint && nodeToSelect.type === node.type) {
1736
1904
  const paintCap = nodeRegistry.get(node.type)?.capabilities?.paint
1737
1905
  if (paintCap) {
1738
1906
  const materialIndex = getIntersectionMaterialIndex(
@@ -1829,27 +1997,23 @@ export const SelectionManager = () => {
1829
1997
  const onGridClick = (event: GridEvent) => {
1830
1998
  if (clickHandledRef.current) return
1831
1999
  if (boxSelectHandled) return
1832
- if (useInteractionScope.getState().scope.kind === 'mesh-editing') return
2000
+ if (!selectionEnabled(useInteractionScope.getState().scope)) return
1833
2001
  const nativeEvent = event.nativeEvent
1834
2002
  if (nativeEvent?.metaKey || nativeEvent?.ctrlKey || nativeEvent?.shiftKey) return
1835
2003
  // Unit focus: a ground click inside a zone paints it; elsewhere it
1836
2004
  // leaves focus and the unit selection alone.
1837
2005
  const focusedUnitId = useViewer.getState().focusedUnitId
1838
2006
  if (focusedUnitId) {
1839
- const zone = zoneAtLevelPoint(event.localPosition[0], event.localPosition[2])
2007
+ const zone = zoneAtLevelPoint(
2008
+ event.localPosition[0],
2009
+ event.localPosition[2],
2010
+ event.localPosition[1],
2011
+ )
1840
2012
  if (zone) paintZoneMembership(focusedUnitId, zone.id)
1841
2013
  return
1842
2014
  }
1843
- const { phase, structureLayer } = useEditor.getState()
1844
- const activeStrategy = SELECTION_STRATEGIES[phase]
1845
- if (activeStrategy) activeStrategy.handleDeselect()
2015
+ SELECTION_STRATEGIES[useEditor.getState().phase].handleDeselect()
1846
2016
  useEditor.getState().setSelectedMaterialTarget(null)
1847
-
1848
- // When deselecting from zone mode, return to structure select
1849
- if (phase === 'structure' && structureLayer === 'zones') {
1850
- useEditor.getState().setStructureLayer('elements')
1851
- useEditor.getState().setMode('select')
1852
- }
1853
2017
  }
1854
2018
  emitter.on('grid:click', onGridClick)
1855
2019
 
@@ -1861,7 +2025,7 @@ export const SelectionManager = () => {
1861
2025
  }
1862
2026
  }, [isCurveReshape, mode, movingNode, registryVersion])
1863
2027
 
1864
- // Global double-click handler for auto-switching phases and cross-phase hover
2028
+ // Hover and double-click
1865
2029
  useEffect(() => {
1866
2030
  // re-subscribe when plugin kinds register after mount (async plugin load)
1867
2031
  void registryVersion
@@ -1869,96 +2033,94 @@ export const SelectionManager = () => {
1869
2033
  if (movingNode || isCurveReshape) return
1870
2034
 
1871
2035
  const onEnter = (event: NodeEvent) => {
1872
- if (useInteractionScope.getState().scope.kind === 'mesh-editing') return
2036
+ if (isCeilingGridHit(event)) return
2037
+ if (!selectionEnabled(useInteractionScope.getState().scope)) return
1873
2038
  // A host-driven drag (handle resize/rotate, box-select) sets
1874
2039
  // `inputDragging`. useNodeEvents still emits hover events during it so
1875
2040
  // surface move tools keep tracking — but the select-hover outline must
1876
2041
  // stay put, so don't repaint under the cursor mid-drag.
1877
2042
  if (useViewer.getState().inputDragging) return
2043
+ // An editor handle anywhere along the ray owns the pointer — it takes the
2044
+ // press (handles draw on top of walls), so it takes the hover too: no
2045
+ // stopPropagation, so the handle behind the wall still hears its enter.
2046
+ if (pointerEventHitsEditorHandle(event.nativeEvent)) {
2047
+ clearSelectHover(event)
2048
+ return
2049
+ }
1878
2050
  const node = resolveCanvasSelectionNode({
1879
2051
  node: resolveSelectModeNodeTarget(event),
1880
2052
  nodes: useScene.getState().nodes,
1881
2053
  selectedIds: useViewer.getState().selection.selectedIds,
1882
2054
  })
1883
- const currentPhase = useEditor.getState().phase
1884
-
1885
- // Ignore site/building if we are already inside a building
1886
- if (node.type === 'building' || node.type === 'site') {
1887
- if (currentPhase === 'structure' || currentPhase === 'furnish') {
1888
- return
1889
- }
1890
- }
1891
-
1892
- // Ignore zones unless specifically in zones layer
1893
- if (node.type === 'zone') {
1894
- if (currentPhase !== 'structure' || useEditor.getState().structureLayer !== 'zones') {
1895
- return
1896
- }
2055
+ // From site the click picks the whole building, so the hover shows it.
2056
+ if (useEditor.getState().phase === 'site') {
2057
+ const buildingId = resolveCanvasBuildingId(node, useScene.getState().nodes)
2058
+ if (!buildingId) return
2059
+ event.stopPropagation()
2060
+ useViewer.getState().setHoveredId(buildingId as AnyNodeId)
2061
+ return
1897
2062
  }
1898
2063
 
1899
- // Check level constraint for interior nodes
1900
- if (currentPhase === 'structure' || currentPhase === 'furnish') {
1901
- if (!isNodeInCurrentLevel(node)) return
1902
- }
2064
+ if (node.type === 'building' || node.type === 'site') return
2065
+ // Rooms hover through their walls and floors; a zone's own volume never does.
2066
+ if (node.type === 'zone') return
2067
+ if (!isNodeInCurrentLevel(node)) return
1903
2068
 
1904
2069
  event.stopPropagation()
1905
- useViewer.setState({ hoveredId: node.id })
2070
+ if (
2071
+ roomPickingEnabled() &&
2072
+ hoverRoomFromHit(
2073
+ roomForEvent(event),
2074
+ selectionModifiersFromEvent(event.nativeEvent, modifierKeysRef.current),
2075
+ node.id,
2076
+ )
2077
+ )
2078
+ return
2079
+ useViewer.getState().setHoveredId(node.id)
1906
2080
  }
1907
2081
 
1908
- const onLeave = (event: NodeEvent) => {
2082
+ const clearSelectHover = (event: NodeEvent) => {
2083
+ useEditor.getState().setHoveredRoom(null)
1909
2084
  if (useViewer.getState().inputDragging) return
1910
- const nodeId = resolveCanvasSelectionNode({
2085
+ const nodes = useScene.getState().nodes
2086
+ const node = resolveCanvasSelectionNode({
1911
2087
  node: resolveSelectModeNodeTarget(event),
1912
- nodes: useScene.getState().nodes,
2088
+ nodes,
1913
2089
  selectedIds: useViewer.getState().selection.selectedIds,
1914
- })?.id
2090
+ })
2091
+ const nodeId =
2092
+ useEditor.getState().phase === 'site' ? resolveCanvasBuildingId(node, nodes) : node.id
1915
2093
  if (nodeId && useViewer.getState().hoveredId === nodeId) {
1916
2094
  useViewer.setState({ hoveredId: null })
1917
2095
  }
1918
2096
  }
1919
2097
 
2098
+ const onLeave = (event: NodeEvent) => {
2099
+ if (isCeilingGridHit(event)) return
2100
+ clearSelectHover(event)
2101
+ }
2102
+
1920
2103
  const onDoubleClick = (event: NodeEvent) => {
2104
+ if (isCeilingGridHit(event)) return
1921
2105
  if (useInteractionScope.getState().scope.kind === 'mesh-editing') return
1922
- let node = resolveCanvasSelectionNode({
1923
- node: resolveSelectModeNodeTarget(event),
2106
+ const node = resolveCanvasSelectionNode({
2107
+ node: resolveSelectModeNodeTarget(event, true),
1924
2108
  nodes: useScene.getState().nodes,
1925
2109
  selectedIds: useViewer.getState().selection.selectedIds,
1926
2110
  })
1927
-
1928
- const currentPhase = useEditor.getState().phase
1929
-
1930
- const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
1931
- const target = resolveNodeSelectionTarget(node)
1932
- let targetPhase: 'site' | 'structure' | 'furnish' | null = target?.phase ?? null
1933
- let targetStructureLayer = target?.structureLayer
1934
- let forceSelect = false
1935
-
1936
- if (node.type === 'building' || node.type === 'site') {
1937
- if (currentPhase === 'structure' || currentPhase === 'furnish') {
1938
- return // Ignore building/site double clicks if we are already inside a building
1939
- }
1940
- if (node.type === 'building') {
1941
- targetPhase = 'structure'
1942
- targetStructureLayer = 'elements'
1943
- }
1944
- } else {
1945
- if (node.type === 'roof-segment' && currentPhase === 'structure') {
1946
- forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
1947
- }
1948
- if (node.type === 'stair-segment' && currentPhase === 'structure') {
1949
- forceSelect = true // allow double click to dive into stair-segment even if already in structure phase
1950
- }
1951
- }
2111
+ if (useEditor.getState().phase !== 'building' || !isNodeInCurrentLevel(node)) return
1952
2112
 
1953
2113
  // While a unit is focused a double-click inside a zone selects that
1954
2114
  // zone (focus stays); the two clicks before it cancel each other's paint.
1955
2115
  if (useViewer.getState().focusedUnitId) {
1956
2116
  const zone =
1957
- node.type === 'zone' ? node : zoneAtWorldPoint(event.position[0], event.position[2])
2117
+ node.type === 'zone'
2118
+ ? node
2119
+ : zoneAtWorldPoint(event.position[0], event.position[2], event.position[1])
1958
2120
  if (zone) {
1959
2121
  event.stopPropagation()
1960
2122
  cancelPendingZonePaint(zone.id)
1961
- SELECTION_STRATEGIES.structure?.handleSelect(
2123
+ SELECTION_STRATEGIES.building.handleSelect(
1962
2124
  zone,
1963
2125
  event.nativeEvent,
1964
2126
  modifierKeysRef.current,
@@ -1968,34 +2130,16 @@ export const SelectionManager = () => {
1968
2130
  }
1969
2131
  }
1970
2132
 
1971
- if (node.type === 'zone') {
1972
- return
1973
- }
1974
-
1975
- if ((targetPhase && targetPhase !== useEditor.getState().phase) || forceSelect) {
2133
+ // A single click selects the whole roof or stair; the double-click dives
2134
+ // into the segment under the cursor.
2135
+ if (node.type === 'roof-segment' || node.type === 'stair-segment') {
1976
2136
  event.stopPropagation()
1977
-
1978
- if (targetPhase && targetPhase !== useEditor.getState().phase) {
1979
- useEditor.getState().setPhase(targetPhase)
1980
- }
1981
-
1982
- if (
1983
- targetPhase === 'structure' &&
1984
- targetStructureLayer &&
1985
- targetStructureLayer !== useEditor.getState().structureLayer
1986
- ) {
1987
- useEditor.getState().setStructureLayer(targetStructureLayer)
1988
- }
1989
-
1990
- const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase]
1991
- if (strategy) {
1992
- strategy.handleSelect(
1993
- node,
1994
- event.nativeEvent,
1995
- modifierKeysRef.current,
1996
- selectedIdsBeforeRouting,
1997
- )
1998
- }
2137
+ SELECTION_STRATEGIES.building.handleSelect(
2138
+ node,
2139
+ event.nativeEvent,
2140
+ modifierKeysRef.current,
2141
+ useViewer.getState().selection.selectedIds,
2142
+ )
1999
2143
  }
2000
2144
  }
2001
2145
 
@@ -2025,6 +2169,7 @@ export const SelectionManager = () => {
2025
2169
 
2026
2170
  subscribedKinds.forEach((type) => {
2027
2171
  emitter.on(`${type}:enter` as any, onEnter as any)
2172
+ emitter.on(`${type}:move` as any, onEnter as any)
2028
2173
  emitter.on(`${type}:leave` as any, onLeave as any)
2029
2174
  emitter.on(`${type}:double-click` as any, onDoubleClick as any)
2030
2175
  })
@@ -2032,6 +2177,7 @@ export const SelectionManager = () => {
2032
2177
  return () => {
2033
2178
  subscribedKinds.forEach((type) => {
2034
2179
  emitter.off(`${type}:enter` as any, onEnter as any)
2180
+ emitter.off(`${type}:move` as any, onEnter as any)
2035
2181
  emitter.off(`${type}:leave` as any, onLeave as any)
2036
2182
  emitter.off(`${type}:double-click` as any, onDoubleClick as any)
2037
2183
  })
@@ -2045,6 +2191,7 @@ export const SelectionManager = () => {
2045
2191
  if (mode !== 'delete') return
2046
2192
 
2047
2193
  const onClick = (event: NodeEvent) => {
2194
+ if (isCeilingGridHit(event)) return
2048
2195
  const node = event.node
2049
2196
  if (!isNodeInCurrentLevel(node)) return
2050
2197
 
@@ -2063,14 +2210,16 @@ export const SelectionManager = () => {
2063
2210
  }
2064
2211
 
2065
2212
  const onEnter = (event: NodeEvent) => {
2213
+ if (isCeilingGridHit(event)) return
2066
2214
  const node = event.node
2067
2215
  if (!isNodeInCurrentLevel(node)) return
2068
2216
  if (node.type === 'building' || node.type === 'site') return
2069
2217
  event.stopPropagation()
2070
- useViewer.setState({ hoveredId: node.id })
2218
+ useViewer.getState().setHoveredId(node.id)
2071
2219
  }
2072
2220
 
2073
2221
  const onLeave = (event: NodeEvent) => {
2222
+ if (isCeilingGridHit(event)) return
2074
2223
  const nodeId = event?.node?.id
2075
2224
  if (nodeId && useViewer.getState().hoveredId === nodeId) {
2076
2225
  useViewer.setState({ hoveredId: null })
@@ -2121,6 +2270,7 @@ export const SelectionManager = () => {
2121
2270
  <SelectionStateSync />
2122
2271
  <SelectionMaterialSync />
2123
2272
  <EditorOutlinerSync />
2273
+ <RoomHighlight3D />
2124
2274
  </>
2125
2275
  )
2126
2276
  }
@@ -2372,39 +2522,18 @@ const EditorOutlinerSync = () => {
2372
2522
  const registryVersion = useRegistryVersion()
2373
2523
  const outliner = useViewer((s) => s.outliner)
2374
2524
  const nodes = useScene((s) => s.nodes)
2525
+ const paintOutline = usePaintOutline((s) => s.surfaces)
2375
2526
 
2376
2527
  useEffect(() => {
2377
2528
  void geometryRevision
2378
2529
  void registryVersion
2379
2530
  let idsToHighlight: string[] = []
2380
2531
 
2381
- // 1. Determine what should be highlighted based on Phase
2382
- switch (phase) {
2383
- case 'site':
2384
- // Only highlight the building if one is selected
2385
- if (selection.buildingId) idsToHighlight = [selection.buildingId]
2386
- break
2387
-
2388
- case 'structure':
2389
- // Highlight selected items (walls/slabs)
2390
- // We IGNORE buildingId even if it's set in the store
2391
- idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
2392
- break
2393
-
2394
- case 'furnish':
2395
- // Highlight selected furniture/items
2396
- idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
2397
- break
2398
-
2399
- default:
2400
- // Pure Viewer mode: Highlight based on the "deepest" selection
2401
- if (selection.selectedIds.length > 0 || previewSelectedIds.length > 0) {
2402
- idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
2403
- } else if (selection.levelId) {
2404
- idsToHighlight = [selection.levelId]
2405
- } else if (selection.buildingId) {
2406
- idsToHighlight = [selection.buildingId]
2407
- }
2532
+ // 1. Site outlines the building; inside it, the selection.
2533
+ if (phase === 'site') {
2534
+ if (selection.buildingId) idsToHighlight = [selection.buildingId]
2535
+ } else {
2536
+ idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
2408
2537
  }
2409
2538
 
2410
2539
  // 2. Sync with the imperative outliner arrays (mutate in place to keep references)
@@ -2417,7 +2546,11 @@ const EditorOutlinerSync = () => {
2417
2546
  }
2418
2547
 
2419
2548
  outliner.hoveredObjects.length = 0
2420
- if (hoveredId) {
2549
+ if (paintOutline) {
2550
+ // Paint on a floor plate: exactly the surfaces the click changes.
2551
+ for (const mesh of paintSurfaceMeshes(paintOutline, (id) => sceneRegistry.nodes.get(id)))
2552
+ if (mesh.parent) outliner.hoveredObjects.push(mesh)
2553
+ } else if (hoveredId) {
2421
2554
  if (!nodes[hoveredId as AnyNodeId]) {
2422
2555
  useViewer.setState({ hoveredId: null })
2423
2556
  } else {
@@ -2440,6 +2573,7 @@ const EditorOutlinerSync = () => {
2440
2573
  hoveredId,
2441
2574
  outliner,
2442
2575
  nodes,
2576
+ paintOutline,
2443
2577
  ])
2444
2578
 
2445
2579
  return null