@pascal-app/editor 0.8.0 → 0.9.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -6,14 +6,22 @@ import {
6
6
  type ColumnNode,
7
7
  emitter,
8
8
  type FenceNode,
9
+ getEffectiveRoofSurfaceMaterial,
10
+ getEffectiveSegmentSurfaceMaterial,
9
11
  getMaterialPresetByRef,
12
+ getRoofSegmentSurfaceY,
13
+ getSelectableKinds,
10
14
  type ItemNode,
15
+ isRegistrySelectable,
11
16
  type NodeEvent,
17
+ nodeRegistry,
12
18
  type RoofEvent,
13
19
  type RoofNode,
14
20
  type RoofSegmentEvent,
21
+ type RoofSegmentNode,
15
22
  resolveLevelId,
16
23
  resolveMaterial,
24
+ type ShelfNode,
17
25
  type SlabNode,
18
26
  type StairEvent,
19
27
  type StairNode,
@@ -21,9 +29,6 @@ import {
21
29
  type StairSurfaceMaterialRole,
22
30
  sceneRegistry,
23
31
  useScene,
24
- type WallEvent,
25
- type WallNode,
26
- type WallSurfaceSide,
27
32
  } from '@pascal-app/core'
28
33
 
29
34
  import {
@@ -33,29 +38,30 @@ import {
33
38
  getRoofMaterialArray,
34
39
  getStairBodyMaterials,
35
40
  getStairRailingMaterial,
36
- getVisibleWallMaterials,
37
41
  useViewer,
38
42
  } from '@pascal-app/viewer'
39
43
  import { useCallback, useEffect, useRef } from 'react'
40
- import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
44
+ import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
41
45
  import {
42
46
  type ActivePaintMaterial,
47
+ buildRoofSegmentSurfaceMaterialPatch,
43
48
  buildRoofSurfaceMaterialPatch,
44
49
  buildSingleSurfaceMaterialPatch,
45
50
  buildStairSurfaceMaterialPatch,
46
- buildWallSurfaceMaterialPatch,
47
51
  hasActivePaintMaterial,
48
52
  resolveActivePaintMaterialFromSelection,
49
53
  } from '../../lib/material-paint'
50
- import { sfxEmitter } from '../../lib/sfx-bus'
54
+ import { emitDeleteSFX } from '../../lib/sfx-bus'
51
55
  import useEditor, {
52
56
  type MaterialTargetRole,
53
57
  type Phase,
54
58
  type StructureLayer,
55
59
  } from './../../store/use-editor'
56
- import { boxSelectHandled } from '../tools/select/box-select-tool'
60
+ import { boxSelectHandled } from '../tools/select/box-select-state'
57
61
 
58
62
  const isNodeInCurrentLevel = (node: AnyNode): boolean => {
63
+ // Elevators are building-scoped, so they stay selectable across level filters.
64
+ if (node.type === 'elevator') return true
59
65
  const currentLevelId = useViewer.getState().selection.levelId
60
66
  if (!currentLevelId) return true // No level selected, allow all
61
67
  const nodeLevelId = resolveLevelId(node, useScene.getState().nodes)
@@ -68,6 +74,7 @@ type SelectableNodeType =
68
74
  | 'item'
69
75
  | 'column'
70
76
  | 'building'
77
+ | 'elevator'
71
78
  | 'zone'
72
79
  | 'slab'
73
80
  | 'ceiling'
@@ -118,33 +125,6 @@ export const resolveBuildingId = (
118
125
  return null
119
126
  }
120
127
 
121
- function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
122
- const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
123
- if (materialIndex === 1) return 'interior'
124
- if (materialIndex === 2) return 'exterior'
125
-
126
- const normalZ = event.normal?.[2]
127
- const localZ = event.localPosition[2]
128
- const thickness = event.node.thickness ?? 0.1
129
-
130
- if (
131
- normalZ === undefined ||
132
- Math.abs(normalZ) < 0.65 ||
133
- Math.abs(localZ) < Math.max(thickness * 0.2, 0.01)
134
- ) {
135
- return null
136
- }
137
-
138
- const hitFace = localZ >= 0 ? 'front' : 'back'
139
- const semantic = hitFace === 'front' ? event.node.frontSide : event.node.backSide
140
-
141
- if (semantic === 'interior' || semantic === 'exterior') {
142
- return semantic
143
- }
144
-
145
- return hitFace === 'front' ? 'interior' : 'exterior'
146
- }
147
-
148
128
  function resolveStairMaterialTarget(
149
129
  event: StairEvent | StairSegmentEvent,
150
130
  ): StairSurfaceMaterialRole | null {
@@ -226,6 +206,59 @@ function getRegisteredMesh(nodeId: string): Mesh | null {
226
206
  return object && (object as Mesh).isMesh ? (object as Mesh) : null
227
207
  }
228
208
 
209
+ const roofSelectionWorldPoint = new Vector3()
210
+
211
+ function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
212
+ const roof = event.node
213
+ if (roof.type !== 'roof') return null
214
+
215
+ roofSelectionWorldPoint.set(...event.position)
216
+ const nodes = useScene.getState().nodes
217
+ let firstSegment: RoofSegmentNode | null = null
218
+ let bestSegment: { node: RoofSegmentNode; score: number } | null = null
219
+
220
+ for (const childId of roof.children ?? []) {
221
+ const segment = nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
222
+ if (segment?.type !== 'roof-segment') continue
223
+
224
+ const object = getRegisteredNodeObject(segment.id)
225
+ if (!object) continue
226
+
227
+ if (!firstSegment) firstSegment = segment
228
+
229
+ object.updateWorldMatrix(true, false)
230
+ const local = object.worldToLocal(roofSelectionWorldPoint.clone())
231
+ const overhang = segment.overhang ?? 0
232
+ const halfWidth = segment.width / 2 + overhang
233
+ const halfDepth = segment.depth / 2 + overhang
234
+
235
+ if (Math.abs(local.x) > halfWidth || Math.abs(local.z) > halfDepth) {
236
+ continue
237
+ }
238
+
239
+ const score = Math.abs(local.y - getRoofSegmentSurfaceY(segment, local.x, local.z))
240
+ if (!bestSegment || score < bestSegment.score) {
241
+ bestSegment = { node: segment, score }
242
+ }
243
+ }
244
+
245
+ return bestSegment?.node ?? firstSegment
246
+ }
247
+
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
255
+
256
+ return selectedIds.some((selectedId) => {
257
+ const selectedNode = nodes[selectedId]
258
+ return selectedNode?.type === 'roof-segment' && selectedNode.parentId === segment.parentId
259
+ })
260
+ }
261
+
229
262
  function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
230
263
  const previousMaterial = mesh.material
231
264
  mesh.material = material
@@ -243,50 +276,82 @@ function previewCursor(cursor: string): PaintPreviewCleanup {
243
276
  }
244
277
 
245
278
  function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
279
+ const shading = useViewer.getState().shading
280
+
246
281
  if (material.materialPreset) {
247
- return createMaterialFromPresetRef(material.materialPreset)
282
+ return createMaterialFromPresetRef(material.materialPreset, shading)
248
283
  }
249
284
 
250
285
  if (material.material) {
251
- return createMaterial(material.material)
286
+ return createMaterial(material.material, shading)
252
287
  }
253
288
 
254
289
  return null
255
290
  }
