@pascal-app/editor 0.8.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -2,60 +2,89 @@ import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
4
  type BuildingNode,
5
- type CeilingNode,
6
- type ColumnNode,
5
+ createSceneApi,
7
6
  emitter,
8
- type FenceNode,
9
- getMaterialPresetByRef,
7
+ type GridEvent,
8
+ getEffectiveRoofSurfaceMaterial,
9
+ getEffectiveSegmentSurfaceMaterial,
10
+ getRoofSegmentSurfaceY,
11
+ getSelectableKinds,
10
12
  type ItemNode,
13
+ isRegistrySelectable,
11
14
  type NodeEvent,
15
+ nodeRegistry,
12
16
  type RoofEvent,
13
17
  type RoofNode,
14
18
  type RoofSegmentEvent,
19
+ type RoofSegmentNode,
15
20
  resolveLevelId,
16
- resolveMaterial,
17
- type SlabNode,
18
21
  type StairEvent,
19
- type StairNode,
20
22
  type StairSegmentEvent,
21
23
  type StairSurfaceMaterialRole,
22
24
  sceneRegistry,
25
+ useLiveNodeOverrides,
23
26
  useScene,
24
- type WallEvent,
25
- type WallNode,
26
- type WallSurfaceSide,
27
27
  } from '@pascal-app/core'
28
28
 
29
29
  import {
30
- applyMaterialPresetToMaterials,
31
30
  createMaterial,
32
31
  createMaterialFromPresetRef,
33
32
  getRoofMaterialArray,
34
- getStairBodyMaterials,
35
- getStairRailingMaterial,
36
- getVisibleWallMaterials,
37
33
  useViewer,
38
34
  } from '@pascal-app/viewer'
35
+ import { useThree } from '@react-three/fiber'
39
36
  import { useCallback, useEffect, useRef } from 'react'
40
- import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
37
+ import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
38
+ import {
39
+ canDirectMoveNode,
40
+ canDirectRotateNode,
41
+ resolveDirectRotationDragDelta,
42
+ resolveDirectRotationPatch,
43
+ } from '../../lib/direct-manipulation'
44
+ import { createEditorApi } from '../../lib/editor-api'
41
45
  import {
42
46
  type ActivePaintMaterial,
47
+ buildRoofSegmentSurfaceMaterialPatch,
43
48
  buildRoofSurfaceMaterialPatch,
44
- buildSingleSurfaceMaterialPatch,
45
- buildStairSurfaceMaterialPatch,
46
- buildWallSurfaceMaterialPatch,
47
49
  hasActivePaintMaterial,
48
50
  resolveActivePaintMaterialFromSelection,
49
51
  } from '../../lib/material-paint'
50
- import { sfxEmitter } from '../../lib/sfx-bus'
51
- import useEditor, {
52
- type MaterialTargetRole,
53
- type Phase,
54
- type StructureLayer,
55
- } from './../../store/use-editor'
56
- import { boxSelectHandled } from '../tools/select/box-select-tool'
52
+ import {
53
+ availablePaintScopes,
54
+ commitPaintScopeFanout,
55
+ nodeSlotRoles,
56
+ type PaintHoverInfo,
57
+ resolvePaintScopeTargets,
58
+ slotDisplayLabel,
59
+ type WallPaintHit,
60
+ } from '../../lib/paint-scope'
61
+ import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
62
+ import {
63
+ resolveCanvasSelectionNode,
64
+ resolveNodeSelectionTarget,
65
+ resolveSelectedIdsForNodeClick,
66
+ type SelectionModifierKeys,
67
+ selectionModifiersFromEvent,
68
+ shouldPreserveSelectedRoofHostTarget,
69
+ } from '../../lib/selection-routing'
70
+ import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
71
+ import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
72
+ import useEditor, { type MaterialTargetRole } from './../../store/use-editor'
73
+ import useInteractionScope, {
74
+ getEditingHole,
75
+ getMovingNode,
76
+ useIsCurveReshape,
77
+ useMovingNode,
78
+ } from '../../store/use-interaction-scope'
79
+ import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state'
80
+ import { armGroupMove3d } from './group-move-3d'
81
+ import { classifyParticipant } from './group-transform-shared'
82
+ import { swallowNextClick } from './node-arrow-handles'
83
+ import { setEditorThreeContext } from './three-context-bridge'
57
84
 
