@pascal-app/editor 1.0.3 → 1.0.5

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 (624) hide show
  1. package/README.md +106 -0
  2. package/package.json +42 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/action-menu-button.tsx +83 -0
  5. package/src/components/editor/bake-thumbnail.tsx +307 -5
  6. package/src/components/editor/capture-level-picker.tsx +72 -0
  7. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  8. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  9. package/src/components/editor/custom-camera-controls.tsx +275 -57
  10. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  11. package/src/components/editor/duplicate-node.ts +244 -0
  12. package/src/components/editor/editor-layout-mobile.tsx +37 -26
  13. package/src/components/editor/editor-layout-v2.tsx +132 -59
  14. package/src/components/editor/export-manager.tsx +47 -26
  15. package/src/components/editor/first-person/build-collider-world.test.ts +320 -0
  16. package/src/components/editor/first-person/build-collider-world.ts +28 -7
  17. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  18. package/src/components/editor/first-person/ground-holes.ts +130 -0
  19. package/src/components/editor/first-person/mechanism-targets.test.ts +162 -0
  20. package/src/components/editor/first-person/mechanism-targets.ts +88 -0
  21. package/src/components/editor/first-person-controls.tsx +220 -9
  22. package/src/components/editor/floating-action-menu.tsx +63 -207
  23. package/src/components/editor/floor-aware-camera-controls.test.ts +466 -0
  24. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  25. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  26. package/src/components/editor/floor-region-controls.tsx +846 -0
  27. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  28. package/src/components/editor/floor-surface.test.ts +165 -0
  29. package/src/components/editor/floor-surface.ts +182 -0
  30. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  31. package/src/components/editor/floorplan-background-selection.ts +1 -3
  32. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  33. package/src/components/editor/floorplan-panel.tsx +414 -363
  34. package/src/components/editor/follow-pascal.test.tsx +912 -0
  35. package/src/components/editor/follow-pascal.tsx +354 -0
  36. package/src/components/editor/footprint-height-handle.tsx +204 -0
  37. package/src/components/editor/group-actions.ts +56 -2
  38. package/src/components/editor/group-move-3d.ts +2 -0
  39. package/src/components/editor/group-rotate-handle.tsx +2 -0
  40. package/src/components/editor/group-transform-shared.test.ts +460 -0
  41. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  42. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  43. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  45. package/src/components/editor/index.tsx +409 -197
  46. package/src/components/editor/join-walls-pill.tsx +44 -0
  47. package/src/components/editor/node-action-menu.tsx +68 -70
  48. package/src/components/editor/node-arrow-handles.tsx +25 -6
  49. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  50. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  52. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  53. package/src/components/editor/registry-action-contributions.tsx +80 -1
  54. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  55. package/src/components/editor/room-controls.tsx +847 -0
  56. package/src/components/editor/room-floor-highlight.tsx +128 -0
  57. package/src/components/editor/room-handles-2d.tsx +354 -0
  58. package/src/components/editor/room-handles.tsx +675 -0
  59. package/src/components/editor/room-highlight.tsx +233 -0
  60. package/src/components/editor/room-labels.test.ts +850 -0
  61. package/src/components/editor/room-labels.tsx +1890 -0
  62. package/src/components/editor/room-transform-ghost.tsx +256 -0
  63. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  64. package/src/components/editor/selection-manager.tsx +474 -419
  65. package/src/components/editor/slab-hole-highlights.tsx +5 -2
  66. package/src/components/editor/snapshot-capture-overlay.tsx +7 -2
  67. package/src/components/editor/snapshot-capture.test.ts +119 -60
  68. package/src/components/editor/snapshot-capture.ts +36 -5
  69. package/src/components/editor/thumbnail-generator.test.ts +654 -0
  70. package/src/components/editor/thumbnail-generator.tsx +1103 -223
  71. package/src/components/editor/use-floorplan-background-placement.ts +34 -12
  72. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  73. package/src/components/editor/views/host-view.tsx +61 -0
  74. package/src/components/editor/views/plugins-view.tsx +663 -0
  75. package/src/components/editor/views/scene-region.tsx +59 -0
  76. package/src/components/editor/views/use-editor-views.ts +57 -0
  77. package/src/components/editor/views/view-bar.test.tsx +54 -0
  78. package/src/components/editor/views/view-bar.tsx +343 -0
  79. package/src/components/editor/wall-justification.test.ts +140 -0
  80. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  81. package/src/components/editor/wall-push-arrow.tsx +92 -0
  82. package/src/components/editor/wall-region-handles.tsx +509 -0
  83. package/src/components/editor/wall-snap-beacon-layer.tsx +8 -4
  84. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  85. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  86. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  87. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  88. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  89. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  90. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +116 -106
  91. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  92. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  93. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  94. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  95. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  96. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  97. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +319 -233
  98. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  99. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  100. package/src/components/editor-2d/svg-paths.ts +12 -15
  101. package/src/components/point-ask/bubble.tsx +538 -0
  102. package/src/components/point-ask/hover-chip.tsx +91 -0
  103. package/src/components/point-ask/mode-pill.tsx +92 -0
  104. package/src/components/point-ask/optional-motion.ts +22 -0
  105. package/src/components/point-ask/outline-corners.tsx +93 -0
  106. package/src/components/point-ask/pins.tsx +711 -0
  107. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  108. package/src/components/point-ask/point-ask-styles.ts +105 -0
  109. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  110. package/src/components/point-ask/region-marquee.tsx +64 -0
  111. package/src/components/procedural-items/inspector.tsx +40 -15
  112. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  113. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  114. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  115. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  116. package/src/components/systems/zone/zone-system.test.ts +47 -0
  117. package/src/components/systems/zone/zone-system.tsx +29 -68
  118. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  119. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  120. package/src/components/tools/distribution/distribution-public.ts +9 -0
  121. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  122. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  123. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  124. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  125. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  126. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  127. package/src/components/tools/distribution/ports.test.ts +56 -0
  128. package/src/components/tools/distribution/ports.ts +378 -0
  129. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  130. package/src/components/tools/distribution/run-cursor.ts +36 -0
  131. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  132. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  133. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  134. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  135. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  136. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  137. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  138. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  139. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  140. package/src/components/tools/item/placement-history.test.tsx +512 -0
  141. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  142. package/src/components/tools/item/placement-strategies.ts +11 -10
  143. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  144. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  145. package/src/components/tools/item/use-draft-node.ts +166 -95
  146. package/src/components/tools/item/use-placement-coordinator.tsx +148 -95
  147. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  148. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  149. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  150. package/src/components/tools/registry/item-surface-move.ts +2 -0
  151. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  152. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  153. package/src/components/tools/registry/move-registry-node-tool.tsx +66 -30
  154. package/src/components/tools/select/box-select-tool.tsx +2 -12
  155. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  156. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  157. package/src/components/tools/select/select-candidates.test.ts +37 -4
  158. package/src/components/tools/select/select-candidates.ts +15 -11
  159. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  160. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  161. package/src/components/tools/tool-manager.tsx +59 -24
  162. package/src/components/tools/wall/wall-drafting.test.ts +175 -47
  163. package/src/components/tools/zone/zone-tool.tsx +4 -12
  164. package/src/components/ui/action-menu/action-button.tsx +9 -1
  165. package/src/components/ui/action-menu/camera-actions.tsx +9 -4
  166. package/src/components/ui/action-menu/control-modes.tsx +15 -42
  167. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  168. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  169. package/src/components/ui/action-menu/index.tsx +56 -33
  170. package/src/components/ui/action-menu/measurement-control.tsx +14 -19
  171. package/src/components/ui/action-menu/placement.ts +20 -0
  172. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  173. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  174. package/src/components/ui/build-panel/build-panel.tsx +242 -0
  175. package/src/components/ui/command-palette/editor-commands.tsx +7 -42
  176. package/src/components/ui/command-palette/index.tsx +50 -39
  177. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  178. package/src/components/ui/controls/action-button.tsx +5 -2
  179. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  180. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  181. package/src/components/ui/controls/material-picker.tsx +4 -1
  182. package/src/components/ui/controls/metric-control.tsx +1 -1
  183. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  184. package/src/components/ui/controls/segmented-control.tsx +21 -8
  185. package/src/components/ui/controls/select-control.tsx +65 -0
  186. package/src/components/ui/controls/slider-control.tsx +1 -1
  187. package/src/components/ui/controls/toggle-control.tsx +3 -3
  188. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  189. package/src/components/ui/floating-level-selector.tsx +159 -59
  190. package/src/components/ui/helpers/building-helper.tsx +4 -1
  191. package/src/components/ui/helpers/contextual-helper-panel.tsx +181 -72
  192. package/src/components/ui/helpers/helper-manager.test.tsx +121 -0
  193. package/src/components/ui/helpers/helper-manager.tsx +331 -14
  194. package/src/components/ui/helpers/item-helper.tsx +17 -1
  195. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  196. package/src/components/ui/icon-ref.tsx +45 -1
  197. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  198. package/src/components/ui/node-icon-image.tsx +16 -0
  199. package/src/components/ui/panels/collections/collections-popover.tsx +28 -22
  200. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  201. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  202. package/src/components/ui/panels/mobile-panel-sheet.tsx +4 -4
  203. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -5
  204. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  205. package/src/components/ui/panels/multi-field-value.ts +6 -3
  206. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  207. package/src/components/ui/panels/multi-selection-panel.tsx +161 -5
  208. package/src/components/ui/panels/node-display.ts +8 -3
  209. package/src/components/ui/panels/panel-manager.tsx +103 -68
  210. package/src/components/ui/panels/panel-wrapper.tsx +130 -62
  211. package/src/components/ui/panels/parametric-field-control.tsx +3 -3
  212. package/src/components/ui/panels/parametric-inspector.tsx +7 -7
  213. package/src/components/ui/panels/reference-panel.tsx +2 -3
  214. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  215. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  216. package/src/components/ui/panels/room-name-options.ts +47 -0
  217. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  218. package/src/components/ui/panels/room-panel.tsx +790 -0
  219. package/src/components/ui/panels/shape-choice.tsx +58 -0
  220. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  221. package/src/components/ui/primitives/badge.tsx +8 -0
  222. package/src/components/ui/primitives/color-dot.tsx +36 -24
  223. package/src/components/ui/primitives/sidebar.tsx +3 -1
  224. package/src/components/ui/right-stack.test.ts +29 -0
  225. package/src/components/ui/right-stack.tsx +164 -0
  226. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  227. package/src/components/ui/sidebar/icon-rail.tsx +8 -3
  228. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  229. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  230. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  231. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  232. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  233. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  234. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +138 -27
  235. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  236. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  237. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  238. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  239. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  240. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  241. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  242. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  243. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  244. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  245. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  246. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  247. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  248. package/src/components/ui/sidebar/panels/site-panel/index.tsx +181 -223
  249. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  250. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  251. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  252. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  253. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  254. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  255. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  256. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  257. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  258. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  259. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -37
  260. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  261. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  262. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  263. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  264. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -4
  265. package/src/components/ui/sidebar/tab-bar.tsx +14 -4
  266. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  267. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  268. package/src/components/viewer/floorplan-preview.tsx +5 -6
  269. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  270. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  271. package/src/components/viewer/viewer-rooms.tsx +191 -0
  272. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  273. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  274. package/src/components/viewer/viewer-stage.tsx +2 -2
  275. package/src/components/viewer-zone-system.tsx +3 -8
  276. package/src/hooks/use-auto-save.integration.test.tsx +215 -43
  277. package/src/hooks/use-auto-save.ts +60 -9
  278. package/src/hooks/use-ceiling-events.test.ts +123 -118
  279. package/src/hooks/use-grid-events.test.ts +105 -105
  280. package/src/hooks/use-keyboard.test.ts +243 -19
  281. package/src/hooks/use-keyboard.ts +118 -83
  282. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  283. package/src/hooks/use-open-wall-ends.ts +238 -0
  284. package/src/hooks/use-placement-notice.test.ts +71 -0
  285. package/src/hooks/use-placement-notice.ts +73 -0
  286. package/src/hooks/use-selected-room.ts +143 -0
  287. package/src/index.tsx +312 -58
  288. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  289. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  290. package/src/lib/add-storey.test.ts +177 -0
  291. package/src/lib/add-storey.ts +59 -0
  292. package/src/lib/below-grade.test.tsx +171 -0
  293. package/src/lib/below-grade.ts +133 -0
  294. package/src/lib/camera-facing.test.ts +24 -0
  295. package/src/lib/camera-facing.ts +24 -0
  296. package/src/lib/ceiling-edit-session.test.ts +321 -0
  297. package/src/lib/ceiling-edit-session.ts +206 -0
  298. package/src/lib/ceiling-region-session.test.ts +179 -0
  299. package/src/lib/continuation.test.ts +19 -1
  300. package/src/lib/continuation.ts +25 -4
  301. package/src/lib/direct-manipulation.ts +2 -0
  302. package/src/lib/door-interaction.ts +14 -0
  303. package/src/lib/download-project-file.ts +17 -0
  304. package/src/lib/editor-level-display.test.ts +96 -0
  305. package/src/lib/editor-level-display.ts +55 -0
  306. package/src/lib/editor-views.ts +178 -0
  307. package/src/lib/export-geometry-wait.test.ts +90 -0
  308. package/src/lib/export-texture-utils.ts +2 -4
  309. package/src/lib/floor-edit-session.ts +153 -0
  310. package/src/lib/floor-footprints.test.ts +563 -0
  311. package/src/lib/floor-footprints.ts +395 -0
  312. package/src/lib/floor-foundation-support.test.ts +284 -0
  313. package/src/lib/floor-opening-draft.ts +218 -0
  314. package/src/lib/floor-openings.test.ts +216 -0
  315. package/src/lib/floor-region-geometry.ts +191 -0
  316. package/src/lib/floor-region-session.test.ts +485 -0
  317. package/src/lib/floor-region-session.ts +589 -0
  318. package/src/lib/floor-region-snap.ts +101 -0
  319. package/src/lib/floor-step-hover.test.ts +32 -0
  320. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  321. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  322. package/src/lib/floorplan/dimension-drive.ts +479 -0
  323. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  324. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  325. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  326. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  327. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  328. package/src/lib/floorplan/floorplan-pdfkit-document.ts +363 -27
  329. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +177 -8
  330. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +60 -25
  331. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  332. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  333. package/src/lib/floorplan/geometry.ts +13 -6
  334. package/src/lib/floorplan/index.ts +0 -9
  335. package/src/lib/floorplan/items.ts +5 -2
  336. package/src/lib/floorplan/marks.test.ts +213 -0
  337. package/src/lib/floorplan/marks.ts +238 -0
  338. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  339. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  340. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  341. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  342. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  343. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  344. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  345. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  346. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  347. package/src/lib/floorplan/schedules.test.ts +189 -0
  348. package/src/lib/floorplan/schedules.ts +339 -0
  349. package/src/lib/floorplan/sheet-export.test.ts +124 -0
  350. package/src/lib/floorplan/sheet-export.ts +318 -0
  351. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +768 -0
  352. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  353. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  354. package/src/lib/floorplan/site-plan/coverage.ts +230 -0
  355. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  356. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  357. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  358. package/src/lib/floorplan/site-plan/front-edge.ts +157 -0
  359. package/src/lib/floorplan/site-plan/geometry.test.ts +269 -0
  360. package/src/lib/floorplan/site-plan/geometry.ts +142 -0
  361. package/src/lib/floorplan/site-plan/index.ts +55 -0
  362. package/src/lib/floorplan/site-plan/site-annotations.test.ts +483 -0
  363. package/src/lib/floorplan/site-plan/site-annotations.ts +1084 -0
  364. package/src/lib/floorplan/site-plan/site-parts.ts +287 -0
  365. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  366. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  367. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  368. package/src/lib/floorplan/site-plan/yards.test.ts +70 -0
  369. package/src/lib/floorplan/types.ts +1 -27
  370. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  371. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  372. package/src/lib/follow-pascal-exterior.ts +39 -0
  373. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  374. package/src/lib/follow-pascal-presentation.ts +129 -0
  375. package/src/lib/follow-pascal-sight.ts +84 -0
  376. package/src/lib/follow-pascal-targets.test.ts +406 -0
  377. package/src/lib/follow-pascal-targets.ts +198 -0
  378. package/src/lib/follow-pascal.test.ts +350 -0
  379. package/src/lib/follow-pascal.ts +284 -0
  380. package/src/lib/fresh-planar-placement.test.ts +148 -0
  381. package/src/lib/fresh-planar-placement.ts +114 -37
  382. package/src/lib/geometry-script/author.ts +157 -0
  383. package/src/lib/geometry-script/client.ts +159 -0
  384. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  385. package/src/lib/geometry-script/protocol.ts +13 -0
  386. package/src/lib/gesture-lifecycle.test.ts +595 -0
  387. package/src/lib/gesture-lifecycle.ts +290 -0
  388. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  389. package/src/lib/glb-export.test.ts +636 -126
  390. package/src/lib/glb-export.ts +241 -120
  391. package/src/lib/history.test.ts +18 -7
  392. package/src/lib/history.ts +7 -0
  393. package/src/lib/hud-title.ts +109 -0
  394. package/src/lib/hydration-gated-storage.ts +49 -0
  395. package/src/lib/ifc-export.test.ts +174 -0
  396. package/src/lib/ifc-export.ts +201 -0
  397. package/src/lib/ifc-parts.ts +15 -0
  398. package/src/lib/inspector-expanded.test.ts +44 -0
  399. package/src/lib/inspector-expanded.ts +36 -0
  400. package/src/lib/interaction/scope.ts +22 -0
  401. package/src/lib/keyboard-pan.test.ts +129 -7
  402. package/src/lib/keyboard-pan.ts +161 -12
  403. package/src/lib/level-selection.test.ts +57 -0
  404. package/src/lib/level-selection.ts +16 -1
  405. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  406. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  407. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  408. package/src/lib/lot/clean-ring.test.ts +187 -0
  409. package/src/lib/lot/clean-ring.ts +217 -0
  410. package/src/lib/lot/dossier.test.ts +202 -0
  411. package/src/lib/lot/dossier.ts +498 -0
  412. package/src/lib/lot/drop-in.test.ts +326 -0
  413. package/src/lib/lot/drop-in.ts +392 -0
  414. package/src/lib/lot/index.ts +43 -0
  415. package/src/lib/lot/lot-patch.test.ts +340 -0
  416. package/src/lib/lot/lot-patch.ts +394 -0
  417. package/src/lib/lot/parcel-provider.ts +43 -0
  418. package/src/lib/lot/terrain.test.ts +161 -0
  419. package/src/lib/lot/terrain.ts +266 -0
  420. package/src/lib/material-catalog-model.ts +3 -1
  421. package/src/lib/material-paint-panel-model.ts +9 -5
  422. package/src/lib/material-paint.ts +4 -0
  423. package/src/lib/measurement-kind.test.ts +6 -8
  424. package/src/lib/measurements.test.ts +23 -0
  425. package/src/lib/mezzanine-actions.test.ts +342 -0
  426. package/src/lib/mezzanine-draft.test.ts +501 -0
  427. package/src/lib/mezzanine-draft.ts +278 -0
  428. package/src/lib/mezzanine-messages.ts +26 -0
  429. package/src/lib/mezzanine-selection.test.ts +207 -0
  430. package/src/lib/mezzanine-stairs.ts +46 -0
  431. package/src/lib/model-export.ts +3 -1
  432. package/src/lib/node-action-movement.ts +17 -6
  433. package/src/lib/node-display-label.ts +37 -0
  434. package/src/lib/orbit-floor-clearance.test.ts +201 -0
  435. package/src/lib/orbit-floor-clearance.ts +154 -0
  436. package/src/lib/paint-commit-route.test.ts +37 -0
  437. package/src/lib/paint-commit-route.ts +17 -0
  438. package/src/lib/paint-eyedropper.test.tsx +165 -0
  439. package/src/lib/paint-eyedropper.ts +99 -0
  440. package/src/lib/paint-pass-through.test.ts +122 -0
  441. package/src/lib/paint-pass-through.ts +78 -0
  442. package/src/lib/paint-region-hover.ts +25 -0
  443. package/src/lib/paint-region-mode.ts +154 -0
  444. package/src/lib/paint-regions.test.ts +166 -0
  445. package/src/lib/paint-regions.ts +192 -0
  446. package/src/lib/paint-scope.test.ts +317 -35
  447. package/src/lib/paint-scope.ts +267 -25
  448. package/src/lib/panel-rows.ts +21 -0
  449. package/src/lib/parametric-node-update.ts +16 -7
  450. package/src/lib/plate-paint-affected.test.ts +300 -0
  451. package/src/lib/plate-paint-affected.ts +177 -0
  452. package/src/lib/plugin-chat.ts +65 -0
  453. package/src/lib/plugin-panels.test.ts +25 -0
  454. package/src/lib/plugin-panels.ts +94 -0
  455. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  456. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  457. package/src/lib/point-ask/ask-created.test.ts +195 -0
  458. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  459. package/src/lib/point-ask/build-context.test.ts +173 -0
  460. package/src/lib/point-ask/build-context.ts +94 -0
  461. package/src/lib/point-ask/capture-crop.ts +129 -0
  462. package/src/lib/point-ask/choreography.ts +69 -0
  463. package/src/lib/point-ask/compare.test.ts +145 -0
  464. package/src/lib/point-ask/compare.ts +73 -0
  465. package/src/lib/point-ask/describe-target.test.ts +165 -0
  466. package/src/lib/point-ask/describe-target.ts +234 -0
  467. package/src/lib/point-ask/dictation.ts +79 -0
  468. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  469. package/src/lib/point-ask/edge-marker.ts +27 -0
  470. package/src/lib/point-ask/frame-loop.ts +25 -0
  471. package/src/lib/point-ask/history-marks.test.ts +94 -0
  472. package/src/lib/point-ask/history-marks.ts +39 -0
  473. package/src/lib/point-ask/host-api.ts +32 -0
  474. package/src/lib/point-ask/outline-box.test.ts +30 -0
  475. package/src/lib/point-ask/outline-box.ts +11 -0
  476. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  477. package/src/lib/point-ask/pick.test.ts +220 -0
  478. package/src/lib/point-ask/pick.ts +121 -0
  479. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  480. package/src/lib/point-ask/pin-machine.ts +217 -0
  481. package/src/lib/point-ask/placement.test.ts +203 -0
  482. package/src/lib/point-ask/placement.ts +141 -0
  483. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  484. package/src/lib/point-ask/projector.ts +83 -0
  485. package/src/lib/point-ask/region.test.ts +157 -0
  486. package/src/lib/point-ask/region.ts +136 -0
  487. package/src/lib/point-ask/retry.test.ts +217 -0
  488. package/src/lib/point-ask/retry.ts +37 -0
  489. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  490. package/src/lib/point-ask/scene-commit.ts +32 -0
  491. package/src/lib/point-ask/session.ts +1391 -0
  492. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  493. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  494. package/src/lib/point-ask/spring.test.ts +63 -0
  495. package/src/lib/point-ask/spring.ts +107 -0
  496. package/src/lib/point-ask/suggestions.test.ts +80 -0
  497. package/src/lib/point-ask/suggestions.ts +125 -0
  498. package/src/lib/point-ask/target-scope.test.ts +622 -0
  499. package/src/lib/point-ask/target-scope.ts +338 -0
  500. package/src/lib/point-ask/types.ts +48 -0
  501. package/src/lib/portable-export.test.ts +34 -1
  502. package/src/lib/portable-export.ts +119 -53
  503. package/src/lib/print-feature-thickness.ts +10 -1
  504. package/src/lib/print-roof-solids.test.ts +39 -1
  505. package/src/lib/print-roof-solids.ts +43 -10
  506. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  507. package/src/lib/print-shell-compiler.ts +1 -0
  508. package/src/lib/registered-draft-snap.ts +12 -0
  509. package/src/lib/roof-duplication.ts +10 -2
  510. package/src/lib/room-assembly-overlay.test.ts +275 -0
  511. package/src/lib/room-assembly-overlay.ts +265 -0
  512. package/src/lib/room-built-on.test.ts +379 -0
  513. package/src/lib/room-built-on.ts +138 -0
  514. package/src/lib/room-construction-commands.ts +712 -0
  515. package/src/lib/room-construction.test.ts +746 -0
  516. package/src/lib/room-construction.ts +385 -0
  517. package/src/lib/room-controls-phase6.test.ts +630 -0
  518. package/src/lib/room-divide-session.ts +523 -0
  519. package/src/lib/room-handle-drag.ts +1068 -0
  520. package/src/lib/room-name-catalog.test.ts +90 -0
  521. package/src/lib/room-name-catalog.ts +168 -0
  522. package/src/lib/room-push-dimensions.test.ts +311 -0
  523. package/src/lib/room-push-dimensions.ts +251 -0
  524. package/src/lib/room-selection-commands.ts +62 -0
  525. package/src/lib/room-selection.test.ts +1017 -0
  526. package/src/lib/room-selection.ts +417 -0
  527. package/src/lib/room-structure-commands.ts +94 -0
  528. package/src/lib/room-structure.test.ts +921 -0
  529. package/src/lib/room-transform-session.ts +570 -0
  530. package/src/lib/room-zone-routing.test.ts +294 -0
  531. package/src/lib/room-zone-routing.ts +197 -0
  532. package/src/lib/scene-clipboard.test.ts +610 -1
  533. package/src/lib/scene-clipboard.ts +269 -58
  534. package/src/lib/scene-material-refs.test.ts +79 -0
  535. package/src/lib/scene-material-refs.ts +79 -0
  536. package/src/lib/scene.test.ts +24 -36
  537. package/src/lib/scene.ts +33 -57
  538. package/src/lib/selection-routing.test.ts +129 -45
  539. package/src/lib/selection-routing.ts +54 -75
  540. package/src/lib/session-writes.test.ts +120 -0
  541. package/src/lib/session-writes.ts +205 -0
  542. package/src/lib/sfx-bus.ts +13 -0
  543. package/src/lib/sfx-player.test.ts +70 -9
  544. package/src/lib/sfx-player.ts +6 -0
  545. package/src/lib/sidebar-panel.ts +62 -0
  546. package/src/lib/site-boundary.test.ts +4 -6
  547. package/src/lib/site-boundary.ts +1 -1
  548. package/src/lib/snapping-mode.test.ts +4 -0
  549. package/src/lib/snapping-mode.ts +8 -1
  550. package/src/lib/stair-levels.test.ts +25 -0
  551. package/src/lib/stair-levels.ts +22 -3
  552. package/src/lib/terrace-draft.test.ts +145 -0
  553. package/src/lib/terrace-draft.ts +214 -0
  554. package/src/lib/tool-cursor-icon.test.ts +16 -0
  555. package/src/lib/tool-cursor-icon.ts +11 -0
  556. package/src/lib/typing-target.ts +9 -0
  557. package/src/lib/units.ts +77 -39
  558. package/src/lib/vector-edges.test.ts +58 -0
  559. package/src/lib/vector-edges.ts +400 -0
  560. package/src/lib/viewer-selection.test.ts +577 -0
  561. package/src/lib/viewer-selection.ts +580 -0
  562. package/src/lib/wall-draw-variant.test.ts +71 -0
  563. package/src/lib/wall-draw-variant.ts +91 -0
  564. package/src/lib/wall-paint-region-session.ts +246 -0
  565. package/src/lib/wall-paint-region.test.ts +373 -0
  566. package/src/lib/wall-placement-pointer.test.ts +42 -0
  567. package/src/lib/wall-placement-pointer.ts +57 -0
  568. package/src/lib/wall-polygon-draft.test.ts +262 -0
  569. package/src/lib/wall-polygon-draft.ts +286 -0
  570. package/src/lib/wall-push.test.ts +453 -0
  571. package/src/lib/wall-region-face.ts +352 -0
  572. package/src/lib/wall-region-handles.test.ts +304 -0
  573. package/src/lib/wall-region-handles.ts +368 -0
  574. package/src/lib/wall-region-list.test.ts +119 -0
  575. package/src/lib/wall-region-list.ts +136 -0
  576. package/src/lib/wall-region-snap.ts +110 -0
  577. package/src/lib/window-changes.ts +35 -0
  578. package/src/lib/window-interaction.ts +14 -0
  579. package/src/lib/zone-content.ts +4 -1
  580. package/src/lib/zone-name.test.ts +17 -0
  581. package/src/lib/zone-name.ts +17 -0
  582. package/src/store/opens-in-select-mode.test.ts +223 -0
  583. package/src/store/paint-mode.test.ts +188 -0
  584. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  585. package/src/store/tool-mode.test.ts +137 -6
  586. package/src/store/use-audio.test.ts +146 -0
  587. package/src/store/use-audio.tsx +127 -3
  588. package/src/store/use-construction-reveal.test.ts +173 -0
  589. package/src/store/use-construction-reveal.ts +145 -0
  590. package/src/store/use-corner-snap-hint.test.ts +24 -0
  591. package/src/store/use-corner-snap-hint.ts +47 -0
  592. package/src/store/use-delete-confirmation.ts +28 -1
  593. package/src/store/use-drawing-view.test.ts +24 -3
  594. package/src/store/use-drawing-view.ts +29 -1
  595. package/src/store/use-editor.tsx +432 -321
  596. package/src/store/use-floorplan-draft-preview.ts +13 -0
  597. package/src/store/use-floorplan-marquee.ts +12 -2
  598. package/src/store/use-follow-pascal.test.ts +62 -0
  599. package/src/store/use-follow-pascal.ts +53 -0
  600. package/src/store/use-point-ask.ts +247 -0
  601. package/src/store/use-quick-measurement-hud.ts +3 -3
  602. package/src/store/use-stair-build-preview.ts +33 -5
  603. package/src/store/use-wall-move-ghosts.ts +4 -2
  604. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  605. package/src/store/use-wall-region-handles-selection.ts +139 -0
  606. package/src/store/view-layout.test.ts +201 -0
  607. package/src/store/view-layout.ts +199 -0
  608. package/src/test-utils/immediate-animation-frames.ts +16 -0
  609. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  610. package/src/test-utils/render-current-markup.tsx +16 -0
  611. package/src/test-utils/selection-harness.ts +82 -0
  612. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  613. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  614. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  615. package/src/components/tools/fence/fence-drafting.ts +0 -266
  616. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  617. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  618. package/src/components/tools/stair/stair-defaults.ts +0 -18
  619. package/src/components/tools/stair/stair-tool.tsx +0 -634
  620. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  621. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  622. package/src/lib/floorplan/stairs.ts +0 -489
  623. package/src/lib/level-duplication.test.ts +0 -144
  624. package/src/lib/level-duplication.ts +0 -195