256
291
 
257
- function applyWallPaintPreview(
258
- node: WallNode,
259
- role: WallSurfaceSide,
292
+ function applyRoofPaintPreview(
293
+ node: RoofNode,
294
+ role: 'top' | 'edge' | 'wall',
260
295
  material: ActivePaintMaterial,
261
296
  ): PaintPreviewCleanup | null {
262
- const mesh = getRegisteredMesh(node.id)
297
+ const root = getRegisteredNodeObject(node.id)
298
+ const mesh = root?.getObjectByName('merged-roof') as Mesh | undefined
263
299
  if (!mesh) return null
264
300
 
265
301
  const previewNode = {
266
302
  ...node,
267
- ...buildWallSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
303
+ ...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
268
304
  }
305
+ const previewMaterial = getRoofMaterialArray(
306
+ previewNode,
307
+ useViewer.getState().shading,
308
+ useViewer.getState().textures,
309
+ useViewer.getState().colorPreset,
310
+ useViewer.getState().sceneTheme,
311
+ )
312
+ if (!previewMaterial) return null
269
313
 
270
- return previewMeshMaterial(mesh, getVisibleWallMaterials(previewNode))
314
+ return previewMeshMaterial(mesh, previewMaterial)
271
315
  }
272
316
 
273
- function applyRoofPaintPreview(
274
- node: RoofNode,
317
+ function applyRoofSegmentPaintPreview(
318
+ node: RoofSegmentNode,
319
+ parent: RoofNode | null,
275
320
  role: 'top' | 'edge' | 'wall',
276
321
  material: ActivePaintMaterial,
277
322
  ): PaintPreviewCleanup | null {
278
- const root = getRegisteredNodeObject(node.id)
279
- const mesh = root?.getObjectByName('merged-roof') as Mesh | undefined
323
+ const mesh = getRegisteredMesh(node.id)
280
324
  if (!mesh) return null
281
325
 
282
- const previewNode = {
326
+ // Synthesise the segment node as if the paint had committed, then build
327
+ // the same 4-slot array the renderer would. Mirrors getRoofMaterialArray
328
+ // layout (slot 0 ← edge, 1 ← wall, 2 ← wall, 3 ← top) so the preview
329
+ // material lands on the matching CSG groups.
330
+ const previewNode: RoofSegmentNode = {
283
331
  ...node,
284
- ...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
332
+ ...buildRoofSegmentSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
285
333
  }
286
- const previewMaterial = getRoofMaterialArray(previewNode)
287
- if (!previewMaterial) return null
288
-
289
- return previewMeshMaterial(mesh, previewMaterial)
334
+ const resolveSlot = (r: 'top' | 'edge' | 'wall'): Material | null => {
335
+ const parentSpec = parent ? getEffectiveRoofSurfaceMaterial(parent, r) : undefined
336
+ const spec = getEffectiveSegmentSurfaceMaterial(previewNode, r, parentSpec)
337
+ if (typeof spec.materialPreset === 'string') {
338
+ const resolved = createMaterialFromPresetRef(spec.materialPreset)
339
+ if (resolved) return resolved
340
+ }
341
+ if (spec.material !== undefined) return createMaterial(spec.material)
342
+ return null
343
+ }
344
+ const edge = resolveSlot('edge')
345
+ const wall = resolveSlot('wall')
346
+ const top = resolveSlot('top')
347
+ if (!(edge || wall || top)) return null
348
+ const fallback = parent ? getRoofMaterialArray(parent) : null
349
+ const fb = (n: number) => fallback?.[n] ?? null
350
+ // Per-role only, then the parent's themed slot — matches the renderer so the
351
+ // preview never bleeds a painted surface onto the segment's other surfaces.
352
+ const arr: Material[] = [edge ?? fb(0)!, wall ?? fb(1)!, wall ?? fb(2)!, top ?? fb(3)!]
353
+ if (arr.some((m) => !m)) return null
354
+ return previewMeshMaterial(mesh, arr)
290
355
  }
291
356
 
292
357
  function applyStairPaintPreview(
@@ -301,8 +366,9 @@ function applyStairPaintPreview(
301
366
  ...node,
302
367
  ...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
303
368
  }
304
- const bodyMaterials = getStairBodyMaterials(previewNode)
305
- const railingMaterial = getStairRailingMaterial(previewNode)
369
+ const shading = useViewer.getState().shading
370
+ const bodyMaterials = getStairBodyMaterials(previewNode, shading)
371
+ const railingMaterial = getStairRailingMaterial(previewNode, shading)
306
372
  const restores: PaintPreviewCleanup[] = []
307
373
 
308
374
  root.traverse((object) => {
@@ -335,7 +401,7 @@ function applyStairPaintPreview(
335
401
  }
336
402
 
337
403
  function applySingleSurfacePaintPreview(
338
- node: FenceNode | ColumnNode | SlabNode | CeilingNode,
404
+ node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
339
405
  material: ActivePaintMaterial,
340
406
  ): PaintPreviewCleanup | null {
341
407
  if (node.type === 'ceiling') {
@@ -403,6 +469,25 @@ function applySingleSurfacePaintPreview(
403
469
  }
404
470
  }
405
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
+
406
491
  if (!mesh) return null
407
492
 
408
493
  if (node.type === 'slab') {
@@ -427,6 +512,11 @@ function applySingleSurfacePaintPreview(
427
512
  return previewMeshMaterial(mesh, previewMaterial)
428
513
  }
429
514
 
515
+ // Chimney + dormer paint dispatch lives on their NodeDefinition's
516
+ // `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/
517
+ // paint.ts). The generic registry-driven arm in this file consults
518
+ // those entries — no per-kind helpers needed here.
519
+
430
520
  function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
431
521
  if (!role) {
432
522
  const currentTarget = useEditor.getState().selectedMaterialTarget
@@ -565,6 +655,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
565
655
  'fence',
566
656
  'item',
567
657
  'column',
658
+ 'elevator',
568
659
  'zone',
569
660
  'slab',
570
661
  'ceiling',
@@ -579,11 +670,18 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
579
670
  handleSelect: (node, nativeEvent, modifierKeys) => {
580
671
  const { selection, setSelection } = useViewer.getState()
581
672
  const nodes = useScene.getState().nodes
582
- const nodeLevelId = resolveLevelId(node, nodes)
583
- const buildingId = resolveBuildingId(nodeLevelId, nodes)
673
+ const nodeLevelId = node.type === 'elevator' ? null : resolveLevelId(node, nodes)
674
+ const buildingId =
675
+ node.type === 'elevator' &&
676
+ node.parentId &&
677
+ nodes[node.parentId as AnyNodeId]?.type === 'building'
678
+ ? node.parentId
679
+ : nodeLevelId
680
+ ? resolveBuildingId(nodeLevelId, nodes)
681
+ : null
584
682
 
585
683
  const updates: any = {}
586
- if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
684
+ if (nodeLevelId && nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
587
685
  updates.levelId = nodeLevelId
588
686
  }
589
687
  if (buildingId && buildingId !== selection.buildingId) {
@@ -619,6 +717,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
619
717
  node.type === 'wall' ||
620
718
  node.type === 'fence' ||
621
719
  node.type === 'column' ||
720
+ node.type === 'elevator' ||
622
721
  node.type === 'slab' ||
623
722
  node.type === 'ceiling' ||
624
723
  node.type === 'roof' ||
@@ -636,6 +735,11 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
636
735
  }
637
736
  if (node.type === 'window' || node.type === 'door') return true
638
737
 
738
+ // Registry-driven: any kind whose NodeDefinition declares the
739
+ // `selectable` capability is also selectable in structure phase. Phase 4
740
+ // makes this the only path and deletes the hardcoded chain above.
741
+ if (isRegistrySelectable(node.type)) return true
742
+
639
743
  return false
640
744
  },
641
745
  },
@@ -664,14 +768,43 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
664
768
  },
665
769
  isValid: (node) => {
666
770
  if (!isNodeInCurrentLevel(node)) return false
667
- if (node.type !== 'item') return false
668
- const item = node as ItemNode
669
- return item.asset.category !== 'door' && item.asset.category !== 'window'
771
+ // Item: door/window-category items belong to structure phase, not furnish.
772
+ if (node.type === 'item') {
773
+ const item = node as ItemNode
774
+ return item.asset.category !== 'door' && item.asset.category !== 'window'
775
+ }
776
+ // Registry-driven kinds with `category: 'furnish'` (shelf today,
777
+ // future furniture kinds): selectable in furnish phase if their
778
+ // definition declares the `selectable` capability. Without this
779
+ // branch, shelf clicks routed to furnish phase via getSelectionTarget
780
+ // would be rejected here — single-click selection broken.
781
+ const def = nodeRegistry.get(node.type)
782
+ if (def && def.category === 'furnish' && def.capabilities.selectable) return true
783
+ return false
670
784
  },
671
785
  },
672
786
  }
673
787
 
674
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
+
675
808
  if (node.type === 'zone') {
676
809
  return {
677
810
  phase: 'structure',
@@ -683,6 +816,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
683
816
  node.type === 'wall' ||
684
817
  node.type === 'fence' ||
685
818
  node.type === 'column' ||
819
+ node.type === 'elevator' ||
686
820
  node.type === 'slab' ||
687
821
  node.type === 'ceiling' ||
688
822
  node.type === 'roof' ||
@@ -699,18 +833,16 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
699
833
  }
700
834
  }
701
835
 
702
- if (node.type === 'item') {
703
- const item = node as ItemNode
704
- if (item.asset.category === 'door' || item.asset.category === 'window') {
705
- return {
706
- phase: 'structure',
707
- structureLayer: 'elements',
708
- }
709
- }
710
-
711
- return {
712
- phase: 'furnish',
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' }
713
844
  }
845
+ return { phase: 'structure', structureLayer: 'elements' }
714
846
  }
715
847
 
716
848
  return null
@@ -762,45 +894,84 @@ export const SelectionManager = () => {
762
894
  })
763
895
 
764
896
  const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
897
+ const eraser = useEditor.getState().paintEraser
765
898
  const activePaintMaterial = resolveActivePaintMaterial()
766
899
  const node = event.node
767
900
 
768
901
  if (!isNodeInCurrentLevel(node)) return null
769
902
 
770
- if (node.type === 'wall') {
771
- const role = resolveWallMaterialTarget(event as WallEvent)
772
- const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
903
+ // The eraser clears a surface back to its default by painting with an
904
+ // empty material every `build*SurfaceMaterialPatch` interprets
905
+ // `undefined` material/preset as "reset this role". So a single spec
906
+ // with both fields undefined drives the same apply/preview paths as a
907
+ // real material; only the enabled-gate differs (no material required).
908
+ const paintEnabled = eraser || hasActivePaintMaterial(activePaintMaterial)
909
+ const paintSpec: ActivePaintMaterial = eraser
910
+ ? {
911
+ material: undefined,
912
+ materialPreset: undefined,
913
+ sourceTarget:
914
+ activePaintMaterial?.sourceTarget ?? useEditor.getState().activePaintTarget,
915
+ }
916
+ : (activePaintMaterial ?? {
917
+ material: undefined,
918
+ materialPreset: undefined,
919
+ sourceTarget: useEditor.getState().activePaintTarget,
920
+ })
921
+
922
+ // Registry-driven paint dispatch — kinds that declare
923
+ // `capabilities.paint` route hover / click / preview through
924
+ // their definition. Wall, chimney, and dormer use this; legacy
925
+ // roof / stair / single-surface arms below stay until they
926
+ // migrate too.
927
+ const paintCap = nodeRegistry.get(node.type)?.capabilities?.paint
928
+ if (paintCap) {
929
+ const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
930
+ const role = paintCap.resolveRole({
931
+ node,
932
+ materialIndex: materialIndex ?? null,
933
+ normal: event.normal,
934
+ localPosition: event.localPosition as readonly [number, number, number] | undefined,
935
+ hitObjectName: event.nativeEvent.object?.name,
936
+ })
937
+ const compatible = role !== null && paintEnabled
773
938
  return {
774
- key: `wall:${node.id}:${role ?? 'unsupported'}`,
939
+ key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
775
940
  hoveredId: node.id as AnyNodeId,
776
- hoverMode:
777
- compatible && hasActivePaintMaterial(activePaintMaterial) && role
778
- ? 'paint-ready'
779
- : 'paint-disabled',
941
+ hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
780
942
  apply:
781
- compatible && hasActivePaintMaterial(activePaintMaterial)
943
+ compatible && role
782
944
  ? () => {
783
- useScene
784
- .getState()
785
- .updateNode(
786
- node.id as AnyNodeId,
787
- buildWallSurfaceMaterialPatch(
788
- node as WallNode,
789
- role!,
790
- activePaintMaterial.material,
791
- activePaintMaterial.materialPreset,
792
- ),
793
- )
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
+ )
794
954
  }
795
955
  : null,
796
956
  preview:
797
- compatible && hasActivePaintMaterial(activePaintMaterial) && role
798
- ? () => applyWallPaintPreview(node as WallNode, role, activePaintMaterial)
957
+ compatible && role
958
+ ? () => {
959
+ const root = getRegisteredNodeObject(node.id)
960
+ if (!root) return null
961
+ return paintCap.applyPreview({
962
+ node,
963
+ role,
964
+ material: paintSpec.material,
965
+ materialPreset: paintSpec.materialPreset,
966
+ root,
967
+ })
968
+ }
799
969
  : () => previewCursor('not-allowed'),
800
970
  }
801
971
  }
802
972
 
803
973
  if (node.type === 'roof' || node.type === 'roof-segment') {
974
+ const isSegmentHit = node.type === 'roof-segment'
804
975
  const roofNode =
805
976
  node.type === 'roof'
806
977
  ? node
@@ -810,33 +981,56 @@ export const SelectionManager = () => {
810
981
  if (!roofNode || roofNode.type !== 'roof') return null
811
982
 
812
983
  const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
813
- const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
984
+ const compatible = role !== null && paintEnabled
985
+ // Painting directly on a segment (only possible in segment edit
986
+ // mode, where the per-segment mesh is visible) writes to the
987
+ // segment's own role-specific fields. Painting the merged shell
988
+ // — or a roof node directly — keeps fanning to the parent roof.
989
+ const segmentTarget = isSegmentHit ? (node as RoofSegmentNode) : null
814
990
  return {
815
- key: `roof:${roofNode.id}:${role ?? 'unsupported'}`,
816
- hoveredId: roofNode.id as AnyNodeId,
817
- hoverMode:
818
- compatible && hasActivePaintMaterial(activePaintMaterial) && role
819
- ? 'paint-ready'
820
- : 'paint-disabled',
991
+ key: `${segmentTarget ? 'roof-segment' : 'roof'}:${
992
+ segmentTarget ? segmentTarget.id : roofNode.id
993
+ }:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
994
+ hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId,
995
+ hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
821
996
  apply:
822
- compatible && hasActivePaintMaterial(activePaintMaterial)
997
+ compatible && role
823
998
  ? () => {
824
- useScene
825
- .getState()
826
- .updateNode(
999
+ const sceneState = useScene.getState()
1000
+ if (segmentTarget) {
1001
+ sceneState.updateNode(
1002
+ segmentTarget.id as AnyNodeId,
1003
+ buildRoofSegmentSurfaceMaterialPatch(
1004
+ segmentTarget,
1005
+ role,
1006
+ paintSpec.material,
1007
+ paintSpec.materialPreset,
1008
+ ),
1009
+ )
1010
+ } else {
1011
+ sceneState.updateNode(
827
1012
  roofNode.id as AnyNodeId,
828
1013
  buildRoofSurfaceMaterialPatch(
829
1014
  roofNode as RoofNode,
830
- role!,
831
- activePaintMaterial.material,
832
- activePaintMaterial.materialPreset,
1015
+ role,
1016
+ paintSpec.material,
1017
+ paintSpec.materialPreset,
833
1018
  ),
834
1019
  )
1020
+ }
835
1021
  }
836
1022
  : null,
837
1023
  preview:
838
- compatible && hasActivePaintMaterial(activePaintMaterial) && role
839
- ? () => applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial)
1024
+ compatible && role
1025
+ ? () =>
1026
+ segmentTarget
1027
+ ? applyRoofSegmentPaintPreview(
1028
+ segmentTarget,
1029
+ roofNode as RoofNode,
1030
+ role,
1031
+ paintSpec,
1032
+ )
1033
+ : applyRoofPaintPreview(roofNode as RoofNode, role, paintSpec)
840
1034
  : () => previewCursor('not-allowed'),
841
1035
  }
842
1036
  }
@@ -851,16 +1045,13 @@ export const SelectionManager = () => {
851
1045
  if (!stairNode || stairNode.type !== 'stair') return null
852
1046
 
853
1047
  const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent)
854
- const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
1048
+ const compatible = role !== null && paintEnabled
855
1049
  return {
856
- key: `stair:${stairNode.id}:${role ?? 'unsupported'}`,
1050
+ key: `stair:${stairNode.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
857
1051
  hoveredId: stairNode.id as AnyNodeId,
858
- hoverMode:
859
- compatible && hasActivePaintMaterial(activePaintMaterial) && role
860
- ? 'paint-ready'
861
- : 'paint-disabled',
1052
+ hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
862
1053
  apply:
863
- compatible && hasActivePaintMaterial(activePaintMaterial)
1054
+ compatible && role
864
1055
  ? () => {
865
1056
  useScene
866
1057
  .getState()
@@ -868,30 +1059,36 @@ export const SelectionManager = () => {
868
1059
  stairNode.id as AnyNodeId,
869
1060
  buildStairSurfaceMaterialPatch(
870
1061
  stairNode as StairNode,
871
- role!,
872
- activePaintMaterial.material,
873
- activePaintMaterial.materialPreset,
1062
+ role,
1063
+ paintSpec.material,
1064
+ paintSpec.materialPreset,
874
1065
  ),
875
1066
  )
876
1067
  }
877
1068
  : null,
878
1069
  preview:
879
- compatible && hasActivePaintMaterial(activePaintMaterial) && role
880
- ? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial)
1070
+ compatible && role
1071
+ ? () => applyStairPaintPreview(stairNode as StairNode, role, paintSpec)
881
1072
  : () => previewCursor('not-allowed'),
882
1073
  }
883
1074
  }
884
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
+
885
1081
  if (
886
1082
  node.type === 'fence' ||
887
1083
  node.type === 'column' ||
888
1084
  node.type === 'slab' ||
889
- node.type === 'ceiling'
1085
+ node.type === 'ceiling' ||
1086
+ node.type === 'shelf'
890
1087
  ) {
891
- const compatible = hasActivePaintMaterial(activePaintMaterial)
1088
+ const compatible = paintEnabled
892
1089
 
893
1090
  return {
894
- key: `${node.type}:${node.id}:surface`,
1091
+ key: `${node.type}:${node.id}:surface:${eraser ? 'erase' : 'paint'}`,
895
1092
  hoveredId: node.id as AnyNodeId,
896
1093
  hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
897
1094
  apply: compatible
@@ -901,16 +1098,16 @@ export const SelectionManager = () => {
901
1098
  .updateNode(
902
1099
  node.id as AnyNodeId,
903
1100
  buildSingleSurfaceMaterialPatch<
904
- FenceNode | ColumnNode | SlabNode | CeilingNode
905
- >(activePaintMaterial.material, activePaintMaterial.materialPreset),
1101
+ FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode
1102
+ >(paintSpec.material, paintSpec.materialPreset),
906
1103
  )
907
1104
  }
908
1105
  : null,
909
1106
  preview: compatible
910
1107
  ? () =>
911
1108
  applySingleSurfacePaintPreview(
912
- node as FenceNode | ColumnNode | SlabNode | CeilingNode,
913
- activePaintMaterial,
1109
+ node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
1110
+ paintSpec,
914
1111
  )
915
1112
  : () => previewCursor('not-allowed'),
916
1113
  }
@@ -1004,14 +1201,21 @@ export const SelectionManager = () => {
1004
1201
  'zone',
1005
1202
  ] as const
1006
1203
 
1007
- for (const type of allTypes) {
1204
+ // Registry-driven kinds get the same subscriptions as the hardcoded list,
1205
+ // so future built-in nodes don't need to edit allTypes per migration.
1206
+ const registryKinds = getSelectableKinds().filter(
1207
+ (k) => !(allTypes as readonly string[]).includes(k),
1208
+ )
1209
+ const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
1210
+
1211
+ for (const type of subscribedKinds) {
1008
1212
  emitter.on(`${type}:enter` as any, onEnter as any)
1009
1213
  emitter.on(`${type}:leave` as any, onLeave as any)
1010
1214
  emitter.on(`${type}:click` as any, onClick as any)
1011
1215
  }
1012
1216
 
1013
1217
  return () => {
1014
- for (const type of allTypes) {
1218
+ for (const type of subscribedKinds) {
1015
1219
  emitter.off(`${type}:enter` as any, onEnter as any)
1016
1220
  emitter.off(`${type}:leave` as any, onLeave as any)
1017
1221
  emitter.off(`${type}:click` as any, onClick as any)
@@ -1058,6 +1262,16 @@ export const SelectionManager = () => {
1058
1262
  if (boxSelectHandled) return
1059
1263
 
1060
1264
  const node = event.node
1265
+
1266
+ // A ceiling is selectable only through its corner handles, never via
1267
+ // the `ceiling-grid` body mesh. When the grid is revealed (ceiling
1268
+ // selected, or an item placed beneath it) a top-down click hits the
1269
+ // grid first; selecting the ceiling there both re-selects it as a
1270
+ // no-op and stops propagation, blocking the hosted item below. By
1271
+ // ignoring non-handle ceiling clicks (without stopping propagation)
1272
+ // the click falls through to the item underneath.
1273
+ if (node.type === 'ceiling' && !event.viaHandle) return
1274
+
1061
1275
  let currentPhase = useEditor.getState().phase
1062
1276
  let currentStructureLayer = useEditor.getState().structureLayer
1063
1277
 
@@ -1091,8 +1305,14 @@ export const SelectionManager = () => {
1091
1305
 
1092
1306
  let nodeToSelect = node
1093
1307
  if (node.type === 'roof-segment' && node.parentId) {
1094
- const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
1095
- if (parentNode && parentNode.type === 'roof') {
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
+ ) {
1096
1316
  nodeToSelect = parentNode
1097
1317
  }
1098
1318
  }
@@ -1103,19 +1323,44 @@ export const SelectionManager = () => {
1103
1323
  }
1104
1324
  }
1105
1325
 
1326
+ // Clicking any node (e.g. the slab surface outside a hole) exits slab
1327
+ // hole-edit mode. The hole handles + hit mesh stopPropagation, so a
1328
+ // click reaching here means the user clicked outside the hole.
1329
+ if (useEditor.getState().editingHole) {
1330
+ useEditor.getState().setEditingHole(null)
1331
+ }
1332
+
1106
1333
  activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
1107
1334
 
1108
1335
  let nextMaterialTargetHandled = false
1109
1336
 
1110
- if (node.type === 'wall' && nodeToSelect.type === 'wall') {
1111
- setSelectedMaterialTargetForNode(
1112
- nodeToSelect,
1113
- resolveWallMaterialTarget(event as WallEvent),
1114
- )
1115
- nextMaterialTargetHandled = true
1337
+ // Registry-driven paint-target resolve on click. Kinds with
1338
+ // `capabilities.paint` route through this entry — wall,
1339
+ // chimney, dormer use it today. The legacy stair / roof /
1340
+ // single-surface arms below stay until they migrate too.
1341
+ if (nodeToSelect.type === node.type) {
1342
+ const paintCap = nodeRegistry.get(node.type)?.capabilities?.paint
1343
+ if (paintCap) {
1344
+ const materialIndex = getIntersectionMaterialIndex(
1345
+ getEventObject(event),
1346
+ event.faceIndex,
1347
+ )
1348
+ const role = paintCap.resolveRole({
1349
+ node,
1350
+ materialIndex: materialIndex ?? null,
1351
+ normal: event.normal,
1352
+ localPosition: event.localPosition as readonly [number, number, number] | undefined,
1353
+ hitObjectName: event.nativeEvent.object?.name,
1354
+ })
1355
+ if (role) {
1356
+ setSelectedMaterialTargetForNode(nodeToSelect, role as MaterialTargetRole)
1357
+ nextMaterialTargetHandled = true
1358
+ }
1359
+ }
1116
1360
  }
1117
1361
 
1118
1362
  if (
1363
+ !nextMaterialTargetHandled &&
1119
1364
  (node.type === 'stair' || node.type === 'stair-segment') &&
1120
1365
  nodeToSelect.type === 'stair'
1121
1366
  ) {
@@ -1127,6 +1372,7 @@ export const SelectionManager = () => {
1127
1372
  }
1128
1373
 
1129
1374
  if (
1375
+ !nextMaterialTargetHandled &&
1130
1376
  (node.type === 'roof' || node.type === 'roof-segment') &&
1131
1377
  nodeToSelect.type === 'roof'
1132
1378
  ) {
@@ -1138,7 +1384,11 @@ export const SelectionManager = () => {
1138
1384
  }
1139
1385
 
1140
1386
  if (
1141
- (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') &&
1387
+ !nextMaterialTargetHandled &&
1388
+ (node.type === 'fence' ||
1389
+ node.type === 'slab' ||
1390
+ node.type === 'ceiling' ||
1391
+ node.type === 'shelf') &&
1142
1392
  nodeToSelect.type === node.type
1143
1393
  ) {
1144
1394
  setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
@@ -1162,6 +1412,7 @@ export const SelectionManager = () => {
1162
1412
  'item',
1163
1413
  'column',
1164
1414
  'building',
1415
+ 'elevator',
1165
1416
  'zone',
1166
1417
  'slab',
1167
1418
  'ceiling',
@@ -1173,7 +1424,14 @@ export const SelectionManager = () => {
1173
1424
  'window',
1174
1425
  'door',
1175
1426
  ]
1176
- allTypes.forEach((type) => {
1427
+ // Registry-driven kinds get the same subscriptions as the hardcoded list,
1428
+ // so future built-in nodes don't need to edit allTypes per migration.
1429
+ const registryKinds = getSelectableKinds().filter(
1430
+ (k) => !(allTypes as readonly string[]).includes(k),
1431
+ )
1432
+ const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
1433
+
1434
+ subscribedKinds.forEach((type) => {
1177
1435
  emitter.on(`${type}:click` as any, onClick as any)
1178
1436
  })
1179
1437
 
@@ -1194,7 +1452,7 @@ export const SelectionManager = () => {
1194
1452
  emitter.on('grid:click', onGridClick)
1195
1453
 
1196
1454
  return () => {
1197
- allTypes.forEach((type) => {
1455
+ subscribedKinds.forEach((type) => {
1198
1456
  emitter.off(`${type}:click` as any, onClick as any)
1199
1457
  })
1200
1458
  emitter.off('grid:click', onGridClick)
@@ -1241,7 +1499,11 @@ export const SelectionManager = () => {
1241
1499
  }
1242
1500
 
1243
1501
  const onDoubleClick = (event: NodeEvent) => {
1244
- const node = event.node
1502
+ let node = event.node
1503
+ if (node.type === 'roof') {
1504
+ node = resolveRoofSegmentSelectionTarget(event) ?? node
1505
+ }
1506
+
1245
1507
  const currentPhase = useEditor.getState().phase
1246
1508
 
1247
1509
  let targetPhase: 'site' | 'structure' | 'furnish' | null = null
@@ -1258,6 +1520,7 @@ export const SelectionManager = () => {
1258
1520
  node.type === 'wall' ||
1259
1521
  node.type === 'fence' ||
1260
1522
  node.type === 'column' ||
1523
+ node.type === 'elevator' ||
1261
1524
  node.type === 'slab' ||
1262
1525
  node.type === 'ceiling' ||
1263
1526
  node.type === 'roof' ||
@@ -1312,6 +1575,7 @@ export const SelectionManager = () => {
1312
1575
  'item',
1313
1576
  'column',
1314
1577
  'building',
1578
+ 'elevator',
1315
1579
  'slab',
1316
1580
  'ceiling',
1317
1581
  'roof',
@@ -1324,14 +1588,19 @@ export const SelectionManager = () => {
1324
1588
  'zone',
1325
1589
  'site',
1326
1590
  ]
1327
- allTypes.forEach((type) => {
1591
+ const registryKinds = getSelectableKinds().filter(
1592
+ (k) => !(allTypes as readonly string[]).includes(k),
1593
+ )
1594
+ const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
1595
+
1596
+ subscribedKinds.forEach((type) => {
1328
1597
  emitter.on(`${type}:enter` as any, onEnter as any)
1329
1598
  emitter.on(`${type}:leave` as any, onLeave as any)
1330
1599
  emitter.on(`${type}:double-click` as any, onDoubleClick as any)
1331
1600
  })
1332
1601
 
1333
1602
  return () => {
1334
- allTypes.forEach((type) => {
1603
+ subscribedKinds.forEach((type) => {
1335
1604
  emitter.off(`${type}:enter` as any, onEnter as any)
1336
1605
  emitter.off(`${type}:leave` as any, onLeave as any)
1337
1606
  emitter.off(`${type}:double-click` as any, onDoubleClick as any)
@@ -1350,11 +1619,7 @@ export const SelectionManager = () => {
1350
1619
  event.stopPropagation()
1351
1620
 
1352
1621
  // Play appropriate SFX
1353
- if (node.type === 'item') {
1354
- sfxEmitter.emit('sfx:item-delete')
1355
- } else {
1356
- sfxEmitter.emit('sfx:structure-delete')
1357
- }
1622
+ emitDeleteSFX(node.type)
1358
1623
 
1359
1624
  useScene.getState().deleteNode(node.id as AnyNodeId)
1360
1625
  if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
@@ -1385,6 +1650,7 @@ export const SelectionManager = () => {
1385
1650
  'fence',
1386
1651
  'item',
1387
1652
  'column',
1653
+ 'elevator',
1388
1654
  'slab',
1389
1655
  'ceiling',
1390
1656
  'roof',
@@ -1397,14 +1663,19 @@ export const SelectionManager = () => {
1397
1663
  'zone',
1398
1664
  ] as const
1399
1665
 
1400
- for (const type of allTypes) {
1666
+ const registryKinds = getSelectableKinds().filter(
1667
+ (k) => !(allTypes as readonly string[]).includes(k),
1668
+ )
1669
+ const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
1670
+
1671
+ for (const type of subscribedKinds) {
1401
1672
  emitter.on(`${type}:click` as any, onClick as any)
1402
1673
  emitter.on(`${type}:enter` as any, onEnter as any)
1403
1674
  emitter.on(`${type}:leave` as any, onLeave as any)
1404
1675
  }
1405
1676
 
1406
1677
  return () => {
1407
- for (const type of allTypes) {
1678
+ for (const type of subscribedKinds) {
1408
1679
  emitter.off(`${type}:click` as any, onClick as any)
1409
1680
  emitter.off(`${type}:enter` as any, onEnter as any)
1410
1681
  emitter.off(`${type}:leave` as any, onLeave as any)
@@ -1636,6 +1907,7 @@ const EditorOutlinerSync = () => {
1636
1907
  const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
1637
1908
  const hoveredId = useViewer((s) => s.hoveredId)
1638
1909
  const outliner = useViewer((s) => s.outliner)
1910
+ const nodes = useScene((s) => s.nodes)
1639
1911
 
1640
1912
  useEffect(() => {
1641
1913
  let idsToHighlight: string[] = []
@@ -1672,16 +1944,21 @@ const EditorOutlinerSync = () => {
1672
1944
  // 2. Sync with the imperative outliner arrays (mutate in place to keep references)
1673
1945
  outliner.selectedObjects.length = 0
1674
1946
  for (const id of idsToHighlight) {
1947
+ if (!nodes[id as AnyNodeId]) continue
1675
1948
  const obj = sceneRegistry.nodes.get(id)
1676
1949
  if (obj?.parent) outliner.selectedObjects.push(obj)
1677
1950
  }
1678
1951
 
1679
1952
  outliner.hoveredObjects.length = 0
1680
1953
  if (hoveredId) {
1681
- const obj = sceneRegistry.nodes.get(hoveredId)
1682
- if (obj?.parent) outliner.hoveredObjects.push(obj)
1954
+ if (!nodes[hoveredId as AnyNodeId]) {
1955
+ useViewer.setState({ hoveredId: null })
1956
+ } else {
1957
+ const obj = sceneRegistry.nodes.get(hoveredId)
1958
+ if (obj?.parent) outliner.hoveredObjects.push(obj)
1959
+ }
1683
1960
  }
1684
- }, [phase, previewSelectedIds, selection, hoveredId, outliner])
1961
+ }, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes])
1685
1962
 
1686
1963
  return null
1687
1964
  }