58
85
  const isNodeInCurrentLevel = (node: AnyNode): boolean => {
86
+ // Elevators are building-scoped, so they stay selectable across level filters.
87
+ if (node.type === 'elevator') return true
59
88
  const currentLevelId = useViewer.getState().selection.levelId
60
89
  if (!currentLevelId) return true // No level selected, allow all
61
90
  const nodeLevelId = resolveLevelId(node, useScene.getState().nodes)
@@ -68,6 +97,7 @@ type SelectableNodeType =
68
97
  | 'item'
69
98
  | 'column'
70
99
  | 'building'
100
+ | 'elevator'
71
101
  | 'zone'
72
102
  | 'slab'
73
103
  | 'ceiling'
@@ -79,11 +109,6 @@ type SelectableNodeType =
79
109
  | 'window'
80
110
  | 'door'
81
111
 
82
- type ModifierKeys = {
83
- meta: boolean
84
- ctrl: boolean
85
- }
86
-
87
112
  type PaintPreviewCleanup = () => void
88
113
 
89
114
  type PaintInteraction = {
@@ -92,18 +117,40 @@ type PaintInteraction = {
92
117
  hoverMode: HoverHighlightMode
93
118
  hoveredId: AnyNodeId
94
119
  preview: (() => PaintPreviewCleanup | null) | null
120
+ // What the paint HUD chip should show for this hover (scopes + labels), or
121
+ // null when the surface isn't paintable.
122
+ paintHover: PaintHoverInfo | null
95
123
  }
96
124
 
97
125
  interface SelectionStrategy {
98
126
  types: SelectableNodeType[]
99
- handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
127
+ handleSelect: (
128
+ node: AnyNode,
129
+ nativeEvent?: any,
130
+ modifierKeys?: SelectionModifierKeys,
131
+ baseSelectedIds?: readonly string[],
132
+ ) => void
100
133
  handleDeselect: () => void
101
134
  isValid: (node: AnyNode) => boolean
102
135
  }
103
136
 
104
- type SelectionTarget = {
105
- phase: Phase
106
- structureLayer?: StructureLayer
137
+ const DIRECT_DRAG_THRESHOLD_PX = 4
138
+ const DIRECT_ROTATE_EPSILON = 1e-6
139
+ const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
140
+
141
+ function pointerEventFromNodeEvent(event: NodeEvent): PointerEvent {
142
+ const threeEvent = event.nativeEvent as unknown as PointerEvent & {
143
+ nativeEvent?: PointerEvent
144
+ }
145
+ return threeEvent.nativeEvent ?? threeEvent
146
+ }
147
+
148
+ function isCommandModifier(event: Pick<PointerEvent, 'metaKey' | 'ctrlKey'>): boolean {
149
+ return event.metaKey || event.ctrlKey
150
+ }
151
+
152
+ function pointerDistancePx(event: PointerEvent, startX: number, startY: number): number {
153
+ return Math.hypot(event.clientX - startX, event.clientY - startY)
107
154
  }
108
155
 
109
156
  export const resolveBuildingId = (
@@ -118,33 +165,6 @@ export const resolveBuildingId = (
118
165
  return null
119
166
  }
120
167
 
121
- function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
122
- const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
123
- if (materialIndex === 1) return 'interior'
124
- if (materialIndex === 2) return 'exterior'
125
-
126
- const normalZ = event.normal?.[2]
127
- const localZ = event.localPosition[2]
128
- const thickness = event.node.thickness ?? 0.1
129
-
130
- if (
131
- normalZ === undefined ||
132
- Math.abs(normalZ) < 0.65 ||
133
- Math.abs(localZ) < Math.max(thickness * 0.2, 0.01)
134
- ) {
135
- return null
136
- }
137
-
138
- const hitFace = localZ >= 0 ? 'front' : 'back'
139
- const semantic = hitFace === 'front' ? event.node.frontSide : event.node.backSide
140
-
141
- if (semantic === 'interior' || semantic === 'exterior') {
142
- return semantic
143
- }
144
-
145
- return hitFace === 'front' ? 'interior' : 'exterior'
146
- }
147
-
148
168
  function resolveStairMaterialTarget(
149
169
  event: StairEvent | StairSegmentEvent,
150
170
  ): StairSurfaceMaterialRole | null {
@@ -200,6 +220,26 @@ function getEventObject(event: NodeEvent): Object3D {
200
220
  return eventWithObject.object ?? event.nativeEvent.object
201
221
  }
202
222
 
223
+ /**
224
+ * Registry-driven in-scene click actions (`capabilities.sceneAction`): walk
225
+ * the pointer hit's object chain, ask the clicked kind to resolve an action
226
+ * target from each object's userData, and run it. Returns `true` when the
227
+ * kind consumed the click (no selection change should happen).
228
+ */
229
+ function dispatchSceneAction(node: AnyNode, object: Object3D | null): boolean {
230
+ const sceneAction = nodeRegistry.get(node.type)?.capabilities?.sceneAction
231
+ if (!sceneAction) return false
232
+ let current: Object3D | null = object
233
+ while (current) {
234
+ const target = sceneAction.resolveTarget(current)
235
+ if (target !== null) {
236
+ return sceneAction.activate(node, target, createSceneApi(useScene))
237
+ }
238
+ current = current.parent
239
+ }
240
+ return false
241
+ }
242
+
203
243
  function getIntersectionMaterialIndex(
204
244
  object: Object3D,
205
245
  faceIndex: number | undefined,
@@ -226,6 +266,107 @@ function getRegisteredMesh(nodeId: string): Mesh | null {
226
266
  return object && (object as Mesh).isMesh ? (object as Mesh) : null
227
267
  }
228
268
 
269
+ // Every distinct slot role on a node, read off the registered mesh subtree's
270
+ // `userData.slotId` tags (a tag may be a single role or an array, one per
271
+ // material group). The mesh-derived fallback behind `nodeSlotRoles` for kinds
272
+ // whose slots come from a GLB (items) rather than a `capabilities.slots`
273
+ // declaration; returns `[]` when the subtree isn't mounted.
274
+ function meshSlotRoles(node: AnyNode): string[] {
275
+ const root = getRegisteredNodeObject(node.id)
276
+ if (!root) return []
277
+ const roles = new Set<string>()
278
+ root.traverse((object) => {
279
+ const mesh = object as Mesh
280
+ if (!mesh.isMesh) return
281
+ const tag = (mesh.userData as { slotId?: string | null | (string | null)[] }).slotId
282
+ if (Array.isArray(tag)) {
283
+ for (const entry of tag) if (typeof entry === 'string') roles.add(entry)
284
+ } else if (typeof tag === 'string') {
285
+ roles.add(tag)
286
+ }
287
+ })
288
+ return [...roles]
289
+ }
290
+
291
+ const roofSelectionWorldPoint = new Vector3()
292
+ const wallPaintWorldPoint = new Vector3()
293
+
294
+ function resolveWallPaintHit(event: NodeEvent): WallPaintHit | undefined {
295
+ const wall = event.node
296
+ if (wall.type !== 'wall') return undefined
297
+ const root = getRegisteredNodeObject(wall.id)
298
+ if (!root) return undefined
299
+
300
+ root.updateWorldMatrix(true, false)
301
+ wallPaintWorldPoint.set(...event.position)
302
+ const local = root.worldToLocal(wallPaintWorldPoint)
303
+ const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
304
+ const cos = Math.cos(angle)
305
+ const sin = Math.sin(angle)
306
+
307
+ return {
308
+ face: local.z >= 0 ? 'front' : 'back',
309
+ point: [
310
+ wall.start[0] + local.x * cos - local.z * sin,
311
+ wall.start[1] + local.x * sin + local.z * cos,
312
+ ],
313
+ }
314
+ }
315
+
316
+ function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
317
+ const roof = event.node
318
+ if (roof.type !== 'roof') return null
319
+
320
+ roofSelectionWorldPoint.set(...event.position)
321
+ const nodes = useScene.getState().nodes
322
+ let firstSegment: RoofSegmentNode | null = null
323
+ let bestSegment: { node: RoofSegmentNode; score: number } | null = null
324
+
325
+ for (const childId of roof.children ?? []) {
326
+ const segment = nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
327
+ if (segment?.type !== 'roof-segment') continue
328
+
329
+ const object = getRegisteredNodeObject(segment.id)
330
+ if (!object) continue
331
+
332
+ if (!firstSegment) firstSegment = segment
333
+
334
+ object.updateWorldMatrix(true, false)
335
+ const local = object.worldToLocal(roofSelectionWorldPoint.clone())
336
+ const overhang = segment.overhang ?? 0
337
+ const halfWidth = segment.width / 2 + overhang
338
+ const halfDepth = segment.depth / 2 + overhang
339
+
340
+ if (Math.abs(local.x) > halfWidth || Math.abs(local.z) > halfDepth) {
341
+ continue
342
+ }
343
+
344
+ const score = Math.abs(local.y - getRoofSegmentSurfaceY(segment, local.x, local.z))
345
+ if (!bestSegment || score < bestSegment.score) {
346
+ bestSegment = { node: segment, score }
347
+ }
348
+ }
349
+
350
+ return bestSegment?.node ?? firstSegment
351
+ }
352
+
353
+ function resolveSelectModeNodeTarget(event: NodeEvent): AnyNode {
354
+ if (event.node.type === 'roof') {
355
+ if (
356
+ shouldPreserveSelectedRoofHostTarget({
357
+ node: event.node,
358
+ selectedIds: useViewer.getState().selection.selectedIds,
359
+ armedRoofId: useEditor.getState().roofHostDragArmedId,
360
+ })
361
+ ) {
362
+ return event.node
363
+ }
364
+ return resolveRoofSegmentSelectionTarget(event) ?? event.node
365
+ }
366
+
367
+ return event.node
368
+ }
369
+
229
370
  function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
230
371
  const previousMaterial = mesh.material
231
372
  mesh.material = material
@@ -242,34 +383,6 @@ function previewCursor(cursor: string): PaintPreviewCleanup {
242
383
  }
243
384
  }
244
385
 
245
- function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
246
- if (material.materialPreset) {
247
- return createMaterialFromPresetRef(material.materialPreset)
248
- }
249
-
250
- if (material.material) {
251
- return createMaterial(material.material)
252
- }
253
-
254
- return null
255
- }
256
-
257
- function applyWallPaintPreview(
258
- node: WallNode,
259
- role: WallSurfaceSide,
260
- material: ActivePaintMaterial,
261
- ): PaintPreviewCleanup | null {
262
- const mesh = getRegisteredMesh(node.id)
263
- if (!mesh) return null
264
-
265
- const previewNode = {
266
- ...node,
267
- ...buildWallSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
268
- }
269
-
270
- return previewMeshMaterial(mesh, getVisibleWallMaterials(previewNode))
271
- }
272
-
273
386
  function applyRoofPaintPreview(
274
387
  node: RoofNode,
275
388
  role: 'top' | 'edge' | 'wall',
@@ -283,149 +396,62 @@ function applyRoofPaintPreview(
283
396
  ...node,
284
397
  ...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
285
398
  }
286
- const previewMaterial = getRoofMaterialArray(previewNode)
399
+ const previewMaterial = getRoofMaterialArray(
400
+ previewNode,
401
+ useViewer.getState().shading,
402
+ useViewer.getState().textures,
403
+ useViewer.getState().colorPreset,
404
+ useViewer.getState().sceneTheme,
405
+ )
287
406
  if (!previewMaterial) return null
288
407
 
289
408
  return previewMeshMaterial(mesh, previewMaterial)
290
409
  }
291
410
 
292
- function applyStairPaintPreview(
293
- node: StairNode,
294
- role: StairSurfaceMaterialRole,
411
+ function applyRoofSegmentPaintPreview(
412
+ node: RoofSegmentNode,
413
+ parent: RoofNode | null,
414
+ role: 'top' | 'edge' | 'wall',
295
415
  material: ActivePaintMaterial,
296
416
  ): PaintPreviewCleanup | null {
297
- const root = getRegisteredNodeObject(node.id)
298
- if (!root) return null
417
+ const mesh = getRegisteredMesh(node.id)
418
+ if (!mesh) return null
299
419
 
300
- const previewNode = {
420
+ // Synthesise the segment node as if the paint had committed, then build
421
+ // the same 4-slot array the renderer would. Mirrors getRoofMaterialArray
422
+ // layout (slot 0 ← edge, 1 ← wall, 2 ← wall, 3 ← top) so the preview
423
+ // material lands on the matching CSG groups.
424
+ const previewNode: RoofSegmentNode = {
301
425
  ...node,
302
- ...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
426
+ ...buildRoofSegmentSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
303
427
  }
304
- const bodyMaterials = getStairBodyMaterials(previewNode)
305
- const railingMaterial = getStairRailingMaterial(previewNode)
306
- const restores: PaintPreviewCleanup[] = []
307
-
308
- root.traverse((object) => {
309
- if (!(object as Mesh).isMesh) return
310
- const mesh = object as Mesh
311
- if (mesh.name.startsWith('stair-railing')) {
312
- restores.push(previewMeshMaterial(mesh, railingMaterial))
313
- return
314
- }
315
- if (Array.isArray(mesh.material) && mesh.material.length === 2) {
316
- restores.push(previewMeshMaterial(mesh, bodyMaterials))
317
- return
318
- }
319
- if (mesh.name === 'merged-stair') {
320
- restores.push(previewMeshMaterial(mesh, bodyMaterials))
321
- return
322
- }
323
- if (mesh.name.startsWith('stair-side')) {
324
- restores.push(previewMeshMaterial(mesh, bodyMaterials[1]))
325
- }
326
- })
327
-
328
- if (restores.length === 0) return null
329
-
330
- return () => {
331
- for (let index = restores.length - 1; index >= 0; index -= 1) {
332
- restores[index]?.()
428
+ const resolveSlot = (r: 'top' | 'edge' | 'wall'): Material | null => {
429
+ const parentSpec = parent ? getEffectiveRoofSurfaceMaterial(parent, r) : undefined
430
+ const spec = getEffectiveSegmentSurfaceMaterial(previewNode, r, parentSpec)
431
+ if (typeof spec.materialPreset === 'string') {
432
+ const resolved = createMaterialFromPresetRef(spec.materialPreset)
433
+ if (resolved) return resolved
333
434
  }
435
+ if (spec.material !== undefined) return createMaterial(spec.material)
436
+ return null
334
437
  }
438
+ const edge = resolveSlot('edge')
439
+ const wall = resolveSlot('wall')
440
+ const top = resolveSlot('top')
441
+ if (!(edge || wall || top)) return null
442
+ const fallback = parent ? getRoofMaterialArray(parent) : null
443
+ const fb = (n: number) => fallback?.[n] ?? null
444
+ // Per-role only, then the parent's themed slot — matches the renderer so the
445
+ // preview never bleeds a painted surface onto the segment's other surfaces.
446
+ const arr: Material[] = [edge ?? fb(0)!, wall ?? fb(1)!, wall ?? fb(2)!, top ?? fb(3)!]
447
+ if (arr.some((m) => !m)) return null
448
+ return previewMeshMaterial(mesh, arr)
335
449
  }
336
450
 
337
- function applySingleSurfacePaintPreview(
338
- node: FenceNode | ColumnNode | SlabNode | CeilingNode,
339
- material: ActivePaintMaterial,
340
- ): PaintPreviewCleanup | null {
341
- if (node.type === 'ceiling') {
342
- const root = getRegisteredMesh(node.id)
343
- const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
344
- if (!(root && overlay)) return null
345
-
346
- const previewColor =
347
- getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
348
- resolveMaterial(material.material).color ??
349
- '#999999'
350
-
351
- const previousRootMaterial = root.material
352
- const previousOverlayMaterial = overlay.material
353
- const rootPreviewMaterial = Array.isArray(previousRootMaterial)
354
- ? previousRootMaterial.map((entry) => entry.clone())
355
- : previousRootMaterial.clone()
356
- const overlayPreviewMaterial = Array.isArray(previousOverlayMaterial)
357
- ? previousOverlayMaterial.map((entry) => entry.clone())
358
- : previousOverlayMaterial.clone()
359
-
360
- const applyColor = (input: Material | Material[]) => {
361
- const materials = Array.isArray(input) ? input : [input]
362
- for (const entry of materials) {
363
- const materialWithColor = entry as Material & { color?: Color; needsUpdate?: boolean }
364
- if (materialWithColor.color instanceof Color) {
365
- materialWithColor.color = new Color(previewColor)
366
- }
367
- materialWithColor.needsUpdate = true
368
- }
369
- }
370
-
371
- applyColor(rootPreviewMaterial)
372
- applyColor(overlayPreviewMaterial)
373
- root.material = rootPreviewMaterial
374
- overlay.material = overlayPreviewMaterial
375
-
376
- return () => {
377
- root.material = previousRootMaterial
378
- overlay.material = previousOverlayMaterial
379
- }
380
- }
381
-
382
- const registeredObject = getRegisteredNodeObject(node.id)
383
- const mesh =
384
- registeredObject && (registeredObject as Mesh).isMesh ? (registeredObject as Mesh) : null
385
-
386
- const previewMaterial = getSingleSurfacePreviewMaterial(material)
387
- if (!previewMaterial) return null
388
-
389
- if (node.type === 'column') {
390
- if (!registeredObject) return null
391
- const restores: PaintPreviewCleanup[] = []
392
-
393
- registeredObject.traverse((object) => {
394
- if (!(object as Mesh).isMesh) return
395
- restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
396
- })
397
-
398
- if (restores.length === 0) return null
399
- return () => {
400
- for (let index = restores.length - 1; index >= 0; index -= 1) {
401
- restores[index]?.()
402
- }
403
- }
404
- }
405
-
406
- if (!mesh) return null
407
-
408
- if (node.type === 'slab') {
409
- const slabMaterial = previewMaterial.clone()
410
- applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
411
- const previewMeshMaterialInput = slabMaterial as Material & {
412
- alphaMap?: unknown
413
- depthWrite?: boolean
414
- needsUpdate?: boolean
415
- opacity?: number
416
- side?: number
417
- transparent?: boolean
418
- }
419
- previewMeshMaterialInput.transparent = false
420
- previewMeshMaterialInput.opacity = 1
421
- previewMeshMaterialInput.alphaMap = null
422
- previewMeshMaterialInput.depthWrite = true
423
- previewMeshMaterialInput.needsUpdate = true
424
- return previewMeshMaterial(mesh, slabMaterial)
425
- }
426
-
427
- return previewMeshMaterial(mesh, previewMaterial)
428
- }
451
+ // Chimney + dormer paint dispatch lives on their NodeDefinition's
452
+ // `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/
453
+ // paint.ts). The generic registry-driven arm in this file consults
454
+ // those entries no per-kind helpers needed here.
429
455
 
430
456
  function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
431
457
  if (!role) {
@@ -450,10 +476,12 @@ const HIGHLIGHT_PROFILES = {
450
476
  emissiveIntensity: 0.46,
451
477
  },
452
478
  selection: {
479
+ // Keep the real material/texture readable: no albedo tint, just a gentle
480
+ // indigo emissive glow so it reads as selected.
453
481
  color: new Color('#818cf8'),
454
- blend: 0.32,
455
- emissiveBlend: 0.7,
456
- emissiveIntensity: 0.42,
482
+ blend: 0,
483
+ emissiveBlend: 0.4,
484
+ emissiveIntensity: 0.12,
457
485
  },
458
486
  } as const
459
487
 
@@ -478,8 +506,30 @@ function isHighlightableMesh(object: Object3D): object is Mesh {
478
506
  )
479
507
  }
480
508
 
509
+ const TEXTURE_MAP_KEYS = [
510
+ 'map',
511
+ 'normalMap',
512
+ 'roughnessMap',
513
+ 'metalnessMap',
514
+ 'aoMap',
515
+ 'emissiveMap',
516
+ 'bumpMap',
517
+ 'displacementMap',
518
+ 'alphaMap',
519
+ 'lightMap',
520
+ ] as const
521
+
481
522
  function createHighlightedMaterial(material: Material, kind: HighlightKind): Material {
482
523
  const highlightedMaterial = material.clone() as HighlightableMaterial
524
+ // `NodeMaterial.clone()` on the WebGPU backend drops the texture-map node
525
+ // assignments, so the clone renders flat. Re-attach the maps from the source
526
+ // material (they're shared by reference — same texture object) so the
527
+ // selected object keeps its texture under the highlight.
528
+ const src = material as unknown as Record<string, unknown>
529
+ const dst = highlightedMaterial as unknown as Record<string, unknown>
530
+ for (const key of TEXTURE_MAP_KEYS) {
531
+ if (src[key]) dst[key] = src[key]
532
+ }
483
533
  const profile = HIGHLIGHT_PROFILES[kind]
484
534
 
485
535
  if (highlightedMaterial.color instanceof Color) {
@@ -529,22 +579,17 @@ function disposeHighlightedMaterials(material: Material | Material[]) {
529
579
 
530
580
  const computeNextIds = (
531
581
  node: AnyNode,
532
- selectedIds: string[],
582
+ selectedIds: readonly string[],
533
583
  event?: any,
534
- modifierKeys?: ModifierKeys,
584
+ modifierKeys?: SelectionModifierKeys,
585
+ baseSelectedIds?: readonly string[],
535
586
  ): string[] => {
536
- const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta
537
- const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl
538
-
539
- if (isMeta || isCtrl) {
540
- if (selectedIds.includes(node.id)) {
541
- return selectedIds.filter((id) => id !== node.id)
542
- }
543
- return [...selectedIds, node.id]
544
- }
545
-
546
- // Not holding modifiers: select only this node
547
- return [node.id]
587
+ return resolveSelectedIdsForNodeClick({
588
+ baseSelectedIds,
589
+ currentSelectedIds: selectedIds,
590
+ modifierKeys: selectionModifiersFromEvent(event, modifierKeys),
591
+ nodeId: node.id,
592
+ })
548
593
  }
549
594
 
550
595
  const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
@@ -565,6 +610,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
565
610
  'fence',
566
611
  'item',
567
612
  'column',
613
+ 'elevator',
568
614
  'zone',
569
615
  'slab',
570
616
  'ceiling',
@@ -576,14 +622,21 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
576
622
  'window',
577
623
  'door',
578
624
  ],
579
- handleSelect: (node, nativeEvent, modifierKeys) => {
625
+ handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
580
626
  const { selection, setSelection } = useViewer.getState()
581
627
  const nodes = useScene.getState().nodes
582
- const nodeLevelId = resolveLevelId(node, nodes)
583
- const buildingId = resolveBuildingId(nodeLevelId, nodes)
628
+ const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes)
629
+ const buildingId =
630
+ node.type === 'elevator' &&
631
+ node.parentId &&
632
+ nodes[node.parentId as AnyNodeId]?.type === 'building'
633
+ ? node.parentId
634
+ : nodeLevelId
635
+ ? resolveBuildingId(nodeLevelId, nodes)
636
+ : null
584
637
 
585
638
  const updates: any = {}
586
- if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
639
+ if (nodeLevelId && nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
587
640
  updates.levelId = nodeLevelId
588
641
  }
589
642
  if (buildingId && buildingId !== selection.buildingId) {
@@ -596,7 +649,13 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
596
649
  // Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
597
650
  setSelection(updates)
598
651
  } else {
599
- updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)
652
+ updates.selectedIds = computeNextIds(
653
+ node,
654
+ selection.selectedIds,
655
+ nativeEvent,
656
+ modifierKeys,
657
+ baseSelectedIds,
658
+ )
600
659
  setSelection(updates)
601
660
  }
602
661
  },
@@ -619,6 +678,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
619
678
  node.type === 'wall' ||
620
679
  node.type === 'fence' ||
621
680
  node.type === 'column' ||
681
+ node.type === 'elevator' ||
622
682
  node.type === 'slab' ||
623
683
  node.type === 'ceiling' ||
624
684
  node.type === 'roof' ||
@@ -636,13 +696,18 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
636
696
  }
637
697
  if (node.type === 'window' || node.type === 'door') return true
638
698
 
699
+ // Registry-driven: any kind whose NodeDefinition declares the
700
+ // `selectable` capability is also selectable in structure phase. Phase 4
701
+ // makes this the only path and deletes the hardcoded chain above.
702
+ if (isRegistrySelectable(node.type)) return true
703
+
639
704
  return false
640
705
  },
641
706
  },
642
707
 
643
708
  furnish: {
644
709
  types: ['item'],
645
- handleSelect: (node, nativeEvent, modifierKeys) => {
710
+ handleSelect: (node, nativeEvent, modifierKeys, baseSelectedIds) => {
646
711
  const { selection, setSelection } = useViewer.getState()
647
712
  const nodes = useScene.getState().nodes
648
713
  const nodeLevelId = resolveLevelId(node, nodes)
@@ -656,7 +721,13 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
656
721
  updates.buildingId = buildingId
657
722
  }
658
723
 
659
- updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)
724
+ updates.selectedIds = computeNextIds(
725
+ node,
726
+ selection.selectedIds,
727
+ nativeEvent,
728
+ modifierKeys,
729
+ baseSelectedIds,
730
+ )
660
731
  setSelection(updates)
661
732
  },
662
733
  handleDeselect: () => {
@@ -664,71 +735,49 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
664
735
  },
665
736
  isValid: (node) => {
666
737
  if (!isNodeInCurrentLevel(node)) return false
667
- if (node.type !== 'item') return false
668
- const item = node as ItemNode
669
- return item.asset.category !== 'door' && item.asset.category !== 'window'
738
+ // Item: door/window-category items belong to structure phase, not furnish.
739
+ if (node.type === 'item') {
740
+ const item = node as ItemNode
741
+ return item.asset.category !== 'door' && item.asset.category !== 'window'
742
+ }
743
+ // Registry-driven kinds with `category: 'furnish'` (shelf today,
744
+ // future furniture kinds): selectable in furnish phase if their
745
+ // definition declares the `selectable` capability. Without this
746
+ // branch, shelf clicks routed to furnish phase via resolveNodeSelectionTarget
747
+ // would be rejected here — single-click selection broken.
748
+ const def = nodeRegistry.get(node.type)
749
+ if (def && def.category === 'furnish' && def.capabilities.selectable) return true
750
+ return false
670
751
  },
671
752
  },
672
753
  }