@@ -4,9 +4,9 @@ import {
4
4
  type BuildingNode,
5
5
  createSceneApi,
6
6
  emitter,
7
+ floorPlatePaintRefusal,
7
8
  type GridEvent,
8
- getEffectiveRoofSurfaceMaterial,
9
- getEffectiveSegmentSurfaceMaterial,
9
+ getRoofSegmentDeckReach,
10
10
  getRoofSegmentSurfaceY,
11
11
  getSelectableKinds,
12
12
  type ItemNode,
@@ -19,6 +19,7 @@ import {
19
19
  type RoofSegmentEvent,
20
20
  type RoofSegmentNode,
21
21
  resolveLevelId,
22
+ type SlabNode,
22
23
  type StairEvent,
23
24
  type StairSegmentEvent,
24
25
  type StairSurfaceMaterialRole,
@@ -26,18 +27,18 @@ import {
26
27
  useLiveNodeOverrides,
27
28
  useRegistryVersion,
28
29
  useScene,
30
+ wallFaceScriptPaintRefusal,
29
31
  } from '@pascal-app/core'
30
-
31
- import {
32
- createMaterial,
33
- createMaterialFromPresetRef,
34
- getRoofMaterialArray,
35
- registerMaterialCacheCleanup,
36
- useViewer,
37
- } from '@pascal-app/viewer'
32
+ import { getRoofMaterialArray, registerMaterialCacheCleanup, useViewer } from '@pascal-app/viewer'
38
33
  import { useThree } from '@react-three/fiber'
39
34
  import { useCallback, useEffect, useRef } from 'react'
40
35
  import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
36
+ import {
37
+ hoverRoomFromHit,
38
+ resolveEditorRoomHit,
39
+ roomPickingEnabled,
40
+ useRoomRecords,
41
+ } from '../../hooks/use-selected-room'
41
42
  import {
42
43
  canDirectMoveNode,
43
44
  canDirectRotateNode,
@@ -48,33 +49,67 @@ import {
48
49
  shouldStartDirectMoveDrag,
49
50
  } from '../../lib/direct-manipulation'
50
51
  import { createEditorApi } from '../../lib/editor-api'
52
+ import {
53
+ isFootprintConstructionHit,
54
+ isRoomOwnedPlate,
55
+ roomOwnedPlateDrillTarget,
56
+ } from '../../lib/floor-footprints'
51
57
  import { selectionEnabled } from '../../lib/interaction/scope'
52
58
  import {
53
59
  type ActivePaintMaterial,
54
- buildRoofSegmentSurfaceMaterialPatch,
55
60
  buildRoofSurfaceMaterialPatch,
56
61
  hasActivePaintMaterial,
57
62
  resolveActivePaintMaterialFromSelection,
58
63
  } from '../../lib/material-paint'
64
+ import { paintCommitRoute } from '../../lib/paint-commit-route'
65
+ import { eyedropperMaterial, paintMaterialKey } from '../../lib/paint-eyedropper'
66
+ import {
67
+ openingPartHidden,
68
+ paintPassesThrough,
69
+ wallHitInOpening,
70
+ } from '../../lib/paint-pass-through'
59
71
  import {
60
72
  combinePaintPreviews,
61
73
  createPaintPreviewOwner,
62
74
  type PaintPreviewCleanup,
63
75
  } from '../../lib/paint-preview-owner'
64
76
  import {
65
- availablePaintScopes,
77
+ bindPaintPickHold,
78
+ isPaintErasing,
79
+ isPaintPicking,
80
+ paintPickReturnMode,
81
+ paintRegionModeActive,
82
+ usePaintRegionMode,
83
+ } from '../../lib/paint-region-mode'
84
+ import {
66
85
  commitPaintScopeFanout,
86
+ effectivePaintScope,
67
87
  nodeSlotRoles,
68
88
  type PaintHoverInfo,
89
+ paintHoverInfo,
90
+ paintScopeRole,
91
+ paintSurfaceLabel,
69
92
  resolvePaintScopeTargets,
70
93
  slotDisplayLabel,
71
94
  type WallPaintHit,
72
95
  } from '../../lib/paint-scope'
96
+ import {
97
+ ceilingAffectedSurfaces,
98
+ mergePaintSurfaces,
99
+ paintSurfaceMeshes,
100
+ plateAffectedSurfaces,
101
+ platePreviewSurfaces,
102
+ usePaintOutline,
103
+ } from '../../lib/plate-paint-affected'
73
104
  import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
105
+ import { sameRoom } from '../../lib/room-selection'
106
+ import { selectRoom, shouldInterceptRoom } from '../../lib/room-selection-commands'
107
+ import { roomKeyForZone } from '../../lib/room-zone-routing'
74
108
  import {
75
109
  emitCanvasNodeSelection,
110
+ enterBuildingFromCanvas,
111
+ resolveCanvasBuildingId,
76
112
  resolveCanvasSelectionNode,
77
- resolveNodeSelectionTarget,
78
113
  resolveSelectedIdsForNodeClick,
79
114
  type SelectionModifierKeys,
80
115
  selectionModifiersFromEvent,
@@ -88,7 +123,7 @@ import {
88
123
  zoneAtWorldPoint,
89
124
  } from '../../lib/units'
90
125
  import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
91
- import useEditor, { type MaterialTargetRole } from './../../store/use-editor'
126
+ import useEditor, { type MaterialTargetRole, type Phase } from './../../store/use-editor'
92
127
  import useInteractionScope, {
93
128
  getEditingHole,
94
129
  getMovingNode,
@@ -100,6 +135,7 @@ import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/b
100
135
  import { armGroupMove3d } from './group-move-3d'
101
136
  import { classifyParticipant } from './group-transform-shared'
102
137
  import { swallowNextClick } from './node-arrow-handles'
138
+ import { RoomHighlight3D } from './room-highlight'
103
139
  import { setEditorThreeContext } from './three-context-bridge'
104
140
 
105
141
  const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -111,24 +147,6 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => {
111
147
  return nodeLevelId === currentLevelId
112
148
  }
113
149
 
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
150
  type PaintInteraction = {
133
151
  key: string
134
152
  apply: (() => void) | null
@@ -141,7 +159,6 @@ type PaintInteraction = {
141
159
  }
142
160
 
143
161
  interface SelectionStrategy {
144
- types: SelectableNodeType[]
145
162
  handleSelect: (
146
163
  node: AnyNode,
147
164
  nativeEvent?: any,
@@ -183,6 +200,18 @@ export const resolveBuildingId = (
183
200
  return null
184
201
  }
185
202
 
203
+ /**
204
+ * Furnishings: picking one leaves the room context (a sofa is not part of the
205
+ * room's shell). Door and window catalog items are openings, not furnishings.
206
+ */
207
+ function isFurnishingNode(node: AnyNode): boolean {
208
+ if (node.type === 'item') {
209
+ const category = (node as ItemNode).asset.category
210
+ return category !== 'door' && category !== 'window'
211
+ }
212
+ return nodeRegistry.get(node.type)?.category === 'furnish'
213
+ }
214
+
186
215
  function resolveStairMaterialTarget(
187
216
  event: StairEvent | StairSegmentEvent,
188
217
  ): StairSurfaceMaterialRole | null {
@@ -233,6 +262,14 @@ function resolveRoofMaterialTarget(
233
262
  return null
234
263
  }
235
264
 
265
+ function isCeilingGridHit(event: NodeEvent): boolean {
266
+ return (
267
+ !event.viaHandle &&
268
+ event.node.type === 'ceiling' &&
269
+ getEventObject(event)?.name === 'ceiling-grid'
270
+ )
271
+ }
272
+
236
273
  function getEventObject(event: NodeEvent): Object3D {
237
274
  const eventWithObject = event as NodeEvent & { object?: Object3D }
238
275
  return eventWithObject.object ?? event.nativeEvent.object
@@ -309,6 +346,31 @@ function meshSlotRoles(node: AnyNode): string[] {
309
346
  const roofSelectionWorldPoint = new Vector3()
310
347
  const wallPaintWorldPoint = new Vector3()
311
348
 
349
+ const roomHitPoint = new Vector3()
350
+ const roomHitXZ: [number, number] = [0, 0]
351
+ export function roomForEvent(event: NodeEvent) {
352
+ if (event.node.type !== 'wall' && event.node.type !== 'slab' && event.node.type !== 'ceiling')
353
+ return null
354
+ // The floor edge band and the foundation belong to the footprint, not the
355
+ // room above: the plate takes the pick and opens Floor & foundation.
356
+ if (isFootprintConstructionHit(event.node, event.object)) return null
357
+ const levelId = resolveLevelId(event.node, useScene.getState().nodes)
358
+ if (!levelId) return null
359
+ if (event.viaHandle && event.node.type === 'ceiling') {
360
+ roomHitXZ[0] = event.position[0]
361
+ roomHitXZ[1] = event.position[2]
362
+ return resolveEditorRoomHit(event.node, levelId, roomHitXZ, '3d')
363
+ }
364
+ const level = sceneRegistry.nodes.get(levelId)
365
+ if (!level) return null
366
+ level.updateWorldMatrix(true, false)
367
+ roomHitPoint.set(...event.position)
368
+ level.worldToLocal(roomHitPoint)
369
+ roomHitXZ[0] = roomHitPoint.x
370
+ roomHitXZ[1] = roomHitPoint.z
371
+ return resolveEditorRoomHit(event.node, levelId, roomHitXZ, '3d')
372
+ }
373
+
312
374
  function resolveWallPaintHit(event: NodeEvent): WallPaintHit | undefined {
313
375
  const wall = event.node
314
376
  if (wall.type !== 'wall') return undefined
@@ -331,7 +393,7 @@ function resolveWallPaintHit(event: NodeEvent): WallPaintHit | undefined {
331
393
  }
332
394
  }
333
395
 
334
- function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
396
+ export function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
335
397
  const roof = event.node
336
398
  if (roof.type !== 'roof') return null
337
399
 
@@ -351,9 +413,9 @@ function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode |
351
413
 
352
414
  object.updateWorldMatrix(true, false)
353
415
  const local = object.worldToLocal(roofSelectionWorldPoint.clone())
354
- const overhang = segment.overhang ?? 0
355
- const halfWidth = segment.width / 2 + overhang
356
- const halfDepth = segment.depth / 2 + overhang
416
+ const reach = getRoofSegmentDeckReach(segment)
417
+ const halfWidth = segment.width / 2 + reach.x
418
+ const halfDepth = segment.depth / 2 + reach.z
357
419
 
358
420
  if (Math.abs(local.x) > halfWidth || Math.abs(local.z) > halfDepth) {
359
421
  continue
@@ -368,7 +430,11 @@ function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode |
368
430
  return bestSegment?.node ?? firstSegment
369
431
  }
370
432
 
371
- function resolveSelectModeNodeTarget(event: NodeEvent): AnyNode {
433
+ function resolveSelectModeNodeTarget(event: NodeEvent, drill = false): AnyNode {
434
+ if (!drill && event.node.type === 'stair-segment' && event.node.parentId) {
435
+ const parent = useScene.getState().nodes[event.node.parentId as AnyNodeId]
436
+ if (parent?.type === 'stair') return parent
437
+ }
372
438
  if (event.node.type === 'roof') {
373
439
  if (
374
440
  shouldPreserveSelectedRoofHostTarget({
@@ -420,52 +486,14 @@ function applyRoofPaintPreview(
420
486
  useViewer.getState().textures,
421
487
  useViewer.getState().colorPreset,
422
488
  useViewer.getState().sceneTheme,
489
+ null,
490
+ useScene.getState().materials,
423
491
  )
424
492
  if (!previewMaterial) return null
425
493
 
426
494
  return previewMeshMaterial(mesh, previewMaterial)
427
495
  }
428
496
 
429
- function applyRoofSegmentPaintPreview(
430
- node: RoofSegmentNode,
431
- parent: RoofNode | null,
432
- role: 'top' | 'edge' | 'wall',
433
- material: ActivePaintMaterial,
434
- ): PaintPreviewCleanup | null {
435
- const mesh = getRegisteredMesh(node.id)
436
- if (!mesh) return null
437
-
438
- // Synthesise the segment node as if the paint had committed, then build
439
- // the same 4-slot array the renderer would. Mirrors getRoofMaterialArray
440
- // layout (slot 0 ← edge, 1 ← wall, 2 ← wall, 3 ← top) so the preview
441
- // material lands on the matching CSG groups.
442
- const previewNode: RoofSegmentNode = {
443
- ...node,
444
- ...buildRoofSegmentSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
445
- }
446
- const resolveSlot = (r: 'top' | 'edge' | 'wall'): Material | null => {
447
- const parentSpec = parent ? getEffectiveRoofSurfaceMaterial(parent, r) : undefined
448
- const spec = getEffectiveSegmentSurfaceMaterial(previewNode, r, parentSpec)
449
- if (typeof spec.materialPreset === 'string') {
450
- const resolved = createMaterialFromPresetRef(spec.materialPreset)
451
- if (resolved) return resolved
452
- }
453
- if (spec.material !== undefined) return createMaterial(spec.material)
454
- return null
455
- }
456
- const edge = resolveSlot('edge')
457
- const wall = resolveSlot('wall')
458
- const top = resolveSlot('top')
459
- if (!(edge || wall || top)) return null
460
- const fallback = parent ? getRoofMaterialArray(parent) : null
461
- const fb = (n: number) => fallback?.[n] ?? null
462
- // Per-role only, then the parent's themed slot — matches the renderer so the
463
- // preview never bleeds a painted surface onto the segment's other surfaces.
464
- const arr: Material[] = [edge ?? fb(0)!, wall ?? fb(1)!, wall ?? fb(2)!, top ?? fb(3)!]
465
- if (arr.some((m) => !m)) return null
466
- return previewMeshMaterial(mesh, arr)
467
- }
468
-
469
497
  // Chimney + dormer paint dispatch lives on their NodeDefinition's
470
498
  // `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/
471
499
  // paint.ts). The generic registry-driven arm in this file consults
@@ -504,7 +532,7 @@ const HIGHLIGHT_PROFILES = {
504
532
  } as const
505
533
 
506
534
  type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
507
- type HoverHighlightMode = 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
535
+ type HoverHighlightMode = 'default' | 'delete' | 'paint-ready' | 'erase-ready' | 'paint-disabled'
508
536
 
509
537
  type HighlightableMaterial = Material & {
510
538
  color?: Color
@@ -611,9 +639,8 @@ const computeNextIds = (
611
639
  })
612
640
  }
613
641
 
614
- const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
642
+ const SELECTION_STRATEGIES: Record<Phase, SelectionStrategy> = {
615
643
  site: {
616
- types: ['building'],
617
644
  handleSelect: (node) => {
618
645
  useViewer.getState().setSelection({ buildingId: (node as BuildingNode).id })
619
646
  },
@@ -623,24 +650,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
623
650
  isValid: (node) => node.type === 'building',
624
651
  },
625
652
 
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
- ],
653
+ building: {
644
654
  handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
645
655
  const { selection, setSelection } = useViewer.getState()
646
656
  const nodes = useScene.getState().nodes
@@ -662,10 +672,15 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
662
672
  updates.buildingId = buildingId
663
673
  }
664
674
 
675
+ if (node.type === 'zone' && !useViewer.getState().focusedUnitId) {
676
+ const room = roomKeyForZone(node.id)
677
+ if (room) {
678
+ if (!sameRoom(useEditor.getState().room, room)) selectRoom(room)
679
+ return
680
+ }
681
+ }
665
682
  if (node.type === 'zone') {
666
683
  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
684
  setSelection(updates)
670
685
  } else {
671
686
  updates.selectedIds = computeNextIds(
@@ -679,21 +694,12 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
679
694
  }
680
695
  },
681
696
  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
- }
697
+ useEditor.getState().clearRoom()
698
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
688
699
  },
689
700
  isValid: (node) => {
690
701
  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 (
702
+ return (
697
703
  node.type === 'wall' ||
698
704
  node.type === 'fence' ||
699
705
  node.type === 'column' ||
@@ -704,75 +710,36 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
704
710
  node.type === 'roof-segment' ||
705
711
  node.type === 'stair' ||
706
712
  node.type === 'stair-segment' ||
707
- node.type === 'spawn'
713
+ node.type === 'spawn' ||
714
+ node.type === 'window' ||
715
+ node.type === 'door' ||
716
+ node.type === 'item' ||
717
+ isRegistrySelectable(node.type)
708
718
  )
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,
749
- )
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
719
  },
771
720
  },
772
721
  }
773
722
 
774
723
  export const SelectionManager = () => {
775
- const phase = useEditor((s) => s.phase)
724
+ const roomLevelId = useViewer((s) => s.selection.levelId)
725
+ useRoomRecords(roomLevelId)
726
+ useEffect(
727
+ () =>
728
+ useViewer.subscribe((state, previous) => {
729
+ if (
730
+ state.selection.levelId !== previous.selection.levelId ||
731
+ (state.selection.zoneId !== previous.selection.zoneId && state.selection.zoneId) ||
732
+ (state.selection.selectedIds !== previous.selection.selectedIds &&
733
+ state.selection.selectedIds.some((id) => {
734
+ const node = useScene.getState().nodes[id as AnyNodeId]
735
+ return node && isFurnishingNode(node)
736
+ }))
737
+ ) {
738
+ useEditor.getState().clearRoom()
739
+ }
740
+ }),
741
+ [],
742
+ )
776
743
  const mode = useEditor((s) => s.mode)
777
744
  // The canvas element — cursor styling must land here, not on `document.body`:
778
745
  // the editor wraps the canvas in a div with a custom `cursor: url(...)`, which
@@ -825,6 +792,18 @@ export const SelectionManager = () => {
825
792
  // The last hover event, replayed when the application scope cycles so the
826
793
  // preview + chip update under a stationary cursor (Shift fires no pointer move).
827
794
  let lastEnterEvent: NodeEvent | null = null
795
+ // Paint hover never stops a pointer event. R3F keeps a hover that stopped
796
+ // propagation stopping every later move over that object, which would pin
797
+ // the pointer to a door or a wall it should pass through where it opens
798
+ // (see `paint-pass-through`). So the nearest surface that takes a pointer
799
+ // event claims it; farther ones see the claim and stand down.
800
+ let claimedEvent: unknown = null
801
+ let hoveredNodeId: string | null = null
802
+ const claim = (event: NodeEvent) => {
803
+ claimedEvent = event.nativeEvent.nativeEvent
804
+ }
805
+ const claimedByNearer = (event: NodeEvent) =>
806
+ claimedEvent !== null && claimedEvent === event.nativeEvent.nativeEvent
828
807
 
829
808
  const clearActivePreview = () => {
830
809
  activePreview?.restore()
@@ -835,6 +814,7 @@ export const SelectionManager = () => {
835
814
  useEditor.getState().activePaintMaterial ??
836
815
  resolveActivePaintMaterialFromSelection({
837
816
  nodes: useScene.getState().nodes,
817
+ materials: useScene.getState().materials,
838
818
  selectedId:
839
819
  useViewer.getState().selection.selectedIds.length === 1
840
820
  ? (useViewer.getState().selection.selectedIds[0] ?? null)
@@ -842,8 +822,50 @@ export const SelectionManager = () => {
842
822
  selectedMaterialTarget: useEditor.getState().selectedMaterialTarget,
843
823
  })
844
824
 
825
+ // The eyedropper: the hover shows what a click would take (the cursor
826
+ // swatch), the click takes it and returns to the sub-mode it came from.
827
+ const pickInteraction = (
828
+ node: AnyNode,
829
+ role: string | null,
830
+ event: NodeEvent,
831
+ materialIndex: number | null,
832
+ ): PaintInteraction => {
833
+ const picked = role
834
+ ? eyedropperMaterial({
835
+ node,
836
+ role,
837
+ nodes: useScene.getState().nodes,
838
+ materials: useScene.getState().materials,
839
+ hitObject: getEventObject(event),
840
+ materialIndex,
841
+ })
842
+ : null
843
+ return {
844
+ key: `pick:${node.id}:${role ?? 'none'}:${paintMaterialKey(picked)}`,
845
+ hoveredId: node.id as AnyNodeId,
846
+ hoverMode: picked ? 'paint-ready' : 'paint-disabled',
847
+ paintHover:
848
+ picked && role
849
+ ? { scopes: ['single'], slotLabel: paintSurfaceLabel(node, role), nodeNoun: node.type }
850
+ : null,
851
+ apply: picked
852
+ ? () => useEditor.getState().armMaterialPaint(picked, paintPickReturnMode())
853
+ : null,
854
+ preview: () => {
855
+ usePaintRegionMode.setState({ picked })
856
+ return () => {
857
+ if (usePaintRegionMode.getState().picked === picked)
858
+ usePaintRegionMode.setState({ picked: null })
859
+ }
860
+ },
861
+ }
862
+ }
863
+
845
864
  const resolvePaintInteraction = (event: NodeEvent): PaintInteraction | null => {
846
- const eraser = useEditor.getState().paintEraser
865
+ // A region sub-mode draws its own region before painting; the
866
+ // whole-surface hover and click stand down.
867
+ if (paintRegionModeActive('material-paint')) return null
868
+ const eraser = isPaintErasing()
847
869
  const activePaintMaterial = resolveActivePaintMaterial()
848
870
  const node = event.node
849
871
 
@@ -885,6 +907,25 @@ export const SelectionManager = () => {
885
907
  hitObject: getEventObject(event),
886
908
  ray: event.nativeEvent.ray,
887
909
  })
910
+ // Not claiming the hover lets the pointer event reach the surface behind:
911
+ // an empty door or window, or a wall's collision hit in one of its holes.
912
+ if (
913
+ paintPassesThrough(node, role) ||
914
+ ((node.type === 'door' || node.type === 'window') &&
915
+ openingPartHidden(
916
+ getRegisteredNodeObject(node.id),
917
+ event.nativeEvent.ray,
918
+ event.nativeEvent.intersections,
919
+ )) ||
920
+ (node.type === 'wall' &&
921
+ wallHitInOpening(
922
+ getRegisteredNodeObject(node.id),
923
+ event.nativeEvent.ray,
924
+ event.nativeEvent.distance,
925
+ ))
926
+ )
927
+ return null
928
+ if (isPaintPicking()) return pickInteraction(node, role, event, materialIndex ?? null)
888
929
  const compatible = role !== null && paintEnabled
889
930
  // Derive the node's slots (declared, else mesh tags) once — drives both
890
931
  // the chip's available scopes and the whole-object fan-out.
@@ -893,7 +934,13 @@ export const SelectionManager = () => {
893
934
  // / all matching / room). The scope is part of the key so cycling it
894
935
  // (Shift) re-keys the interaction → the preview re-applies for the new
895
936
  // spread instead of being deduped to the single-surface preview.
896
- const scope = useEditor.getState().paintScope
937
+ const hover = compatible && role ? paintHoverInfo(node, role, slotRoles) : null
938
+ // A scope carried over from another surface paints what the chip
939
+ // shows for this one: the narrowest when it isn't offered here.
940
+ const scope = effectivePaintScope(
941
+ useEditor.getState().paintScope,
942
+ hover?.scopes ?? ['single'],
943
+ )
897
944
  const wallHit = resolveWallPaintHit(event)
898
945
  const scopeTargets =
899
946
  compatible && role
@@ -907,30 +954,47 @@ export const SelectionManager = () => {
907
954
  wallHit,
908
955
  })
909
956
  : []
910
- const scopeTargetKey = scopeTargets
957
+ // What the click changes on floor plates, fallbacks included (a room's
958
+ // floor carries its unpainted steps): the outline. The preview draws
959
+ // the same set, each follower through the surface it follows.
960
+ const scopeRole = compatible && role ? paintScopeRole(node, role, scope) : null
961
+ const sceneNodes = useScene.getState().nodes
962
+ const affected = scopeRole
963
+ ? (plateAffectedSurfaces(sceneNodes, node, scopeRole, { erasing: eraser }) ??
964
+ ceilingAffectedSurfaces(node, scopeRole))
965
+ : null
966
+ const previewTargets = mergePaintSurfaces(
967
+ scopeTargets,
968
+ scopeRole
969
+ ? (platePreviewSurfaces(sceneNodes, node, scopeRole) ??
970
+ ceilingAffectedSurfaces(node, scopeRole))
971
+ : null,
972
+ )
973
+ const scopeTargetKey = previewTargets
911
974
  .map((target) => `${target.nodeId}:${target.role}`)
912
975
  .sort()
913
976
  .join(',')
914
977
  return {
915
978
  key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}:${scope}:${scopeTargetKey}`,
916
979
  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,
980
+ hoverMode: compatible ? (eraser ? 'erase-ready' : 'paint-ready') : 'paint-disabled',
981
+ paintHover: hover,
926
982
  apply:
927
983
  compatible && role
928
984
  ? () => {
985
+ // A surface with nothing of its own to paint (a footprint's
986
+ // top where no room stands), or one a script builds, says
987
+ // so instead of painting.
988
+ const refusal =
989
+ floorPlatePaintRefusal(useScene.getState().nodes, node, role) ??
990
+ wallFaceScriptPaintRefusal(node, role)
991
+ usePaintRegionMode.getState().setNotice(refusal?.message ?? null)
992
+ if (refusal) return
929
993
  // 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) {
994
+ // batched commit writes them in a single undo step; only
995
+ // the hovered surface itself keeps the kind's own commit
996
+ // (covers non-slot kinds too) — see `paintCommitRoute`.
997
+ if (paintCommitRoute(scopeTargets, node.id, role) === 'fanout') {
934
998
  commitPaintScopeFanout(
935
999
  scopeTargets,
936
1000
  paintSpec.material,
@@ -964,10 +1028,14 @@ export const SelectionManager = () => {
964
1028
  // hovered surface. Each target is the same kind, so its own
965
1029
  // paint capability builds the preview; restores combine.
966
1030
  const restores: PaintPreviewCleanup[] = []
967
- const sceneNodes = useScene.getState().nodes
1031
+ const liveNodes = useScene.getState().nodes
1032
+ if (affected) {
1033
+ usePaintOutline.setState({ surfaces: affected })
1034
+ restores.push(() => usePaintOutline.setState({ surfaces: null }))
1035
+ }
968
1036
  try {
969
- for (const target of scopeTargets) {
970
- const targetNode = sceneNodes[target.nodeId]
1037
+ for (const target of previewTargets) {
1038
+ const targetNode = liveNodes[target.nodeId]
971
1039
  const targetRoot = getRegisteredNodeObject(target.nodeId)
972
1040
  const targetCap = targetNode
973
1041
  ? nodeRegistry.get(targetNode.type)?.capabilities?.paint
@@ -975,6 +1043,8 @@ export const SelectionManager = () => {
975
1043
  if (!(targetNode && targetRoot && targetCap)) continue
976
1044
  const restore = targetCap.applyPreview({
977
1045
  node: targetNode,
1046
+ nodes: liveNodes,
1047
+ materials: useScene.getState().materials,
978
1048
  role: target.role,
979
1049
  material: paintSpec.material,
980
1050
  materialPreset: paintSpec.materialPreset,
@@ -993,82 +1063,50 @@ export const SelectionManager = () => {
993
1063
  }
994
1064
  }
995
1065
 
996
- if (node.type === 'roof' || node.type === 'roof-segment') {
997
- const isSegmentHit = node.type === 'roof-segment'
998
- const roofNode =
999
- node.type === 'roof'
1000
- ? node
1001
- : node.parentId
1002
- ? useScene.getState().nodes[node.parentId as AnyNodeId]
1003
- : null
1004
- if (roofNode?.type !== 'roof') return null
1005
-
1006
- const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
1066
+ // A segment paints through its definition's `capabilities.paint`; the merged shell's hit is
1067
+ // the roof's, and fans to it.
1068
+ if (node.type === 'roof') {
1069
+ const role = resolveRoofMaterialTarget(event as RoofEvent)
1070
+ if (isPaintPicking()) return pickInteraction(node, role, event, null)
1007
1071
  const compatible = role !== null && paintEnabled
1008
- // Painting directly on a segment (only possible in segment edit
1009
- // mode, where the per-segment mesh is visible) writes to the
1010
- // segment's own role-specific fields. Painting the merged shell
1011
- // — or a roof node directly — keeps fanning to the parent roof.
1012
- const segmentTarget = isSegmentHit ? (node as RoofSegmentNode) : null
1013
1072
  return {
1014
- key: `${segmentTarget ? 'roof-segment' : 'roof'}:${
1015
- segmentTarget ? segmentTarget.id : roofNode.id
1016
- }:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
1017
- hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId,
1018
- hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
1073
+ key: `roof:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
1074
+ hoveredId: node.id as AnyNodeId,
1075
+ hoverMode: compatible ? (eraser ? 'erase-ready' : 'paint-ready') : 'paint-disabled',
1019
1076
  // Roof isn't on the slot model (role-specific fields, custom commit),
1020
1077
  // so it offers only the single surface — but still labels it.
1021
1078
  paintHover:
1022
1079
  compatible && role
1023
1080
  ? {
1024
1081
  scopes: ['single'],
1025
- slotLabel: slotDisplayLabel(roofNode, role),
1082
+ slotLabel: slotDisplayLabel(node, role),
1026
1083
  nodeNoun: 'roof',
1027
1084
  }
1028
1085
  : null,
1029
1086
  apply:
1030
1087
  compatible && role
1031
1088
  ? () => {
1032
- const sceneState = useScene.getState()
1033
- if (segmentTarget) {
1034
- sceneState.updateNode(
1035
- segmentTarget.id as AnyNodeId,
1036
- buildRoofSegmentSurfaceMaterialPatch(
1037
- segmentTarget,
1038
- role,
1039
- paintSpec.material,
1040
- paintSpec.materialPreset,
1041
- ),
1042
- )
1043
- } else {
1044
- sceneState.updateNode(
1045
- roofNode.id as AnyNodeId,
1089
+ useScene
1090
+ .getState()
1091
+ .updateNode(
1092
+ node.id as AnyNodeId,
1046
1093
  buildRoofSurfaceMaterialPatch(
1047
- roofNode as RoofNode,
1094
+ node,
1048
1095
  role,
1049
1096
  paintSpec.material,
1050
1097
  paintSpec.materialPreset,
1051
1098
  ),
1052
1099
  )
1053
- }
1054
1100
  }
1055
1101
  : null,
1056
1102
  preview:
1057
1103
  compatible && role
1058
- ? () =>
1059
- segmentTarget
1060
- ? applyRoofSegmentPaintPreview(
1061
- segmentTarget,
1062
- roofNode as RoofNode,
1063
- role,
1064
- paintSpec,
1065
- )
1066
- : applyRoofPaintPreview(roofNode as RoofNode, role, paintSpec)
1104
+ ? () => applyRoofPaintPreview(node, role, paintSpec)
1067
1105
  : () => previewCursor('not-allowed'),
1068
1106
  }
1069
1107
  }
1070
1108
 
1071
- // Only `roof` / `roof-segment` reach a legacy paint arm (above) — every
1109
+ // Only `roof` reaches a legacy paint arm (above) — every
1072
1110
  // other paintable kind declares `capabilities.paint` and returns from the
1073
1111
  // registry-driven dispatch at the top of this function.
1074
1112
 
@@ -1091,17 +1129,20 @@ export const SelectionManager = () => {
1091
1129
  previewOwner.wrap(resolvePaintInteraction(event))
1092
1130
 
1093
1131
  const onEnter = (event: NodeEvent) => {
1132
+ if (isCeilingGridHit(event)) return
1094
1133
  // A host-driven drag (handle resize/rotate) sets `inputDragging`.
1095
1134
  // useNodeEvents now emits hover events during such a drag so surface
1096
1135
  // move tools keep tracking the cursor — but paint preview must not fire
1097
1136
  // mid-drag, so gate on `inputDragging` here too.
1098
1137
  if (boxSelectHandled || useViewer.getState().inputDragging) return
1138
+ if (claimedByNearer(event)) return
1099
1139
 
1100
1140
  const interaction = getPaintInteraction(event)
1101
1141
  if (!interaction) return
1102
1142
 
1103
- event.stopPropagation()
1143
+ claim(event)
1104
1144
  lastEnterEvent = event
1145
+ hoveredNodeId = interaction.hoveredId
1105
1146
 
1106
1147
  // Drive the paint HUD off this hover: the interaction carries the scopes +
1107
1148
  // labels for the painted surface (`null` when it isn't paintable — no
@@ -1123,36 +1164,43 @@ export const SelectionManager = () => {
1123
1164
  }
1124
1165
 
1125
1166
  const onLeave = (event: NodeEvent) => {
1126
- const interaction = getPaintInteraction(event)
1127
- if (!interaction) return
1167
+ if (isCeilingGridHit(event)) return
1168
+ // Only the hovered node leaving ends the hover; a surface behind it (under
1169
+ // an opening that never stops the pointer) leaving says nothing about it.
1170
+ // This goes by the node, not by resolving the hit again: a leave event's
1171
+ // ray no longer meets the node, so that resolved to nothing and every
1172
+ // leave was ignored — the last surface stayed previewed, with its HUD.
1173
+ if (event.node.id !== hoveredNodeId) return
1128
1174
 
1129
1175
  // Leaving any surface → the HUD shows the "hover a surface" hint again.
1176
+ hoveredNodeId = null
1130
1177
  lastEnterEvent = null
1131
1178
  useEditor.getState().setPaintHover(null)
1132
-
1133
- if (activePreview?.key !== interaction.key) {
1134
- return
1135
- }
1136
-
1179
+ usePaintOutline.setState({ surfaces: null })
1137
1180
  clearActivePreview()
1138
- if (useViewer.getState().hoveredId === interaction.hoveredId) {
1181
+ if (useViewer.getState().hoveredId === event.node.id) {
1139
1182
  useViewer.setState({ hoveredId: null })
1140
1183
  }
1141
1184
  setHoverHighlightMode('default')
1142
1185
  }
1143
1186
 
1144
1187
  const onClick = (event: NodeEvent) => {
1188
+ if (isCeilingGridHit(event)) return
1145
1189
  if (boxSelectHandled) return
1190
+ if (claimedByNearer(event)) return
1146
1191
 
1147
1192
  const interaction = getPaintInteraction(event)
1148
1193
  if (!interaction) return
1149
1194
 
1150
- event.stopPropagation()
1195
+ claim(event)
1151
1196
 
1152
1197
  if (!interaction.apply) {
1153
1198
  return
1154
1199
  }
1155
1200
 
1201
+ // Picking switches sub-mode synchronously and replays the hover, replacing
1202
+ // the event claim. Consume this click before a farther hit can paint.
1203
+ if (isPaintPicking()) event.stopPropagation()
1156
1204
  interaction.apply()
1157
1205
  sfxEmitter.emit('sfx:paint-apply')
1158
1206
  if (activePreview?.key === interaction.key) {
@@ -1202,11 +1250,22 @@ export const SelectionManager = () => {
1202
1250
  const unsubscribePaintScope = useEditor.subscribe((state, prev) => {
1203
1251
  if (state.paintScope === prev.paintScope || !lastEnterEvent) return
1204
1252
  clearActivePreview()
1253
+ claimedEvent = null
1254
+ onEnter(lastEnterEvent)
1255
+ })
1256
+ const releasePickHold = bindPaintPickHold(window)
1257
+ // So does switching the sub-mode (the eyedropper held on Alt, a mode icon).
1258
+ const unsubscribePaintMode = usePaintRegionMode.subscribe((state, prev) => {
1259
+ if (state.mode === prev.mode || !lastEnterEvent) return
1260
+ clearActivePreview()
1261
+ claimedEvent = null
1205
1262
  onEnter(lastEnterEvent)
1206
1263
  })
1207
1264
 
1208
1265
  return () => {
1209
1266
  unsubscribePaintScope()
1267
+ unsubscribePaintMode()
1268
+ releasePickHold()
1210
1269
  for (const type of subscribedKinds) {
1211
1270
  emitter.off(`${type}:enter` as any, onEnter as any)
1212
1271
  emitter.off(`${type}:move` as any, onEnter as any)
@@ -1215,6 +1274,7 @@ export const SelectionManager = () => {
1215
1274
  }
1216
1275
  clearActivePreview()
1217
1276
  useViewer.setState({ hoveredId: null })
1277
+ usePaintOutline.setState({ surfaces: null })
1218
1278
  setHoverHighlightMode('default')
1219
1279
  useEditor.getState().setPaintHover(null)
1220
1280
  }
@@ -1260,9 +1320,20 @@ export const SelectionManager = () => {
1260
1320
  if (movingNode || isCurveReshape) return
1261
1321
 
1262
1322
  const onPointerDown = (event: NodeEvent) => {
1323
+ if (isCeilingGridHit(event)) return
1263
1324
  if (!selectionEnabled(useInteractionScope.getState().scope)) return
1325
+ if (useEditor.getState().phase === 'site') return
1264
1326
  const pointer = pointerEventFromNodeEvent(event)
1265
- if (pointer.button !== 0) return
1327
+ if (pointer.button !== 0 || pointer.altKey) return
1328
+ if (
1329
+ roomPickingEnabled() &&
1330
+ shouldInterceptRoom(
1331
+ roomForEvent(event),
1332
+ selectionModifiersFromEvent(pointer),
1333
+ event.node.id,
1334
+ )
1335
+ )
1336
+ return
1266
1337
  const handleOwnsPointer = pointerEventHitsEditorHandle(event.nativeEvent)
1267
1338
 
1268
1339
  // Plain press on a transformable member of a multi-selection arms the
@@ -1577,6 +1648,7 @@ export const SelectionManager = () => {
1577
1648
  if (movingNode || isCurveReshape) return
1578
1649
 
1579
1650
  const onClick = (event: NodeEvent) => {
1651
+ if (isCeilingGridHit(event)) return
1580
1652
  // Skip if box-select just completed (drag ended over a node)
1581
1653
  if (boxSelectHandled) return
1582
1654
 
@@ -1588,8 +1660,11 @@ export const SelectionManager = () => {
1588
1660
  // body click so only the reshape tool handles the release. (Scoped to
1589
1661
  // `endpoint`: hole-edit relies on node clicks to exit, just below.)
1590
1662
  const activeScope = useInteractionScope.getState().scope
1591
- if (activeScope.kind === 'mesh-editing') return
1592
- if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return
1663
+ if (
1664
+ !selectionEnabled(activeScope) &&
1665
+ !(activeScope.kind === 'reshaping' && activeScope.reshape === 'hole')
1666
+ )
1667
+ return
1593
1668
 
1594
1669
  if (dispatchSceneAction(event.node, getEventObject(event))) {
1595
1670
  event.stopPropagation()
@@ -1606,21 +1681,14 @@ export const SelectionManager = () => {
1606
1681
  selectedIds: useViewer.getState().selection.selectedIds,
1607
1682
  })
1608
1683
 
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
1684
  // Unit focus turns clicks inside a zone into the paint gesture:
1619
1685
  // membership toggles, the zone stays unselected, focus stays.
1620
1686
  const focusedUnitId = useViewer.getState().focusedUnitId
1621
1687
  if (focusedUnitId) {
1622
1688
  const zone =
1623
- node.type === 'zone' ? node : zoneAtWorldPoint(event.position[0], event.position[2])
1689
+ node.type === 'zone'
1690
+ ? node
1691
+ : zoneAtWorldPoint(event.position[0], event.position[2], event.position[1])
1624
1692
  if (zone) {
1625
1693
  event.stopPropagation()
1626
1694
  clickHandledRef.current = true
@@ -1632,35 +1700,42 @@ export const SelectionManager = () => {
1632
1700
  }
1633
1701
  }
1634
1702
 
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
- }
1703
+ // From site the house is one thing: a hit anywhere on it picks its
1704
+ // building and goes inside, where the next click picks a room or element.
1705
+ if (useEditor.getState().phase === 'site') {
1706
+ if (!enterBuildingFromCanvas(node)) return
1707
+ event.stopPropagation()
1708
+ clickHandledRef.current = true
1709
+ setTimeout(() => {
1710
+ clickHandledRef.current = false
1711
+ }, 50)
1712
+ return
1713
+ }
1649
1714
 
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
- }
1715
+ if (isNodeInCurrentLevel(node) && roomPickingEnabled()) {
1716
+ const room = roomForEvent(event)
1717
+ if (
1718
+ room &&
1719
+ shouldInterceptRoom(
1720
+ room,
1721
+ selectionModifiersFromEvent(event.nativeEvent, modifierKeysRef.current),
1722
+ node.id,
1723
+ )
1724
+ ) {
1725
+ event.stopPropagation()
1726
+ clickHandledRef.current = true
1727
+ setTimeout(() => {
1728
+ clickHandledRef.current = false
1729
+ }, 50)
1730
+ selectRoom(room)
1731
+ return
1659
1732
  }
1733
+ useEditor.getState().setHoveredRoom(null)
1660
1734
  }
1661
1735
 
1662
- const activeStrategy = SELECTION_STRATEGIES[currentPhase]
1663
- if (activeStrategy?.isValid(node)) {
1736
+ const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
1737
+ const activeStrategy = SELECTION_STRATEGIES.building
1738
+ if (activeStrategy.isValid(node)) {
1664
1739
  event.stopPropagation()
1665
1740
  clickHandledRef.current = true
1666
1741
  // Reset the handled flag after a short delay so the grid:click that the
@@ -1673,13 +1748,22 @@ export const SelectionManager = () => {
1673
1748
  clickHandledRef.current = false
1674
1749
  }, 50)
1675
1750
 
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
1751
+ // Room first: a raised or sunken room's plate and a mezzanine's plate
1752
+ // belong to their room, never selected on their own. Past the room (the
1753
+ // drill click, or Alt) a raised or sunken room continues to the
1754
+ // footprint floor it stands on, like a ground-level room; a mezzanine
1755
+ // stays on its mezzanine.
1756
+ let footprint: SlabNode | null = null
1757
+ if (roomPickingEnabled() && isRoomOwnedPlate(node)) {
1758
+ footprint = roomOwnedPlateDrillTarget(useScene.getState().nodes, node as SlabNode)
1759
+ if (!footprint) {
1760
+ const hit = roomForEvent(event)
1761
+ if (hit) selectRoom(hit)
1762
+ return
1681
1763
  }
1682
1764
  }
1765
+
1766
+ let nodeToSelect: AnyNode = footprint ?? node
1683
1767
  nodeToSelect = resolveCanvasSelectionNode({
1684
1768
  node: nodeToSelect,
1685
1769
  nodes: useScene.getState().nodes,
@@ -1701,16 +1785,21 @@ export const SelectionManager = () => {
1701
1785
  // Move button. The first (selecting) click can't hit this because the
1702
1786
  // node isn't yet in `selectedIdsBeforeRouting`.
1703
1787
  const nativeEvent = event.nativeEvent
1704
- const hasModifier = nativeEvent.shiftKey || isCommandModifier(nativeEvent)
1788
+ const hasModifier =
1789
+ nativeEvent.altKey || nativeEvent.shiftKey || isCommandModifier(nativeEvent)
1705
1790
  const isAlreadySole =
1706
1791
  selectedIdsBeforeRouting.length === 1 && selectedIdsBeforeRouting[0] === nodeToSelect.id
1707
1792
  if (
1708
1793
  useEditor.getState().mode !== 'delete' &&
1709
1794
  !hasModifier &&
1710
1795
  isAlreadySole &&
1796
+ !expandSessionSelectionForNode(nodeToSelect.id) &&
1711
1797
  !getMovingNode() &&
1712
1798
  canDirectMoveNode(nodeToSelect)
1713
1799
  ) {
1800
+ // The move tool mounts before the browser's click that ends this
1801
+ // gesture, which would otherwise commit the node where it stands.
1802
+ swallowNextClick()
1714
1803
  sfxEmitter.emit('sfx:item-pick')
1715
1804
  const moveTarget = resolveDirectManipulationNode(nodeToSelect, useScene.getState().nodes)
1716
1805
  useEditor.getState().setMovingNode(moveTarget as never)
@@ -1732,7 +1821,7 @@ export const SelectionManager = () => {
1732
1821
  // `capabilities.paint` route through this entry — wall,
1733
1822
  // chimney, dormer use it today. The legacy stair / roof /
1734
1823
  // single-surface arms below stay until they migrate too.
1735
- if (nodeToSelect.type === node.type) {
1824
+ if (!footprint && nodeToSelect.type === node.type) {
1736
1825
  const paintCap = nodeRegistry.get(node.type)?.capabilities?.paint
1737
1826
  if (paintCap) {
1738
1827
  const materialIndex = getIntersectionMaterialIndex(
@@ -1829,27 +1918,23 @@ export const SelectionManager = () => {
1829
1918
  const onGridClick = (event: GridEvent) => {
1830
1919
  if (clickHandledRef.current) return
1831
1920
  if (boxSelectHandled) return
1832
- if (useInteractionScope.getState().scope.kind === 'mesh-editing') return
1921
+ if (!selectionEnabled(useInteractionScope.getState().scope)) return
1833
1922
  const nativeEvent = event.nativeEvent
1834
1923
  if (nativeEvent?.metaKey || nativeEvent?.ctrlKey || nativeEvent?.shiftKey) return
1835
1924
  // Unit focus: a ground click inside a zone paints it; elsewhere it
1836
1925
  // leaves focus and the unit selection alone.
1837
1926
  const focusedUnitId = useViewer.getState().focusedUnitId
1838
1927
  if (focusedUnitId) {
1839
- const zone = zoneAtLevelPoint(event.localPosition[0], event.localPosition[2])
1928
+ const zone = zoneAtLevelPoint(
1929
+ event.localPosition[0],
1930
+ event.localPosition[2],
1931
+ event.localPosition[1],
1932
+ )
1840
1933
  if (zone) paintZoneMembership(focusedUnitId, zone.id)
1841
1934
  return
1842
1935
  }
1843
- const { phase, structureLayer } = useEditor.getState()
1844
- const activeStrategy = SELECTION_STRATEGIES[phase]
1845
- if (activeStrategy) activeStrategy.handleDeselect()
1936
+ SELECTION_STRATEGIES[useEditor.getState().phase].handleDeselect()
1846
1937
  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
1938
  }
1854
1939
  emitter.on('grid:click', onGridClick)
1855
1940
 
@@ -1861,7 +1946,7 @@ export const SelectionManager = () => {
1861
1946
  }
1862
1947
  }, [isCurveReshape, mode, movingNode, registryVersion])
1863
1948
 
1864
- // Global double-click handler for auto-switching phases and cross-phase hover
1949
+ // Hover and double-click
1865
1950
  useEffect(() => {
1866
1951
  // re-subscribe when plugin kinds register after mount (async plugin load)
1867
1952
  void registryVersion
@@ -1869,96 +1954,94 @@ export const SelectionManager = () => {
1869
1954
  if (movingNode || isCurveReshape) return
1870
1955
 
1871
1956
  const onEnter = (event: NodeEvent) => {
1872
- if (useInteractionScope.getState().scope.kind === 'mesh-editing') return
1957
+ if (isCeilingGridHit(event)) return
1958
+ if (!selectionEnabled(useInteractionScope.getState().scope)) return
1873
1959
  // A host-driven drag (handle resize/rotate, box-select) sets
1874
1960
  // `inputDragging`. useNodeEvents still emits hover events during it so
1875
1961
  // surface move tools keep tracking — but the select-hover outline must
1876
1962
  // stay put, so don't repaint under the cursor mid-drag.
1877
1963
  if (useViewer.getState().inputDragging) return
1964
+ // An editor handle anywhere along the ray owns the pointer — it takes the
1965
+ // press (handles draw on top of walls), so it takes the hover too: no
1966
+ // stopPropagation, so the handle behind the wall still hears its enter.
1967
+ if (pointerEventHitsEditorHandle(event.nativeEvent)) {
1968
+ clearSelectHover(event)
1969
+ return
1970
+ }
1878
1971
  const node = resolveCanvasSelectionNode({
1879
1972
  node: resolveSelectModeNodeTarget(event),
1880
1973
  nodes: useScene.getState().nodes,
1881
1974
  selectedIds: useViewer.getState().selection.selectedIds,
1882
1975
  })
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
- }
1976
+ // From site the click picks the whole building, so the hover shows it.
1977
+ if (useEditor.getState().phase === 'site') {
1978
+ const buildingId = resolveCanvasBuildingId(node, useScene.getState().nodes)
1979
+ if (!buildingId) return
1980
+ event.stopPropagation()
1981
+ useViewer.getState().setHoveredId(buildingId as AnyNodeId)
1982
+ return
1897
1983
  }
1898
1984
 
1899
- // Check level constraint for interior nodes
1900
- if (currentPhase === 'structure' || currentPhase === 'furnish') {
1901
- if (!isNodeInCurrentLevel(node)) return
1902
- }
1985
+ if (node.type === 'building' || node.type === 'site') return
1986
+ // Rooms hover through their walls and floors; a zone's own volume never does.
1987
+ if (node.type === 'zone') return
1988
+ if (!isNodeInCurrentLevel(node)) return
1903
1989
 
1904
1990
  event.stopPropagation()
1905
- useViewer.setState({ hoveredId: node.id })
1991
+ if (
1992
+ roomPickingEnabled() &&
1993
+ hoverRoomFromHit(
1994
+ roomForEvent(event),
1995
+ selectionModifiersFromEvent(event.nativeEvent, modifierKeysRef.current),
1996
+ node.id,
1997
+ )
1998
+ )
1999
+ return
2000
+ useViewer.getState().setHoveredId(node.id)
1906
2001
  }
1907
2002
 
1908
- const onLeave = (event: NodeEvent) => {
2003
+ const clearSelectHover = (event: NodeEvent) => {
2004
+ useEditor.getState().setHoveredRoom(null)
1909
2005
  if (useViewer.getState().inputDragging) return
1910
- const nodeId = resolveCanvasSelectionNode({
2006
+ const nodes = useScene.getState().nodes
2007
+ const node = resolveCanvasSelectionNode({
1911
2008
  node: resolveSelectModeNodeTarget(event),
1912
- nodes: useScene.getState().nodes,
2009
+ nodes,
1913
2010
  selectedIds: useViewer.getState().selection.selectedIds,
1914
- })?.id
2011
+ })
2012
+ const nodeId =
2013
+ useEditor.getState().phase === 'site' ? resolveCanvasBuildingId(node, nodes) : node.id
1915
2014
  if (nodeId && useViewer.getState().hoveredId === nodeId) {
1916
2015
  useViewer.setState({ hoveredId: null })
1917
2016
  }
1918
2017
  }
1919
2018
 
2019
+ const onLeave = (event: NodeEvent) => {
2020
+ if (isCeilingGridHit(event)) return
2021
+ clearSelectHover(event)
2022
+ }
2023
+
1920
2024
  const onDoubleClick = (event: NodeEvent) => {
2025
+ if (isCeilingGridHit(event)) return
1921
2026
  if (useInteractionScope.getState().scope.kind === 'mesh-editing') return
1922
- let node = resolveCanvasSelectionNode({
1923
- node: resolveSelectModeNodeTarget(event),
2027
+ const node = resolveCanvasSelectionNode({
2028
+ node: resolveSelectModeNodeTarget(event, true),
1924
2029
  nodes: useScene.getState().nodes,
1925
2030
  selectedIds: useViewer.getState().selection.selectedIds,
1926
2031
  })
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
- }
2032
+ if (useEditor.getState().phase !== 'building' || !isNodeInCurrentLevel(node)) return
1952
2033
 
1953
2034
  // While a unit is focused a double-click inside a zone selects that
1954
2035
  // zone (focus stays); the two clicks before it cancel each other's paint.
1955
2036
  if (useViewer.getState().focusedUnitId) {
1956
2037
  const zone =
1957
- node.type === 'zone' ? node : zoneAtWorldPoint(event.position[0], event.position[2])
2038
+ node.type === 'zone'
2039
+ ? node
2040
+ : zoneAtWorldPoint(event.position[0], event.position[2], event.position[1])
1958
2041
  if (zone) {
1959
2042
  event.stopPropagation()
1960
2043
  cancelPendingZonePaint(zone.id)
1961
- SELECTION_STRATEGIES.structure?.handleSelect(
2044
+ SELECTION_STRATEGIES.building.handleSelect(
1962
2045
  zone,
1963
2046
  event.nativeEvent,
1964
2047
  modifierKeysRef.current,
@@ -1968,34 +2051,16 @@ export const SelectionManager = () => {
1968
2051
  }
1969
2052
  }
1970
2053
 
1971
- if (node.type === 'zone') {
1972
- return
1973
- }
1974
-
1975
- if ((targetPhase && targetPhase !== useEditor.getState().phase) || forceSelect) {
2054
+ // A single click selects the whole roof or stair; the double-click dives
2055
+ // into the segment under the cursor.
2056
+ if (node.type === 'roof-segment' || node.type === 'stair-segment') {
1976
2057
  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
- }
2058
+ SELECTION_STRATEGIES.building.handleSelect(
2059
+ node,
2060
+ event.nativeEvent,
2061
+ modifierKeysRef.current,
2062
+ useViewer.getState().selection.selectedIds,
2063
+ )
1999
2064
  }
2000
2065
  }
2001
2066
 
@@ -2025,6 +2090,7 @@ export const SelectionManager = () => {
2025
2090
 
2026
2091
  subscribedKinds.forEach((type) => {
2027
2092
  emitter.on(`${type}:enter` as any, onEnter as any)
2093
+ emitter.on(`${type}:move` as any, onEnter as any)
2028
2094
  emitter.on(`${type}:leave` as any, onLeave as any)
2029
2095
  emitter.on(`${type}:double-click` as any, onDoubleClick as any)
2030
2096
  })
@@ -2032,6 +2098,7 @@ export const SelectionManager = () => {
2032
2098
  return () => {
2033
2099
  subscribedKinds.forEach((type) => {
2034
2100
  emitter.off(`${type}:enter` as any, onEnter as any)
2101
+ emitter.off(`${type}:move` as any, onEnter as any)
2035
2102
  emitter.off(`${type}:leave` as any, onLeave as any)
2036
2103
  emitter.off(`${type}:double-click` as any, onDoubleClick as any)
2037
2104
  })
@@ -2045,6 +2112,7 @@ export const SelectionManager = () => {
2045
2112
  if (mode !== 'delete') return
2046
2113
 
2047
2114
  const onClick = (event: NodeEvent) => {
2115
+ if (isCeilingGridHit(event)) return
2048
2116
  const node = event.node
2049
2117
  if (!isNodeInCurrentLevel(node)) return
2050
2118
 
@@ -2063,14 +2131,16 @@ export const SelectionManager = () => {
2063
2131
  }
2064
2132
 
2065
2133
  const onEnter = (event: NodeEvent) => {
2134
+ if (isCeilingGridHit(event)) return
2066
2135
  const node = event.node
2067
2136
  if (!isNodeInCurrentLevel(node)) return
2068
2137
  if (node.type === 'building' || node.type === 'site') return
2069
2138
  event.stopPropagation()
2070
- useViewer.setState({ hoveredId: node.id })
2139
+ useViewer.getState().setHoveredId(node.id)
2071
2140
  }
2072
2141
 
2073
2142
  const onLeave = (event: NodeEvent) => {
2143
+ if (isCeilingGridHit(event)) return
2074
2144
  const nodeId = event?.node?.id
2075
2145
  if (nodeId && useViewer.getState().hoveredId === nodeId) {
2076
2146
  useViewer.setState({ hoveredId: null })
@@ -2121,6 +2191,7 @@ export const SelectionManager = () => {
2121
2191
  <SelectionStateSync />
2122
2192
  <SelectionMaterialSync />
2123
2193
  <EditorOutlinerSync />
2194
+ <RoomHighlight3D />
2124
2195
  </>
2125
2196
  )
2126
2197
  }
@@ -2372,39 +2443,18 @@ const EditorOutlinerSync = () => {
2372
2443
  const registryVersion = useRegistryVersion()
2373
2444
  const outliner = useViewer((s) => s.outliner)
2374
2445
  const nodes = useScene((s) => s.nodes)
2446
+ const paintOutline = usePaintOutline((s) => s.surfaces)
2375
2447
 
2376
2448
  useEffect(() => {
2377
2449
  void geometryRevision
2378
2450
  void registryVersion
2379
2451
  let idsToHighlight: string[] = []
2380
2452
 
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
- }
2453
+ // 1. Site outlines the building; inside it, the selection.
2454
+ if (phase === 'site') {
2455
+ if (selection.buildingId) idsToHighlight = [selection.buildingId]
2456
+ } else {
2457
+ idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
2408
2458
  }
2409
2459
 
2410
2460
  // 2. Sync with the imperative outliner arrays (mutate in place to keep references)
@@ -2417,7 +2467,11 @@ const EditorOutlinerSync = () => {
2417
2467
  }
2418
2468
 
2419
2469
  outliner.hoveredObjects.length = 0
2420
- if (hoveredId) {
2470
+ if (paintOutline) {
2471
+ // Paint on a floor plate: exactly the surfaces the click changes.
2472
+ for (const mesh of paintSurfaceMeshes(paintOutline, (id) => sceneRegistry.nodes.get(id)))
2473
+ if (mesh.parent) outliner.hoveredObjects.push(mesh)
2474
+ } else if (hoveredId) {
2421
2475
  if (!nodes[hoveredId as AnyNodeId]) {
2422
2476
  useViewer.setState({ hoveredId: null })
2423
2477
  } else {
@@ -2440,6 +2494,7 @@ const EditorOutlinerSync = () => {
2440
2494
  hoveredId,
2441
2495
  outliner,
2442
2496
  nodes,
2497
+ paintOutline,
2443
2498
  ])
2444
2499
 
2445
2500
  return null