@pascal-app/editor 0.9.1 → 1.0.0-beta.1

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