673
754
 
674
- const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
675
- if (node.type === 'zone') {
676
- return {
677
- phase: 'structure',
678
- structureLayer: 'zones',
679
- }
680
- }
681
-
682
- if (
683
- node.type === 'wall' ||
684
- node.type === 'fence' ||
685
- node.type === 'column' ||
686
- node.type === 'slab' ||
687
- node.type === 'ceiling' ||
688
- node.type === 'roof' ||
689
- node.type === 'roof-segment' ||
690
- node.type === 'stair' ||
691
- node.type === 'stair-segment' ||
692
- node.type === 'spawn' ||
693
- node.type === 'window' ||
694
- node.type === 'door'
695
- ) {
696
- return {
697
- phase: 'structure',
698
- structureLayer: 'elements',
699
- }
700
- }
701
-
702
- if (node.type === 'item') {
703
- const item = node as ItemNode
704
- if (item.asset.category === 'door' || item.asset.category === 'window') {
705
- return {
706
- phase: 'structure',
707
- structureLayer: 'elements',
708
- }
709
- }
710
-
711
- return {
712
- phase: 'furnish',
713
- }
714
- }
715
-
716
- return null
717
- }
718
-
719
755
  export const SelectionManager = () => {
720
756
  const phase = useEditor((s) => s.phase)
721
757
  const mode = useEditor((s) => s.mode)
758
+ // The canvas element — cursor styling must land here, not on `document.body`:
759
+ // the editor wraps the canvas in a div with a custom `cursor: url(...)`, which
760
+ // (being a closer ancestor) overrides any body cursor over the canvas.
761
+ const glDomElement = useThree((s) => s.gl.domElement)
762
+ const camera = useThree((s) => s.camera)
763
+ const raycaster = useThree((s) => s.raycaster)
722
764
  const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
723
- const modifierKeysRef = useRef<ModifierKeys>({
765
+
766
+ // Publish the live three context for DOM-level sessions (group pick-up
767
+ // move) that raycast the 3D view from outside the R3F tree.
768
+ useEffect(() => {
769
+ setEditorThreeContext({ camera, raycaster, domElement: glDomElement })
770
+ return () => setEditorThreeContext(null)
771
+ }, [camera, raycaster, glDomElement])
772
+ const modifierKeysRef = useRef<SelectionModifierKeys>({
724
773
  meta: false,
725
774
  ctrl: false,
775
+ shift: false,
726
776
  })
727
777
  const clickHandledRef = useRef(false)
728
778
 
729
- const movingNode = useEditor((s) => s.movingNode)
730
- const curvingWall = useEditor((s) => s.curvingWall)
731
- const curvingFence = useEditor((s) => s.curvingFence)
779
+ const movingNode = useMovingNode()
780
+ const isCurveReshape = useIsCurveReshape()
732
781
 
733
782
  useEffect(() => {
734
783
  const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
@@ -741,9 +790,12 @@ export const SelectionManager = () => {
741
790
 
742
791
  useEffect(() => {
743
792
  if (mode !== 'material-paint') return
744
- if (movingNode || curvingWall) return
793
+ if (movingNode || isCurveReshape) return
745
794
 
746
795
  let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null
796
+ // The last hover event, replayed when the application scope cycles so the
797
+ // preview + chip update under a stationary cursor (Shift fires no pointer move).
798
+ let lastEnterEvent: NodeEvent | null = null
747
799
 
748
800
  const clearActivePreview = () => {
749
801
  activePreview?.restore()
@@ -762,166 +814,240 @@ export const SelectionManager = () => {
762
814
  })
763
815
 
764
816
  const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
817
+ const eraser = useEditor.getState().paintEraser
765
818
  const activePaintMaterial = resolveActivePaintMaterial()
766
819
  const node = event.node
767
820
 
768
821
  if (!isNodeInCurrentLevel(node)) return null
769
822
 
770
- if (node.type === 'wall') {
771
- const role = resolveWallMaterialTarget(event as WallEvent)
772
- const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
823
+ // The eraser clears a surface back to its default by painting with an
824
+ // empty material every `build*SurfaceMaterialPatch` interprets
825
+ // `undefined` material/preset as "reset this role". So a single spec
826
+ // with both fields undefined drives the same apply/preview paths as a
827
+ // real material; only the enabled-gate differs (no material required).
828
+ const paintEnabled = eraser || hasActivePaintMaterial(activePaintMaterial)
829
+ const paintSpec: ActivePaintMaterial = eraser
830
+ ? {
831
+ material: undefined,
832
+ materialPreset: undefined,
833
+ sourceTarget:
834
+ activePaintMaterial?.sourceTarget ?? useEditor.getState().activePaintTarget,
835
+ }
836
+ : (activePaintMaterial ?? {
837
+ material: undefined,
838
+ materialPreset: undefined,
839
+ sourceTarget: useEditor.getState().activePaintTarget,
840
+ })
841
+
842
+ // Registry-driven paint dispatch — kinds that declare
843
+ // `capabilities.paint` route hover / click / preview through
844
+ // their definition. Wall, chimney, and dormer use this; legacy
845
+ // roof / stair / single-surface arms below stay until they
846
+ // migrate too.
847
+ const paintCap = nodeRegistry.get(node.type)?.capabilities?.paint
848
+ if (paintCap) {
849
+ const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
850
+ const role = paintCap.resolveRole({
851
+ node,
852
+ materialIndex: materialIndex ?? null,
853
+ normal: event.normal,
854
+ localPosition: event.localPosition as readonly [number, number, number] | undefined,
855
+ hitObjectName: event.nativeEvent.object?.name,
856
+ hitObject: getEventObject(event),
857
+ ray: event.nativeEvent.ray,
858
+ })
859
+ const compatible = role !== null && paintEnabled
860
+ // Derive the node's slots (declared, else mesh tags) once — drives both
861
+ // the chip's available scopes and the whole-object fan-out.
862
+ const slotRoles = compatible && role ? nodeSlotRoles(node, meshSlotRoles) : []
863
+ // Resolve the application-scope fan-out once (this surface / whole object
864
+ // / all matching / room). The scope is part of the key so cycling it
865
+ // (Shift) re-keys the interaction → the preview re-applies for the new
866
+ // spread instead of being deduped to the single-surface preview.
867
+ const scope = useEditor.getState().paintScope
868
+ const wallHit = resolveWallPaintHit(event)
869
+ const scopeTargets =
870
+ compatible && role
871
+ ? resolvePaintScopeTargets({
872
+ node,
873
+ role,
874
+ scope,
875
+ nodes: useScene.getState().nodes,
876
+ spaces: useEditor.getState().spaces,
877
+ slotRolesOf: () => slotRoles,
878
+ wallHit,
879
+ })
880
+ : []
881
+ const scopeTargetKey = scopeTargets
882
+ .map((target) => `${target.nodeId}:${target.role}`)
883
+ .sort()
884
+ .join(',')
773
885
  return {
774
- key: `wall:${node.id}:${role ?? 'unsupported'}`,
886
+ key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}:${scope}:${scopeTargetKey}`,
775
887
  hoveredId: node.id as AnyNodeId,
776
- hoverMode:
777
- compatible && hasActivePaintMaterial(activePaintMaterial) && role
778
- ? 'paint-ready'
779
- : 'paint-disabled',
888
+ hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
889
+ paintHover:
890
+ compatible && role
891
+ ? {
892
+ scopes: availablePaintScopes({ node, slotRoles }),
893
+ slotLabel: slotDisplayLabel(node, role),
894
+ nodeNoun: node.type,
895
+ }
896
+ : null,
780
897
  apply:
781
- compatible && hasActivePaintMaterial(activePaintMaterial)
898
+ compatible && role
782
899
  ? () => {
783
- useScene
784
- .getState()
785
- .updateNode(
786
- node.id as AnyNodeId,
787
- buildWallSurfaceMaterialPatch(
788
- node as WallNode,
789
- role!,
790
- activePaintMaterial.material,
791
- activePaintMaterial.materialPreset,
792
- ),
900
+ // Spread targets are all the same slot-model kind, so one
901
+ // batched commit writes them in a single undo step; the
902
+ // single-surface case keeps the kind's own commit (covers
903
+ // non-slot kinds too).
904
+ if (scopeTargets.length > 1) {
905
+ commitPaintScopeFanout(
906
+ scopeTargets,
907
+ paintSpec.material,
908
+ paintSpec.materialPreset,
793
909
  )
910
+ return
911
+ }
912
+ const args = {
913
+ node,
914
+ role,
915
+ material: paintSpec.material,
916
+ materialPreset: paintSpec.materialPreset,
917
+ }
918
+ if (paintCap.commit) {
919
+ paintCap.commit(args)
920
+ } else {
921
+ useScene
922
+ .getState()
923
+ .updateNode(
924
+ node.id as AnyNodeId,
925
+ paintCap.buildPatch(args) as Partial<AnyNode>,
926
+ )
927
+ }
794
928
  }
795
929
  : null,
796
930
  preview:
797
- compatible && hasActivePaintMaterial(activePaintMaterial) && role
798
- ? () => applyWallPaintPreview(node as WallNode, role, activePaintMaterial)
931
+ compatible && role
932
+ ? () => {
933
+ // Preview every surface the click would paint, so room /
934
+ // whole-item / all-matching show the full spread, not just the
935
+ // hovered surface. Each target is the same kind, so its own
936
+ // paint capability builds the preview; restores combine.
937
+ const restores: PaintPreviewCleanup[] = []
938
+ const sceneNodes = useScene.getState().nodes
939
+ for (const target of scopeTargets) {
940
+ const targetNode = sceneNodes[target.nodeId]
941
+ const targetRoot = getRegisteredNodeObject(target.nodeId)
942
+ const targetCap = targetNode
943
+ ? nodeRegistry.get(targetNode.type)?.capabilities?.paint
944
+ : null
945
+ if (!(targetNode && targetRoot && targetCap)) continue
946
+ const restore = targetCap.applyPreview({
947
+ node: targetNode,
948
+ role: target.role,
949
+ material: paintSpec.material,
950
+ materialPreset: paintSpec.materialPreset,
951
+ root: targetRoot,
952
+ })
953
+ if (restore) restores.push(restore)
954
+ }
955
+ if (restores.length === 0) return null
956
+ return () => {
957
+ for (let index = restores.length - 1; index >= 0; index -= 1)
958
+ restores[index]?.()
959
+ }
960
+ }
799
961
  : () => previewCursor('not-allowed'),
800
962
  }
801
963
  }
802
964
 
803
965
  if (node.type === 'roof' || node.type === 'roof-segment') {
966
+ const isSegmentHit = node.type === 'roof-segment'
804
967
  const roofNode =
805
968
  node.type === 'roof'
806
969
  ? node
807
970
  : node.parentId
808
971
  ? useScene.getState().nodes[node.parentId as AnyNodeId]
809
972
  : null
810
- if (!roofNode || roofNode.type !== 'roof') return null
973
+ if (roofNode?.type !== 'roof') return null
811
974
 
812
975
  const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
813
- const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
976
+ const compatible = role !== null && paintEnabled
977
+ // Painting directly on a segment (only possible in segment edit
978
+ // mode, where the per-segment mesh is visible) writes to the
979
+ // segment's own role-specific fields. Painting the merged shell
980
+ // — or a roof node directly — keeps fanning to the parent roof.
981
+ const segmentTarget = isSegmentHit ? (node as RoofSegmentNode) : null
814
982
  return {
815
- key: `roof:${roofNode.id}:${role ?? 'unsupported'}`,
816
- hoveredId: roofNode.id as AnyNodeId,
817
- hoverMode:
818
- compatible && hasActivePaintMaterial(activePaintMaterial) && role
819
- ? 'paint-ready'
820
- : 'paint-disabled',
983
+ key: `${segmentTarget ? 'roof-segment' : 'roof'}:${
984
+ segmentTarget ? segmentTarget.id : roofNode.id
985
+ }:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
986
+ hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId,
987
+ hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
988
+ // Roof isn't on the slot model (role-specific fields, custom commit),
989
+ // so it offers only the single surface — but still labels it.
990
+ paintHover:
991
+ compatible && role
992
+ ? {
993
+ scopes: ['single'],
994
+ slotLabel: slotDisplayLabel(roofNode, role),
995
+ nodeNoun: 'roof',
996
+ }
997
+ : null,
821
998
  apply:
822
- compatible && hasActivePaintMaterial(activePaintMaterial)
999
+ compatible && role
823
1000
  ? () => {
824
- useScene
825
- .getState()
826
- .updateNode(
1001
+ const sceneState = useScene.getState()
1002
+ if (segmentTarget) {
1003
+ sceneState.updateNode(
1004
+ segmentTarget.id as AnyNodeId,
1005
+ buildRoofSegmentSurfaceMaterialPatch(
1006
+ segmentTarget,
1007
+ role,
1008
+ paintSpec.material,
1009
+ paintSpec.materialPreset,
1010
+ ),
1011
+ )
1012
+ } else {
1013
+ sceneState.updateNode(
827
1014
  roofNode.id as AnyNodeId,
828
1015
  buildRoofSurfaceMaterialPatch(
829
1016
  roofNode as RoofNode,
830
- role!,
831
- activePaintMaterial.material,
832
- activePaintMaterial.materialPreset,
1017
+ role,
1018
+ paintSpec.material,
1019
+ paintSpec.materialPreset,
833
1020
  ),
834
1021
  )
1022
+ }
835
1023
  }
836
1024
  : null,
837
1025
  preview:
838
- compatible && hasActivePaintMaterial(activePaintMaterial) && role
839
- ? () => applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial)
1026
+ compatible && role
1027
+ ? () =>
1028
+ segmentTarget
1029
+ ? applyRoofSegmentPaintPreview(
1030
+ segmentTarget,
1031
+ roofNode as RoofNode,
1032
+ role,
1033
+ paintSpec,
1034
+ )
1035
+ : applyRoofPaintPreview(roofNode as RoofNode, role, paintSpec)
840
1036
  : () => previewCursor('not-allowed'),
841
1037
  }
842
1038
  }
843
1039
 
844
- if (node.type === 'stair' || node.type === 'stair-segment') {
845
- const stairNode =
846
- node.type === 'stair'
847
- ? node
848
- : node.parentId
849
- ? useScene.getState().nodes[node.parentId as AnyNodeId]
850
- : null
851
- if (!stairNode || stairNode.type !== 'stair') return null
1040
+ // Only `roof` / `roof-segment` reach a legacy paint arm (above) — every
1041
+ // other paintable kind declares `capabilities.paint` and returns from the
1042
+ // registry-driven dispatch at the top of this function.
852
1043
 
853
- const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent)
854
- const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
855
- return {
856
- key: `stair:${stairNode.id}:${role ?? 'unsupported'}`,
857
- hoveredId: stairNode.id as AnyNodeId,
858
- hoverMode:
859
- compatible && hasActivePaintMaterial(activePaintMaterial) && role
860
- ? 'paint-ready'
861
- : 'paint-disabled',
862
- apply:
863
- compatible && hasActivePaintMaterial(activePaintMaterial)
864
- ? () => {
865
- useScene
866
- .getState()
867
- .updateNode(
868
- stairNode.id as AnyNodeId,
869
- buildStairSurfaceMaterialPatch(
870
- stairNode as StairNode,
871
- role!,
872
- activePaintMaterial.material,
873
- activePaintMaterial.materialPreset,
874
- ),
875
- )
876
- }
877
- : null,
878
- preview:
879
- compatible && hasActivePaintMaterial(activePaintMaterial) && role
880
- ? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial)
881
- : () => previewCursor('not-allowed'),
882
- }
883
- }
884
-
885
- if (
886
- node.type === 'fence' ||
887
- node.type === 'column' ||
888
- node.type === 'slab' ||
889
- node.type === 'ceiling'
890
- ) {
891
- const compatible = hasActivePaintMaterial(activePaintMaterial)
892
-
893
- return {
894
- key: `${node.type}:${node.id}:surface`,
895
- hoveredId: node.id as AnyNodeId,
896
- hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
897
- apply: compatible
898
- ? () => {
899
- useScene
900
- .getState()
901
- .updateNode(
902
- node.id as AnyNodeId,
903
- buildSingleSurfaceMaterialPatch<
904
- FenceNode | ColumnNode | SlabNode | CeilingNode
905
- >(activePaintMaterial.material, activePaintMaterial.materialPreset),
906
- )
907
- }
908
- : null,
909
- preview: compatible
910
- ? () =>
911
- applySingleSurfacePaintPreview(
912
- node as FenceNode | ColumnNode | SlabNode | CeilingNode,
913
- activePaintMaterial,
914
- )
915
- : () => previewCursor('not-allowed'),
916
- }
917
- }
918
-
919
- const disabledNodeTypes = ['item', 'window', 'door', 'zone']
1044
+ const disabledNodeTypes = ['zone']
920
1045
  if (disabledNodeTypes.includes(node.type)) {
921
1046
  return {
922
1047
  key: `${node.type}:${node.id}:unsupported`,
923
1048
  hoveredId: node.id as AnyNodeId,
924
1049
  hoverMode: 'paint-disabled',
1050
+ paintHover: null,
925
1051
  apply: null,
926
1052
  preview: () => previewCursor('not-allowed'),
927
1053
  }
@@ -931,12 +1057,22 @@ export const SelectionManager = () => {
931
1057
  }
932
1058
 
933
1059
  const onEnter = (event: NodeEvent) => {
934
- if (boxSelectHandled) return
1060
+ // A host-driven drag (handle resize/rotate) sets `inputDragging`.
1061
+ // useNodeEvents now emits hover events during such a drag so surface
1062
+ // move tools keep tracking the cursor — but paint preview must not fire
1063
+ // mid-drag, so gate on `inputDragging` here too.
1064
+ if (boxSelectHandled || useViewer.getState().inputDragging) return
935
1065
 
936
1066
  const interaction = getPaintInteraction(event)
937
1067
  if (!interaction) return
938
1068
 
939
1069
  event.stopPropagation()
1070
+ lastEnterEvent = event
1071
+
1072
+ // Drive the paint HUD off this hover: the interaction carries the scopes +
1073
+ // labels for the painted surface (`null` when it isn't paintable — no
1074
+ // slots, etc. — which makes the HUD show the "hover a surface" hint).
1075
+ useEditor.getState().setPaintHover(interaction.paintHover)
940
1076
 
941
1077
  if (activePreview?.key === interaction.key) {
942
1078
  return
@@ -956,6 +1092,10 @@ export const SelectionManager = () => {
956
1092
  const interaction = getPaintInteraction(event)
957
1093
  if (!interaction) return
958
1094
 
1095
+ // Leaving any surface → the HUD shows the "hover a surface" hint again.
1096
+ lastEnterEvent = null
1097
+ useEditor.getState().setPaintHover(null)
1098
+
959
1099
  if (activePreview?.key !== interaction.key) {
960
1100
  return
961
1101
  }
@@ -980,6 +1120,7 @@ export const SelectionManager = () => {
980
1120
  }
981
1121
 
982
1122
  interaction.apply()
1123
+ sfxEmitter.emit('sfx:paint-apply')
983
1124
  if (activePreview?.key === interaction.key) {
984
1125
  activePreview = null
985
1126
  } else {
@@ -1004,38 +1145,64 @@ export const SelectionManager = () => {
1004
1145
  'zone',
1005
1146
  ] as const
1006
1147
 
1007
- for (const type of allTypes) {
1148
+ // Registry-driven kinds get the same subscriptions as the hardcoded list,
1149
+ // so future built-in nodes don't need to edit allTypes per migration.
1150
+ const registryKinds = getSelectableKinds().filter(
1151
+ (k) => !(allTypes as readonly string[]).includes(k),
1152
+ )
1153
+ const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
1154
+
1155
+ for (const type of subscribedKinds) {
1008
1156
  emitter.on(`${type}:enter` as any, onEnter as any)
1157
+ // Re-evaluate on move so the hover preview tracks the cursor across a
1158
+ // kind's sub-parts (door/window panel↔frame↔glass↔hardware, wall
1159
+ // interior↔exterior) — not just on the initial enter. onEnter is
1160
+ // idempotent (no-ops when the resolved part is unchanged).
1161
+ emitter.on(`${type}:move` as any, onEnter as any)
1009
1162
  emitter.on(`${type}:leave` as any, onLeave as any)
1010
1163
  emitter.on(`${type}:click` as any, onClick as any)
1011
1164
  }
1012
1165
 
1166
+ // Cycling the application scope (Shift) fires no pointer event, so replay
1167
+ // the last hover to re-resolve the spread and re-apply the preview at once.
1168
+ const unsubscribePaintScope = useEditor.subscribe((state, prev) => {
1169
+ if (state.paintScope === prev.paintScope || !lastEnterEvent) return
1170
+ clearActivePreview()
1171
+ onEnter(lastEnterEvent)
1172
+ })
1173
+
1013
1174
  return () => {
1014
- for (const type of allTypes) {
1175
+ unsubscribePaintScope()
1176
+ for (const type of subscribedKinds) {
1015
1177
  emitter.off(`${type}:enter` as any, onEnter as any)
1178
+ emitter.off(`${type}:move` as any, onEnter as any)
1016
1179
  emitter.off(`${type}:leave` as any, onLeave as any)
1017
1180
  emitter.off(`${type}:click` as any, onClick as any)
1018
1181
  }
1019
1182
  clearActivePreview()
1020
1183
  useViewer.setState({ hoveredId: null })
1021
1184
  setHoverHighlightMode('default')
1185
+ useEditor.getState().setPaintHover(null)
1022
1186
  }
1023
- }, [curvingWall, mode, movingNode, setHoverHighlightMode])
1187
+ }, [isCurveReshape, mode, movingNode, setHoverHighlightMode])
1024
1188
 
1025
1189
  useEffect(() => {
1026
1190
  const onKeyDown = (event: KeyboardEvent) => {
1027
1191
  if (event.key === 'Meta') modifierKeysRef.current.meta = true
1028
1192
  if (event.key === 'Control') modifierKeysRef.current.ctrl = true
1193
+ if (event.key === 'Shift') modifierKeysRef.current.shift = true
1029
1194
  }
1030
1195
 
1031
1196
  const onKeyUp = (event: KeyboardEvent) => {
1032
1197
  if (event.key === 'Meta') modifierKeysRef.current.meta = false
1033
1198
  if (event.key === 'Control') modifierKeysRef.current.ctrl = false
1199
+ if (event.key === 'Shift') modifierKeysRef.current.shift = false
1034
1200
  }
1035
1201
 
1036
1202
  const clearModifiers = () => {
1037
1203
  modifierKeysRef.current.meta = false
1038
1204
  modifierKeysRef.current.ctrl = false
1205
+ modifierKeysRef.current.shift = false
1039
1206
  }
1040
1207
 
1041
1208
  window.addEventListener('keydown', onKeyDown)
@@ -1051,21 +1218,360 @@ export const SelectionManager = () => {
1051
1218
 
1052
1219
  useEffect(() => {
1053
1220
  if (mode !== 'select') return
1054
- if (movingNode || curvingWall || curvingFence) return
1221
+ if (movingNode || isCurveReshape) return
1222
+
1223
+ const onPointerDown = (event: NodeEvent) => {
1224
+ const pointer = pointerEventFromNodeEvent(event)
1225
+ if (pointer.button !== 0) return
1226
+
1227
+ // Plain press on a transformable member of a multi-selection arms the
1228
+ // group move — dragging slides the whole selection on the ground plane
1229
+ // (the 3D sibling of the 2D floorplan group drag, replacing the removed
1230
+ // group-move gizmo cross). A plain click (no drag) still falls through
1231
+ // to the normal click handling, which collapses to the pressed node.
1232
+ if (
1233
+ !(pointer.shiftKey || pointer.altKey || isCommandModifier(pointer)) &&
1234
+ armGroupMove3d({
1235
+ nodeId: event.node.id as AnyNodeId,
1236
+ clientX: pointer.clientX,
1237
+ clientY: pointer.clientY,
1238
+ pointerId: pointer.pointerId,
1239
+ nativeEvent: pointer,
1240
+ camera,
1241
+ raycaster,
1242
+ domElement: glDomElement,
1243
+ })
1244
+ ) {
1245
+ return
1246
+ }
1247
+
1248
+ if (!isCommandModifier(pointer)) return
1249
+
1250
+ const eventNode = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node
1251
+ const node = resolveCanvasSelectionNode({
1252
+ node: eventNode,
1253
+ nodes: useScene.getState().nodes,
1254
+ selectedIds: useViewer.getState().selection.selectedIds,
1255
+ })
1256
+ if (!canDirectMoveNode(node)) return
1257
+ // Sole selection only: per-node direct manipulation stands down for a
1258
+ // multi-selection (the group sessions own plain drags there, and Cmd is
1259
+ // the selection-toggle key — a wobbly Cmd+click must not yank one
1260
+ // member out of the group).
1261
+ const currentSelectedIds = useViewer.getState().selection.selectedIds
1262
+ if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== node.id) return
1263
+
1264
+ const startX = pointer.clientX
1265
+ const startY = pointer.clientY
1266
+ const pointerId = pointer.pointerId
1267
+ const pointerTarget = pointer.target instanceof EventTarget ? pointer.target : null
1268
+ let engaged = false
1269
+
1270
+ const cleanup = () => {
1271
+ window.removeEventListener('pointermove', onMove)
1272
+ window.removeEventListener('pointerup', onEnd)
1273
+ window.removeEventListener('pointercancel', onEnd)
1274
+ if (engaged) {
1275
+ useViewer.getState().setInputDragging(false)
1276
+ }
1277
+ }
1278
+
1279
+ const onMove = (moveEvent: PointerEvent) => {
1280
+ if (moveEvent.pointerId !== pointerId) return
1281
+ if (engaged) return
1282
+ if (pointerDistancePx(moveEvent, startX, startY) < DIRECT_DRAG_THRESHOLD_PX) return
1283
+
1284
+ engaged = true
1285
+ event.stopPropagation()
1286
+ suppressBoxSelectForPointer(event.nativeEvent)
1287
+ useViewer.getState().setInputDragging(true)
1288
+ swallowNextClick()
1289
+ createEditorApi().engageMoveDrag(node)
1290
+ requestAnimationFrame(() => {
1291
+ if (getMovingNode()?.id !== node.id) return
1292
+ pointerTarget?.dispatchEvent(
1293
+ new PointerEvent('pointermove', {
1294
+ altKey: moveEvent.altKey,
1295
+ bubbles: true,
1296
+ buttons: moveEvent.buttons,
1297
+ clientX: moveEvent.clientX,
1298
+ clientY: moveEvent.clientY,
1299
+ ctrlKey: moveEvent.ctrlKey,
1300
+ metaKey: moveEvent.metaKey,
1301
+ pointerId,
1302
+ pointerType: moveEvent.pointerType,
1303
+ shiftKey: moveEvent.shiftKey,
1304
+ }),
1305
+ )
1306
+ })
1307
+ }
1308
+
1309
+ const onEnd = (endEvent: PointerEvent) => {
1310
+ if (endEvent.pointerId !== pointerId) return
1311
+ cleanup()
1312
+ if (engaged) {
1313
+ requestAnimationFrame(() => {
1314
+ const editor = useEditor.getState()
1315
+ if (getMovingNode()?.id !== node.id || !editor.placementDragMode) return
1316
+ editor.setMovingNode(null)
1317
+ })
1318
+ }
1319
+ }
1320
+
1321
+ window.addEventListener('pointermove', onMove)
1322
+ window.addEventListener('pointerup', onEnd)
1323
+ window.addEventListener('pointercancel', onEnd)
1324
+ }
1325
+
1326
+ const allTypes = [
1327
+ 'wall',
1328
+ 'fence',
1329
+ 'item',
1330
+ 'column',
1331
+ 'slab',
1332
+ 'ceiling',
1333
+ 'roof',
1334
+ 'roof-segment',
1335
+ 'stair',
1336
+ 'stair-segment',
1337
+ 'window',
1338
+ 'door',
1339
+ 'zone',
1340
+ 'shelf',
1341
+ 'spawn',
1342
+ 'elevator',
1343
+ 'building',
1344
+ ] as const
1345
+ const registryKinds = getSelectableKinds().filter(
1346
+ (kind) => !(allTypes as readonly string[]).includes(kind),
1347
+ )
1348
+ const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
1349
+
1350
+ for (const type of subscribedKinds) {
1351
+ emitter.on(`${type}:pointerdown` as any, onPointerDown as any)
1352
+ }
1353
+
1354
+ return () => {
1355
+ for (const type of subscribedKinds) {
1356
+ emitter.off(`${type}:pointerdown` as any, onPointerDown as any)
1357
+ }
1358
+ }
1359
+ }, [isCurveReshape, mode, movingNode, camera, raycaster, glDomElement])
1360
+
1361
+ // Move cursor over the selected movable node: the visual cue that clicking it
1362
+ // picks it up (replaces the removed move-cross gizmo). Reacts only when the
1363
+ // hovered/selected node changes (not on every camera move) so it doesn't fight
1364
+ // the rotate/resize gizmos' own hover cursors. Clears only the cursor it owns.
1365
+ useEffect(() => {
1366
+ if (mode !== 'select') return
1367
+ let owns = false
1368
+ let prevKey = ''
1369
+ const applyCursor = () => {
1370
+ const { selection, hoveredId } = useViewer.getState()
1371
+ const selectedIds = selection.selectedIds
1372
+ const sole = selectedIds.length === 1 ? selectedIds[0] : null
1373
+ const key = `${hoveredId ?? ''}|${sole ?? ''}|${selectedIds.length}`
1374
+ if (key === prevKey) return
1375
+ prevKey = key
1376
+ let wantsMove = false
1377
+ if (hoveredId && !getMovingNode()) {
1378
+ if (sole === hoveredId) {
1379
+ const node = useScene.getState().nodes[sole as AnyNodeId]
1380
+ wantsMove = !!node && canDirectMoveNode(node)
1381
+ } else if (selectedIds.length > 1 && selectedIds.includes(hoveredId)) {
1382
+ // Group member: dragging it slides the whole selection.
1383
+ const nodes = useScene.getState().nodes
1384
+ wantsMove =
1385
+ classifyParticipant(nodes[hoveredId as AnyNodeId], selection.levelId, nodes) !== null
1386
+ }
1387
+ }
1388
+ if (wantsMove) {
1389
+ glDomElement.style.cursor = 'move'
1390
+ owns = true
1391
+ } else if (owns) {
1392
+ glDomElement.style.cursor = ''
1393
+ owns = false
1394
+ }
1395
+ }
1396
+ applyCursor()
1397
+ const unsub = useViewer.subscribe(applyCursor)
1398
+ return () => {
1399
+ unsub()
1400
+ if (owns) glDomElement.style.cursor = ''
1401
+ }
1402
+ }, [mode, glDomElement])
1403
+
1404
+ // While a node is actively being moved (click-to-move / Move button, or a
1405
+ // fresh preset placement), show a grabbing hand. Mode-independent: presets
1406
+ // move in build mode. Overrides the hover 'move' cursor (which bails while a
1407
+ // movingNode exists), and clears back to the canvas's custom cursor on drop.
1408
+ useEffect(() => {
1409
+ if (!movingNode) return
1410
+ glDomElement.style.cursor = 'grabbing'
1411
+ return () => {
1412
+ glDomElement.style.cursor = ''
1413
+ }
1414
+ }, [movingNode, glDomElement])
1415
+
1416
+ useEffect(() => {
1417
+ if (mode !== 'select') return
1418
+ if (movingNode || isCurveReshape) return
1419
+
1420
+ const onPointerDown = (event: PointerEvent) => {
1421
+ if (event.button !== 2 || !isCommandModifier(event)) return
1422
+ if (!(event.target instanceof HTMLCanvasElement)) return
1423
+
1424
+ // Sole selection only — same stand-down as the Cmd-drag move above.
1425
+ const selectedIds = useViewer.getState().selection.selectedIds
1426
+ const hoveredId = useViewer.getState().hoveredId as AnyNodeId | null
1427
+ if (!hoveredId || selectedIds.length !== 1 || selectedIds[0] !== hoveredId) return
1428
+
1429
+ const node = useScene.getState().nodes[hoveredId]
1430
+ if (!node || !canDirectRotateNode(node)) return
1431
+
1432
+ event.preventDefault()
1433
+ event.stopPropagation()
1434
+
1435
+ const nodeId = node.id as AnyNodeId
1436
+ const pointerId = event.pointerId
1437
+ const startX = event.clientX
1438
+ const sceneApi = createSceneApi(useScene)
1439
+ let lastPatch: Partial<AnyNode> | null = null
1440
+
1441
+ const applyDelta = (moveEvent: PointerEvent) => {
1442
+ const delta = resolveDirectRotationDragDelta(
1443
+ startX,
1444
+ moveEvent.clientX,
1445
+ DIRECT_ROTATE_RADIANS_PER_PIXEL,
1446
+ moveEvent.shiftKey,
1447
+ )
1448
+ if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
1449
+ lastPatch = null
1450
+ useLiveNodeOverrides.getState().clear(nodeId)
1451
+ useScene.getState().markDirty(nodeId)
1452
+ return
1453
+ }
1454
+ const patch = resolveDirectRotationPatch(node, delta, sceneApi)
1455
+ if (!patch) return
1456
+ lastPatch = patch
1457
+ useLiveNodeOverrides.getState().set(nodeId, patch as Record<string, unknown>)
1458
+ useScene.getState().markDirty(nodeId)
1459
+ }
1460
+
1461
+ const cleanup = () => {
1462
+ window.removeEventListener('pointermove', onMove, true)
1463
+ window.removeEventListener('pointerup', onUp, true)
1464
+ window.removeEventListener('pointercancel', onCancel, true)
1465
+ window.removeEventListener('contextmenu', preventContextMenu, true)
1466
+ useLiveNodeOverrides.getState().clear(nodeId)
1467
+ useScene.getState().markDirty(nodeId)
1468
+ useDirectManipulationFeedback.getState().clearActiveRotateNodeId(nodeId)
1469
+ useScene.temporal.getState().resume()
1470
+ useViewer.getState().setInputDragging(false)
1471
+ if (document.body.style.cursor === 'ew-resize') {
1472
+ document.body.style.cursor = ''
1473
+ }
1474
+ }
1475
+
1476
+ const onMove = (moveEvent: PointerEvent) => {
1477
+ if (moveEvent.pointerId !== pointerId) return
1478
+ moveEvent.preventDefault()
1479
+ moveEvent.stopPropagation()
1480
+ applyDelta(moveEvent)
1481
+ }
1482
+
1483
+ const onUp = (upEvent: PointerEvent) => {
1484
+ if (upEvent.pointerId !== pointerId) return
1485
+ upEvent.preventDefault()
1486
+ upEvent.stopPropagation()
1487
+ swallowNextClick()
1488
+ if (lastPatch) {
1489
+ sceneApi.update(nodeId, lastPatch)
1490
+ sfxEmitter.emit('sfx:item-place')
1491
+ }
1492
+ cleanup()
1493
+ }
1494
+
1495
+ const onCancel = (cancelEvent: PointerEvent) => {
1496
+ if (cancelEvent.pointerId !== pointerId) return
1497
+ cleanup()
1498
+ }
1499
+
1500
+ const preventContextMenu = (contextEvent: Event) => {
1501
+ contextEvent.preventDefault()
1502
+ contextEvent.stopPropagation()
1503
+ }
1504
+
1505
+ useViewer.getState().setInputDragging(true)
1506
+ useDirectManipulationFeedback.getState().setActiveRotateNodeId(nodeId)
1507
+ useScene.temporal.getState().pause()
1508
+ document.body.style.cursor = 'ew-resize'
1509
+ sfxEmitter.emit('sfx:item-pick')
1510
+ applyDelta(event)
1511
+
1512
+ window.addEventListener('pointermove', onMove, true)
1513
+ window.addEventListener('pointerup', onUp, true)
1514
+ window.addEventListener('pointercancel', onCancel, true)
1515
+ window.addEventListener('contextmenu', preventContextMenu, true)
1516
+ }
1517
+
1518
+ window.addEventListener('pointerdown', onPointerDown, true)
1519
+ return () => {
1520
+ window.removeEventListener('pointerdown', onPointerDown, true)
1521
+ }
1522
+ }, [isCurveReshape, mode, movingNode])
1523
+
1524
+ useEffect(() => {
1525
+ if (mode !== 'select') return
1526
+ if (movingNode || isCurveReshape) return
1055
1527
 
1056
1528
  const onClick = (event: NodeEvent) => {
1057
1529
  // Skip if box-select just completed (drag ended over a node)
1058
1530
  if (boxSelectHandled) return
1059
1531
 
1060
- const node = event.node
1532
+ // node:click is synthesized on pointer-up (use-node-events). A wall/fence
1533
+ // endpoint handle sits ON the wall body, so from a 3D angle the wall mesh
1534
+ // is raycast-hit behind it and the SAME pointer-up also emits the wall's
1535
+ // click — which would select + arm the wall move tool on top of the
1536
+ // endpoint move. While an endpoint reshape owns the pointer, ignore the
1537
+ // body click so only the reshape tool handles the release. (Scoped to
1538
+ // `endpoint`: hole-edit relies on node clicks to exit, just below.)
1539
+ const activeScope = useInteractionScope.getState().scope
1540
+ if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return
1541
+
1542
+ if (dispatchSceneAction(event.node, getEventObject(event))) {
1543
+ event.stopPropagation()
1544
+ clickHandledRef.current = true
1545
+ setTimeout(() => {
1546
+ clickHandledRef.current = false
1547
+ }, 50)
1548
+ return
1549
+ }
1550
+
1551
+ const node = resolveCanvasSelectionNode({
1552
+ node: resolveSelectModeNodeTarget(event),
1553
+ nodes: useScene.getState().nodes,
1554
+ selectedIds: useViewer.getState().selection.selectedIds,
1555
+ })
1556
+
1557
+ // A ceiling is selectable only through its corner handles, never via
1558
+ // the `ceiling-grid` body mesh. When the grid is revealed (ceiling
1559
+ // selected, or an item placed beneath it) a top-down click hits the
1560
+ // grid first; selecting the ceiling there both re-selects it as a
1561
+ // no-op and stops propagation, blocking the hosted item below. By
1562
+ // ignoring non-handle ceiling clicks (without stopping propagation)
1563
+ // the click falls through to the item underneath.
1564
+ if (node.type === 'ceiling' && !event.viaHandle) return
1565
+
1061
1566
  let currentPhase = useEditor.getState().phase
1062
1567
  let currentStructureLayer = useEditor.getState().structureLayer
1568
+ const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
1063
1569
 
1064
1570
  // Auto-switch between zones, structure, and furnish when clicking elements on the same level.
1065
1571
  // Also auto-switch from site phase when clicking structural/furnish elements (e.g. 2D floorplan).
1066
1572
  if (currentPhase === 'structure' || currentPhase === 'furnish' || currentPhase === 'site') {
1067
1573
  if (isNodeInCurrentLevel(node)) {
1068
- const target = getSelectionTarget(node)
1574
+ const target = resolveNodeSelectionTarget(node)
1069
1575
  if (target) {
1070
1576
  if (target.phase !== currentPhase) {
1071
1577
  useEditor.getState().setPhase(target.phase)
@@ -1088,34 +1594,92 @@ export const SelectionManager = () => {
1088
1594
  if (activeStrategy?.isValid(node)) {
1089
1595
  event.stopPropagation()
1090
1596
  clickHandledRef.current = true
1597
+ // Reset the handled flag after a short delay so the grid:click that the
1598
+ // SAME DOM click also raycasts is ignored (it fires synchronously, before
1599
+ // this 50ms macrotask). Scheduled here — right after the flag is set — so
1600
+ // EVERY branch below clears it, including the click-to-move early return
1601
+ // (which previously skipped the reset and left empty-click deselect stuck
1602
+ // until the next normal select).
1603
+ setTimeout(() => {
1604
+ clickHandledRef.current = false
1605
+ }, 50)
1091
1606
 
1092
1607
  let nodeToSelect = node
1093
- if (node.type === 'roof-segment' && node.parentId) {
1094
- const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
1095
- if (parentNode && parentNode.type === 'roof') {
1096
- nodeToSelect = parentNode
1097
- }
1098
- }
1099
1608
  if (node.type === 'stair-segment' && node.parentId) {
1100
1609
  const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
1101
1610
  if (parentNode && parentNode.type === 'stair') {
1102
1611
  nodeToSelect = parentNode
1103
1612
  }
1104
1613
  }
1614
+ nodeToSelect = resolveCanvasSelectionNode({
1615
+ node: nodeToSelect,
1616
+ nodes: useScene.getState().nodes,
1617
+ selectedIds: selectedIdsBeforeRouting,
1618
+ })
1619
+ // Clicking any node (e.g. the slab surface outside a hole) exits slab
1620
+ // hole-edit mode. The hole handles + hit mesh stopPropagation, so a
1621
+ // click reaching here means the user clicked outside the hole.
1622
+ if (getEditingHole()) {
1623
+ useInteractionScope
1624
+ .getState()
1625
+ .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
1626
+ }
1627
+
1628
+ // Click-to-move: clicking the already-selected sole movable node with
1629
+ // no modifiers picks it up instead of re-selecting — the move-cross
1630
+ // gizmo's old job, now on the node body. `setMovingNode` arms the
1631
+ // registry move tool in click-to-commit mode, exactly like the floating
1632
+ // Move button. The first (selecting) click can't hit this because the
1633
+ // node isn't yet in `selectedIdsBeforeRouting`.
1634
+ const nativeEvent = event.nativeEvent
1635
+ const hasModifier = nativeEvent.shiftKey || isCommandModifier(nativeEvent)
1636
+ const isAlreadySole =
1637
+ selectedIdsBeforeRouting.length === 1 && selectedIdsBeforeRouting[0] === nodeToSelect.id
1638
+ if (!hasModifier && isAlreadySole && !getMovingNode() && canDirectMoveNode(nodeToSelect)) {
1639
+ sfxEmitter.emit('sfx:item-pick')
1640
+ useEditor.getState().setMovingNode(nodeToSelect as never)
1641
+ useViewer.getState().setSelection({ selectedIds: [] })
1642
+ return
1643
+ }
1105
1644
 
1106
- activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
1645
+ activeStrategy.handleSelect(
1646
+ nodeToSelect,
1647
+ event.nativeEvent,
1648
+ modifierKeysRef.current,
1649
+ selectedIdsBeforeRouting,
1650
+ )
1107
1651
 
1108
1652
  let nextMaterialTargetHandled = false
1109
1653
 
1110
- if (node.type === 'wall' && nodeToSelect.type === 'wall') {
1111
- setSelectedMaterialTargetForNode(
1112
- nodeToSelect,
1113
- resolveWallMaterialTarget(event as WallEvent),
1114
- )
1115
- nextMaterialTargetHandled = true
1654
+ // Registry-driven paint-target resolve on click. Kinds with
1655
+ // `capabilities.paint` route through this entry — wall,
1656
+ // chimney, dormer use it today. The legacy stair / roof /
1657
+ // single-surface arms below stay until they migrate too.
1658
+ if (nodeToSelect.type === node.type) {
1659
+ const paintCap = nodeRegistry.get(node.type)?.capabilities?.paint
1660
+ if (paintCap) {
1661
+ const materialIndex = getIntersectionMaterialIndex(
1662
+ getEventObject(event),
1663
+ event.faceIndex,
1664
+ )
1665
+ const role = paintCap.resolveRole({
1666
+ node,
1667
+ materialIndex: materialIndex ?? null,
1668
+ normal: event.normal,
1669
+ localPosition: event.localPosition as readonly [number, number, number] | undefined,
1670
+ hitObjectName: event.nativeEvent.object?.name,
1671
+ hitObject: getEventObject(event),
1672
+ ray: event.nativeEvent.ray,
1673
+ })
1674
+ if (role) {
1675
+ setSelectedMaterialTargetForNode(nodeToSelect, role as MaterialTargetRole)
1676
+ nextMaterialTargetHandled = true
1677
+ }
1678
+ }
1116
1679
  }
1117
1680
 
1118
1681
  if (
1682
+ !nextMaterialTargetHandled &&
1119
1683
  (node.type === 'stair' || node.type === 'stair-segment') &&
1120
1684
  nodeToSelect.type === 'stair'
1121
1685
  ) {
@@ -1127,6 +1691,7 @@ export const SelectionManager = () => {
1127
1691
  }
1128
1692
 
1129
1693
  if (
1694
+ !nextMaterialTargetHandled &&
1130
1695
  (node.type === 'roof' || node.type === 'roof-segment') &&
1131
1696
  nodeToSelect.type === 'roof'
1132
1697
  ) {
@@ -1138,7 +1703,11 @@ export const SelectionManager = () => {
1138
1703
  }
1139
1704
 
1140
1705
  if (
1141
- (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') &&
1706
+ !nextMaterialTargetHandled &&
1707
+ (node.type === 'fence' ||
1708
+ node.type === 'slab' ||
1709
+ node.type === 'ceiling' ||
1710
+ node.type === 'shelf') &&
1142
1711
  nodeToSelect.type === node.type
1143
1712
  ) {
1144
1713
  setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
@@ -1148,11 +1717,6 @@ export const SelectionManager = () => {
1148
1717
  if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
1149
1718
  useEditor.getState().setSelectedMaterialTarget(null)
1150
1719
  }
1151
-
1152
- // Reset the handled flag after a short delay to allow grid:click to be ignored
1153
- setTimeout(() => {
1154
- clickHandledRef.current = false
1155
- }, 50)
1156
1720
  }
1157
1721
  }
1158
1722
 
@@ -1162,6 +1726,7 @@ export const SelectionManager = () => {
1162
1726
  'item',
1163
1727
  'column',
1164
1728
  'building',
1729
+ 'elevator',
1165
1730
  'zone',
1166
1731
  'slab',
1167
1732
  'ceiling',
@@ -1173,13 +1738,22 @@ export const SelectionManager = () => {
1173
1738
  'window',
1174
1739
  'door',
1175
1740
  ]
1176
- allTypes.forEach((type) => {
1741
+ // Registry-driven kinds get the same subscriptions as the hardcoded list,
1742
+ // so future built-in nodes don't need to edit allTypes per migration.
1743
+ const registryKinds = getSelectableKinds().filter(
1744
+ (k) => !(allTypes as readonly string[]).includes(k),
1745
+ )
1746
+ const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
1747
+
1748
+ subscribedKinds.forEach((type) => {
1177
1749
  emitter.on(`${type}:click` as any, onClick as any)
1178
1750
  })
1179
1751
 
1180
- const onGridClick = () => {
1752
+ const onGridClick = (event: GridEvent) => {
1181
1753
  if (clickHandledRef.current) return
1182
1754
  if (boxSelectHandled) return
1755
+ const nativeEvent = event.nativeEvent
1756
+ if (nativeEvent?.metaKey || nativeEvent?.ctrlKey || nativeEvent?.shiftKey) return
1183
1757
  const { phase, structureLayer } = useEditor.getState()
1184
1758
  const activeStrategy = SELECTION_STRATEGIES[phase]
1185
1759
  if (activeStrategy) activeStrategy.handleDeselect()
@@ -1194,20 +1768,29 @@ export const SelectionManager = () => {
1194
1768
  emitter.on('grid:click', onGridClick)
1195
1769
 
1196
1770
  return () => {
1197
- allTypes.forEach((type) => {
1771
+ subscribedKinds.forEach((type) => {
1198
1772
  emitter.off(`${type}:click` as any, onClick as any)
1199
1773
  })
1200
1774
  emitter.off('grid:click', onGridClick)
1201
1775
  }
1202
- }, [curvingFence, curvingWall, mode, movingNode])
1776
+ }, [isCurveReshape, mode, movingNode])
1203
1777
 
1204
1778
  // Global double-click handler for auto-switching phases and cross-phase hover
1205
1779
  useEffect(() => {
1206
1780
  if (mode !== 'select') return
1207
- if (movingNode || curvingWall || curvingFence) return
1781
+ if (movingNode || isCurveReshape) return
1208
1782
 
1209
1783
  const onEnter = (event: NodeEvent) => {
1210
- const node = event.node
1784
+ // A host-driven drag (handle resize/rotate, box-select) sets
1785
+ // `inputDragging`. useNodeEvents still emits hover events during it so
1786
+ // surface move tools keep tracking — but the select-hover outline must
1787
+ // stay put, so don't repaint under the cursor mid-drag.
1788
+ if (useViewer.getState().inputDragging) return
1789
+ const node = resolveCanvasSelectionNode({
1790
+ node: resolveSelectModeNodeTarget(event),
1791
+ nodes: useScene.getState().nodes,
1792
+ selectedIds: useViewer.getState().selection.selectedIds,
1793
+ })
1211
1794
  const currentPhase = useEditor.getState().phase
1212
1795
 
1213
1796
  // Ignore site/building if we are already inside a building
@@ -1234,17 +1817,30 @@ export const SelectionManager = () => {
1234
1817
  }
1235
1818
 
1236
1819
  const onLeave = (event: NodeEvent) => {
1237
- const nodeId = event?.node?.id
1820
+ if (useViewer.getState().inputDragging) return
1821
+ const nodeId = resolveCanvasSelectionNode({
1822
+ node: resolveSelectModeNodeTarget(event),
1823
+ nodes: useScene.getState().nodes,
1824
+ selectedIds: useViewer.getState().selection.selectedIds,
1825
+ })?.id
1238
1826
  if (nodeId && useViewer.getState().hoveredId === nodeId) {
1239
1827
  useViewer.setState({ hoveredId: null })
1240
1828
  }
1241
1829
  }
1242
1830
 
1243
1831
  const onDoubleClick = (event: NodeEvent) => {
1244
- const node = event.node
1832
+ let node = resolveCanvasSelectionNode({
1833
+ node: resolveSelectModeNodeTarget(event),
1834
+ nodes: useScene.getState().nodes,
1835
+ selectedIds: useViewer.getState().selection.selectedIds,
1836
+ })
1837
+
1245
1838
  const currentPhase = useEditor.getState().phase
1246
1839
 
1247
- let targetPhase: 'site' | 'structure' | 'furnish' | null = null
1840
+ const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
1841
+ const target = resolveNodeSelectionTarget(node)
1842
+ let targetPhase: 'site' | 'structure' | 'furnish' | null = target?.phase ?? null
1843
+ let targetStructureLayer = target?.structureLayer
1248
1844
  let forceSelect = false
1249
1845
 
1250
1846
  if (node.type === 'building' || node.type === 'site') {
@@ -1253,35 +1849,15 @@ export const SelectionManager = () => {
1253
1849
  }
1254
1850
  if (node.type === 'building') {
1255
1851
  targetPhase = 'structure'
1852
+ targetStructureLayer = 'elements'
1256
1853
  }
1257
- } else if (
1258
- node.type === 'wall' ||
1259
- node.type === 'fence' ||
1260
- node.type === 'column' ||
1261
- node.type === 'slab' ||
1262
- node.type === 'ceiling' ||
1263
- node.type === 'roof' ||
1264
- node.type === 'roof-segment' ||
1265
- node.type === 'stair' ||
1266
- node.type === 'stair-segment' ||
1267
- node.type === 'spawn' ||
1268
- node.type === 'window' ||
1269
- node.type === 'door'
1270
- ) {
1271
- targetPhase = 'structure'
1854
+ } else {
1272
1855
  if (node.type === 'roof-segment' && currentPhase === 'structure') {
1273
1856
  forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
1274
1857
  }
1275
1858
  if (node.type === 'stair-segment' && currentPhase === 'structure') {
1276
1859
  forceSelect = true // allow double click to dive into stair-segment even if already in structure phase
1277
1860
  }
1278
- } else if (node.type === 'item') {
1279
- const item = node as ItemNode
1280
- if (item.asset.category === 'door' || item.asset.category === 'window') {
1281
- targetPhase = 'structure'
1282
- } else {
1283
- targetPhase = 'furnish'
1284
- }
1285
1861
  }
1286
1862
 
1287
1863
  if (node.type === 'zone') {
@@ -1295,13 +1871,22 @@ export const SelectionManager = () => {
1295
1871
  useEditor.getState().setPhase(targetPhase)
1296
1872
  }
1297
1873
 
1298
- if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {
1299
- useEditor.getState().setStructureLayer('elements')
1874
+ if (
1875
+ targetPhase === 'structure' &&
1876
+ targetStructureLayer &&
1877
+ targetStructureLayer !== useEditor.getState().structureLayer
1878
+ ) {
1879
+ useEditor.getState().setStructureLayer(targetStructureLayer)
1300
1880
  }
1301
1881
 
1302
1882
  const strategy = SELECTION_STRATEGIES[targetPhase || currentPhase]
1303
1883
  if (strategy) {
1304
- strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)
1884
+ strategy.handleSelect(
1885
+ node,
1886
+ event.nativeEvent,
1887
+ modifierKeysRef.current,
1888
+ selectedIdsBeforeRouting,
1889
+ )
1305
1890
  }
1306
1891
  }
1307
1892
  }
@@ -1312,6 +1897,7 @@ export const SelectionManager = () => {
1312
1897
  'item',
1313
1898
  'column',
1314
1899
  'building',
1900
+ 'elevator',
1315
1901
  'slab',
1316
1902
  'ceiling',
1317
1903
  'roof',
@@ -1324,20 +1910,25 @@ export const SelectionManager = () => {
1324
1910
  'zone',
1325
1911
  'site',
1326
1912
  ]
1327
- allTypes.forEach((type) => {
1913
+ const registryKinds = getSelectableKinds().filter(
1914
+ (k) => !(allTypes as readonly string[]).includes(k),
1915
+ )
1916
+ const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
1917
+
1918
+ subscribedKinds.forEach((type) => {
1328
1919
  emitter.on(`${type}:enter` as any, onEnter as any)
1329
1920
  emitter.on(`${type}:leave` as any, onLeave as any)
1330
1921
  emitter.on(`${type}:double-click` as any, onDoubleClick as any)
1331
1922
  })
1332
1923
 
1333
1924
  return () => {
1334
- allTypes.forEach((type) => {
1925
+ subscribedKinds.forEach((type) => {
1335
1926
  emitter.off(`${type}:enter` as any, onEnter as any)
1336
1927
  emitter.off(`${type}:leave` as any, onLeave as any)
1337
1928
  emitter.off(`${type}:double-click` as any, onDoubleClick as any)
1338
1929
  })
1339
1930
  }
1340
- }, [curvingFence, curvingWall, mode, movingNode])
1931
+ }, [isCurveReshape, mode, movingNode])
1341
1932
 
1342
1933
  // Delete mode: click-to-delete (sledgehammer tool)
1343
1934
  useEffect(() => {
@@ -1350,11 +1941,7 @@ export const SelectionManager = () => {
1350
1941
  event.stopPropagation()
1351
1942
 
1352
1943
  // Play appropriate SFX
1353
- if (node.type === 'item') {
1354
- sfxEmitter.emit('sfx:item-delete')
1355
- } else {
1356
- sfxEmitter.emit('sfx:structure-delete')
1357
- }
1944
+ emitDeleteSFX(node.type)
1358
1945
 
1359
1946
  useScene.getState().deleteNode(node.id as AnyNodeId)
1360
1947
  if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
@@ -1385,6 +1972,7 @@ export const SelectionManager = () => {
1385
1972
  'fence',
1386
1973
  'item',
1387
1974
  'column',
1975
+ 'elevator',
1388
1976
  'slab',
1389
1977
  'ceiling',
1390
1978
  'roof',
@@ -1397,14 +1985,19 @@ export const SelectionManager = () => {
1397
1985
  'zone',
1398
1986
  ] as const
1399
1987
 
1400
- for (const type of allTypes) {
1988
+ const registryKinds = getSelectableKinds().filter(
1989
+ (k) => !(allTypes as readonly string[]).includes(k),
1990
+ )
1991
+ const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
1992
+
1993
+ for (const type of subscribedKinds) {
1401
1994
  emitter.on(`${type}:click` as any, onClick as any)
1402
1995
  emitter.on(`${type}:enter` as any, onEnter as any)
1403
1996
  emitter.on(`${type}:leave` as any, onLeave as any)
1404
1997
  }
1405
1998
 
1406
1999
  return () => {
1407
- for (const type of allTypes) {
2000
+ for (const type of subscribedKinds) {
1408
2001
  emitter.off(`${type}:click` as any, onClick as any)
1409
2002
  emitter.off(`${type}:enter` as any, onEnter as any)
1410
2003
  emitter.off(`${type}:leave` as any, onLeave as any)
@@ -1425,6 +2018,8 @@ export const SelectionManager = () => {
1425
2018
  const SelectionStateSync = () => {
1426
2019
  const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
1427
2020
  const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget)
2021
+ const roofHostDragArmedId = useEditor((s) => s.roofHostDragArmedId)
2022
+ const setRoofHostDragArmedId = useEditor((s) => s.setRoofHostDragArmedId)
1428
2023
  const singleSelectedId = useViewer((s) =>
1429
2024
  s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
1430
2025
  )
@@ -1457,6 +2052,12 @@ const SelectionStateSync = () => {
1457
2052
  })
1458
2053
  }, [])
1459
2054
 
2055
+ useEffect(() => {
2056
+ if (!roofHostDragArmedId) return
2057
+ if (singleSelectedId === roofHostDragArmedId) return
2058
+ setRoofHostDragArmedId(null)
2059
+ }, [roofHostDragArmedId, setRoofHostDragArmedId, singleSelectedId])
2060
+
1460
2061
  useEffect(() => {
1461
2062
  if (!selectedMaterialTarget) return
1462
2063
 
@@ -1468,7 +2069,8 @@ const SelectionStateSync = () => {
1468
2069
  const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
1469
2070
  if (
1470
2071
  !selectedNode ||
1471
- (selectedNode.type !== 'wall' &&
2072
+ (!nodeRegistry.get(selectedNode.type)?.capabilities?.paint &&
2073
+ selectedNode.type !== 'wall' &&
1472
2074
  selectedNode.type !== 'fence' &&
1473
2075
  selectedNode.type !== 'slab' &&
1474
2076
  selectedNode.type !== 'ceiling' &&
@@ -1492,6 +2094,7 @@ const SelectionMaterialSync = () => {
1492
2094
  const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
1493
2095
  const hoveredId = useViewer((s) => s.hoveredId)
1494
2096
  const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
2097
+ const geometryRevision = useViewer((s) => s.geometryRevision)
1495
2098
  const activeHighlightKindsRef = useRef(new Map<string, HighlightKind>())
1496
2099
  const highlightedMaterialsRef = useRef(
1497
2100
  new Map<
@@ -1568,6 +2171,7 @@ const SelectionMaterialSync = () => {
1568
2171
  }, [])
1569
2172
 
1570
2173
  useEffect(() => {
2174
+ void geometryRevision
1571
2175
  const nextHighlightKinds = new Map<string, HighlightKind>()
1572
2176
 
1573
2177
  for (const id of new Set([...selectedIds, ...previewSelectedIds])) {
@@ -1580,7 +2184,14 @@ const SelectionMaterialSync = () => {
1580
2184
 
1581
2185
  activeHighlightKindsRef.current = nextHighlightKinds
1582
2186
  syncSelectionMaterials()
1583
- }, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
2187
+ }, [
2188
+ geometryRevision,
2189
+ hoverHighlightMode,
2190
+ hoveredId,
2191
+ previewSelectedIds,
2192
+ selectedIds,
2193
+ syncSelectionMaterials,
2194
+ ])
1584
2195
 
1585
2196
  useEffect(() => {
1586
2197
  return useScene.subscribe((state, prevState) => {
@@ -1635,9 +2246,12 @@ const EditorOutlinerSync = () => {
1635
2246
  const selection = useViewer((s) => s.selection)
1636
2247
  const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
1637
2248
  const hoveredId = useViewer((s) => s.hoveredId)
2249
+ const geometryRevision = useViewer((s) => s.geometryRevision)
1638
2250
  const outliner = useViewer((s) => s.outliner)
2251
+ const nodes = useScene((s) => s.nodes)
1639
2252
 
1640
2253
  useEffect(() => {
2254
+ void geometryRevision
1641
2255
  let idsToHighlight: string[] = []
1642
2256
 
1643
2257
  // 1. Determine what should be highlighted based on Phase
@@ -1672,16 +2286,25 @@ const EditorOutlinerSync = () => {
1672
2286
  // 2. Sync with the imperative outliner arrays (mutate in place to keep references)
1673
2287
  outliner.selectedObjects.length = 0
1674
2288
  for (const id of idsToHighlight) {
2289
+ if (!nodes[id as AnyNodeId]) continue
1675
2290
  const obj = sceneRegistry.nodes.get(id)
1676
2291
  if (obj?.parent) outliner.selectedObjects.push(obj)
1677
2292
  }
1678
2293
 
1679
2294
  outliner.hoveredObjects.length = 0
1680
2295
  if (hoveredId) {
1681
- const obj = sceneRegistry.nodes.get(hoveredId)
1682
- if (obj?.parent) outliner.hoveredObjects.push(obj)
2296
+ if (!nodes[hoveredId as AnyNodeId]) {
2297
+ useViewer.setState({ hoveredId: null })
2298
+ } else {
2299
+ const hoveredNode = nodes[hoveredId as AnyNodeId]
2300
+ const obj =
2301
+ hoveredNode?.type === 'roof-segment'
2302
+ ? (getHoveredRoofSegmentOutlineProxy(hoveredId) ?? sceneRegistry.nodes.get(hoveredId))
2303
+ : sceneRegistry.nodes.get(hoveredId)
2304
+ if (obj?.parent) outliner.hoveredObjects.push(obj)
2305
+ }
1683
2306
  }
1684
- }, [phase, previewSelectedIds, selection, hoveredId, outliner])
2307
+ }, [geometryRevision, phase, previewSelectedIds, selection, hoveredId, outliner, nodes])
1685
2308
 
1686
2309
  return null
1687
2310
  }