@pascal-app/editor 0.7.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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  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 +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  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 +5837 -12006
  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 +234 -190
  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 +435 -158
  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 +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  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-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. 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,13 +401,13 @@ 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') {
342
408
  const root = getRegisteredMesh(node.id)
343
409
  const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
344
- if (!root || !overlay) return null
410
+ if (!(root && overlay)) return null
345
411
 
346
412
  const previewColor =
347
413
  getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
@@ -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
  }
@@ -999,20 +1196,26 @@ export const SelectionManager = () => {
999
1196
  'roof-segment',
1000
1197
  'stair',
1001
1198
  'stair-segment',
1002
- 'spawn',
1003
1199
  'window',
1004
1200
  'door',
1005
1201
  'zone',
1006
1202
  ] as const
1007
1203
 
1008
- 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) {
1009
1212
  emitter.on(`${type}:enter` as any, onEnter as any)
1010
1213
  emitter.on(`${type}:leave` as any, onLeave as any)
1011
1214
  emitter.on(`${type}:click` as any, onClick as any)
1012
1215
  }
1013
1216
 
1014
1217
  return () => {
1015
- for (const type of allTypes) {
1218
+ for (const type of subscribedKinds) {
1016
1219
  emitter.off(`${type}:enter` as any, onEnter as any)
1017
1220
  emitter.off(`${type}:leave` as any, onLeave as any)
1018
1221
  emitter.off(`${type}:click` as any, onClick as any)
@@ -1059,6 +1262,16 @@ export const SelectionManager = () => {
1059
1262
  if (boxSelectHandled) return
1060
1263
 
1061
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
+
1062
1275
  let currentPhase = useEditor.getState().phase
1063
1276
  let currentStructureLayer = useEditor.getState().structureLayer
1064
1277
 
@@ -1092,8 +1305,14 @@ export const SelectionManager = () => {
1092
1305
 
1093
1306
  let nodeToSelect = node
1094
1307
  if (node.type === 'roof-segment' && node.parentId) {
1095
- const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
1096
- 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
+ ) {
1097
1316
  nodeToSelect = parentNode
1098
1317
  }
1099
1318
  }
@@ -1104,19 +1323,44 @@ export const SelectionManager = () => {
1104
1323
  }
1105
1324
  }
1106
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
+
1107
1333
  activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
1108
1334
 
1109
1335
  let nextMaterialTargetHandled = false
1110
1336
 
1111
- if (node.type === 'wall' && nodeToSelect.type === 'wall') {
1112
- setSelectedMaterialTargetForNode(
1113
- nodeToSelect,
1114
- resolveWallMaterialTarget(event as WallEvent),
1115
- )
1116
- 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
+ }
1117
1360
  }
1118
1361
 
1119
1362
  if (
1363
+ !nextMaterialTargetHandled &&
1120
1364
  (node.type === 'stair' || node.type === 'stair-segment') &&
1121
1365
  nodeToSelect.type === 'stair'
1122
1366
  ) {
@@ -1128,6 +1372,7 @@ export const SelectionManager = () => {
1128
1372
  }
1129
1373
 
1130
1374
  if (
1375
+ !nextMaterialTargetHandled &&
1131
1376
  (node.type === 'roof' || node.type === 'roof-segment') &&
1132
1377
  nodeToSelect.type === 'roof'
1133
1378
  ) {
@@ -1139,7 +1384,11 @@ export const SelectionManager = () => {
1139
1384
  }
1140
1385
 
1141
1386
  if (
1142
- (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') &&
1143
1392
  nodeToSelect.type === node.type
1144
1393
  ) {
1145
1394
  setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
@@ -1163,6 +1412,7 @@ export const SelectionManager = () => {
1163
1412
  'item',
1164
1413
  'column',
1165
1414
  'building',
1415
+ 'elevator',
1166
1416
  'zone',
1167
1417
  'slab',
1168
1418
  'ceiling',
@@ -1174,7 +1424,14 @@ export const SelectionManager = () => {
1174
1424
  'window',
1175
1425
  'door',
1176
1426
  ]
1177
- 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) => {
1178
1435
  emitter.on(`${type}:click` as any, onClick as any)
1179
1436
  })
1180
1437
 
@@ -1195,7 +1452,7 @@ export const SelectionManager = () => {
1195
1452
  emitter.on('grid:click', onGridClick)
1196
1453
 
1197
1454
  return () => {
1198
- allTypes.forEach((type) => {
1455
+ subscribedKinds.forEach((type) => {
1199
1456
  emitter.off(`${type}:click` as any, onClick as any)
1200
1457
  })
1201
1458
  emitter.off('grid:click', onGridClick)
@@ -1242,7 +1499,11 @@ export const SelectionManager = () => {
1242
1499
  }
1243
1500
 
1244
1501
  const onDoubleClick = (event: NodeEvent) => {
1245
- const node = event.node
1502
+ let node = event.node
1503
+ if (node.type === 'roof') {
1504
+ node = resolveRoofSegmentSelectionTarget(event) ?? node
1505
+ }
1506
+
1246
1507
  const currentPhase = useEditor.getState().phase
1247
1508
 
1248
1509
  let targetPhase: 'site' | 'structure' | 'furnish' | null = null
@@ -1259,6 +1520,7 @@ export const SelectionManager = () => {
1259
1520
  node.type === 'wall' ||
1260
1521
  node.type === 'fence' ||
1261
1522
  node.type === 'column' ||
1523
+ node.type === 'elevator' ||
1262
1524
  node.type === 'slab' ||
1263
1525
  node.type === 'ceiling' ||
1264
1526
  node.type === 'roof' ||
@@ -1313,6 +1575,7 @@ export const SelectionManager = () => {
1313
1575
  'item',
1314
1576
  'column',
1315
1577
  'building',
1578
+ 'elevator',
1316
1579
  'slab',
1317
1580
  'ceiling',
1318
1581
  'roof',
@@ -1325,14 +1588,19 @@ export const SelectionManager = () => {
1325
1588
  'zone',
1326
1589
  'site',
1327
1590
  ]
1328
- 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) => {
1329
1597
  emitter.on(`${type}:enter` as any, onEnter as any)
1330
1598
  emitter.on(`${type}:leave` as any, onLeave as any)
1331
1599
  emitter.on(`${type}:double-click` as any, onDoubleClick as any)
1332
1600
  })
1333
1601
 
1334
1602
  return () => {
1335
- allTypes.forEach((type) => {
1603
+ subscribedKinds.forEach((type) => {
1336
1604
  emitter.off(`${type}:enter` as any, onEnter as any)
1337
1605
  emitter.off(`${type}:leave` as any, onLeave as any)
1338
1606
  emitter.off(`${type}:double-click` as any, onDoubleClick as any)
@@ -1351,11 +1619,7 @@ export const SelectionManager = () => {
1351
1619
  event.stopPropagation()
1352
1620
 
1353
1621
  // Play appropriate SFX
1354
- if (node.type === 'item') {
1355
- sfxEmitter.emit('sfx:item-delete')
1356
- } else {
1357
- sfxEmitter.emit('sfx:structure-delete')
1358
- }
1622
+ emitDeleteSFX(node.type)
1359
1623
 
1360
1624
  useScene.getState().deleteNode(node.id as AnyNodeId)
1361
1625
  if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
@@ -1386,25 +1650,32 @@ export const SelectionManager = () => {
1386
1650
  'fence',
1387
1651
  'item',
1388
1652
  'column',
1653
+ 'elevator',
1389
1654
  'slab',
1390
1655
  'ceiling',
1391
1656
  'roof',
1392
1657
  'roof-segment',
1393
1658
  'stair',
1394
1659
  'stair-segment',
1660
+ 'spawn',
1395
1661
  'window',
1396
1662
  'door',
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
  }