@pascal-app/editor 0.9.1 → 0.9.2

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