@pascal-app/editor 0.5.1 → 0.7.0

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 (150) hide show
  1. package/package.json +12 -7
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +29 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -2,19 +2,57 @@ import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
4
  type BuildingNode,
5
+ type CeilingNode,
6
+ type ColumnNode,
5
7
  emitter,
8
+ type FenceNode,
9
+ getMaterialPresetByRef,
6
10
  type ItemNode,
7
11
  type NodeEvent,
12
+ type RoofEvent,
13
+ type RoofNode,
14
+ type RoofSegmentEvent,
8
15
  resolveLevelId,
16
+ resolveMaterial,
17
+ type SlabNode,
18
+ type StairEvent,
19
+ type StairNode,
20
+ type StairSegmentEvent,
21
+ type StairSurfaceMaterialRole,
9
22
  sceneRegistry,
10
23
  useScene,
24
+ type WallEvent,
25
+ type WallNode,
26
+ type WallSurfaceSide,
11
27
  } from '@pascal-app/core'
12
28
 
13
- import { useViewer } from '@pascal-app/viewer'
29
+ import {
30
+ applyMaterialPresetToMaterials,
31
+ createMaterial,
32
+ createMaterialFromPresetRef,
33
+ getRoofMaterialArray,
34
+ getStairBodyMaterials,
35
+ getStairRailingMaterial,
36
+ getVisibleWallMaterials,
37
+ useViewer,
38
+ } from '@pascal-app/viewer'
14
39
  import { useCallback, useEffect, useRef } from 'react'
15
- import { Color, type Material, type Mesh, type Object3D } from 'three'
40
+ import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
41
+ import {
42
+ type ActivePaintMaterial,
43
+ buildRoofSurfaceMaterialPatch,
44
+ buildSingleSurfaceMaterialPatch,
45
+ buildStairSurfaceMaterialPatch,
46
+ buildWallSurfaceMaterialPatch,
47
+ hasActivePaintMaterial,
48
+ resolveActivePaintMaterialFromSelection,
49
+ } from '../../lib/material-paint'
16
50
  import { sfxEmitter } from '../../lib/sfx-bus'
17
- import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
51
+ import useEditor, {
52
+ type MaterialTargetRole,
53
+ type Phase,
54
+ type StructureLayer,
55
+ } from './../../store/use-editor'
18
56
  import { boxSelectHandled } from '../tools/select/box-select-tool'
19
57
 
20
58
  const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -28,6 +66,7 @@ type SelectableNodeType =
28
66
  | 'wall'
29
67
  | 'fence'
30
68
  | 'item'
69
+ | 'column'
31
70
  | 'building'
32
71
  | 'zone'
33
72
  | 'slab'
@@ -36,6 +75,7 @@ type SelectableNodeType =
36
75
  | 'roof-segment'
37
76
  | 'stair'
38
77
  | 'stair-segment'
78
+ | 'spawn'
39
79
  | 'window'
40
80
  | 'door'
41
81
 
@@ -44,6 +84,16 @@ type ModifierKeys = {
44
84
  ctrl: boolean
45
85
  }
46
86
 
87
+ type PaintPreviewCleanup = () => void
88
+
89
+ type PaintInteraction = {
90
+ key: string
91
+ apply: (() => void) | null
92
+ hoverMode: HoverHighlightMode
93
+ hoveredId: AnyNodeId
94
+ preview: (() => PaintPreviewCleanup | null) | null
95
+ }
96
+
47
97
  interface SelectionStrategy {
48
98
  types: SelectableNodeType[]
49
99
  handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
@@ -68,6 +118,330 @@ export const resolveBuildingId = (
68
118
  return null
69
119
  }
70
120
 
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
+ function resolveStairMaterialTarget(
149
+ event: StairEvent | StairSegmentEvent,
150
+ ): StairSurfaceMaterialRole | null {
151
+ const hitObjectName = event.nativeEvent.object?.name ?? ''
152
+ const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
153
+
154
+ if (hitObjectName.startsWith('stair-railing')) {
155
+ return 'railing'
156
+ }
157
+
158
+ if (hitObjectName.startsWith('stair-side')) {
159
+ return 'side'
160
+ }
161
+
162
+ if (materialIndex === 0) {
163
+ return 'tread'
164
+ }
165
+
166
+ if (materialIndex === 1) {
167
+ return 'side'
168
+ }
169
+
170
+ const normalY = event.normal?.[1]
171
+ if (normalY !== undefined && normalY > 0.75) {
172
+ return 'tread'
173
+ }
174
+
175
+ if (normalY !== undefined && Math.abs(normalY) <= 0.75) {
176
+ return 'side'
177
+ }
178
+
179
+ return null
180
+ }
181
+
182
+ function resolveRoofMaterialTarget(
183
+ event: RoofEvent | RoofSegmentEvent,
184
+ ): 'top' | 'edge' | 'wall' | null {
185
+ const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
186
+ if (materialIndex === 3) return 'top'
187
+ if (materialIndex === 0) return 'edge'
188
+ if (materialIndex === 1 || materialIndex === 2) return 'wall'
189
+
190
+ const normalY = event.normal?.[1]
191
+ if (normalY !== undefined && normalY > 0.35) return 'top'
192
+ if (normalY !== undefined && Math.abs(normalY) <= 0.35) return 'edge'
193
+ if (normalY !== undefined && normalY < -0.35) return 'wall'
194
+
195
+ return null
196
+ }
197
+
198
+ function getEventObject(event: NodeEvent): Object3D {
199
+ const eventWithObject = event as NodeEvent & { object?: Object3D }
200
+ return eventWithObject.object ?? event.nativeEvent.object
201
+ }
202
+
203
+ function getIntersectionMaterialIndex(
204
+ object: Object3D,
205
+ faceIndex: number | undefined,
206
+ ): number | undefined {
207
+ if (faceIndex === undefined) return undefined
208
+
209
+ const geometry = (object as Mesh).geometry as BufferGeometry | undefined
210
+ if (!geometry || geometry.groups.length === 0) return undefined
211
+
212
+ const triangleStart = faceIndex * 3
213
+ const group = geometry.groups.find(
214
+ (entry) => triangleStart >= entry.start && triangleStart < entry.start + entry.count,
215
+ )
216
+
217
+ return group?.materialIndex
218
+ }
219
+
220
+ function getRegisteredNodeObject(nodeId: string): Object3D | null {
221
+ return sceneRegistry.nodes.get(nodeId) ?? null
222
+ }
223
+
224
+ function getRegisteredMesh(nodeId: string): Mesh | null {
225
+ const object = getRegisteredNodeObject(nodeId)
226
+ return object && (object as Mesh).isMesh ? (object as Mesh) : null
227
+ }
228
+
229
+ function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
230
+ const previousMaterial = mesh.material
231
+ mesh.material = material
232
+ return () => {
233
+ mesh.material = previousMaterial
234
+ }
235
+ }
236
+
237
+ function previewCursor(cursor: string): PaintPreviewCleanup {
238
+ const previousCursor = document.body.style.cursor
239
+ document.body.style.cursor = cursor
240
+ return () => {
241
+ document.body.style.cursor = previousCursor
242
+ }
243
+ }
244
+
245
+ function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
246
+ if (material.materialPreset) {
247
+ return createMaterialFromPresetRef(material.materialPreset)
248
+ }
249
+
250
+ if (material.material) {
251
+ return createMaterial(material.material)
252
+ }
253
+
254
+ return null
255
+ }
256
+
257
+ function applyWallPaintPreview(
258
+ node: WallNode,
259
+ role: WallSurfaceSide,
260
+ material: ActivePaintMaterial,
261
+ ): PaintPreviewCleanup | null {
262
+ const mesh = getRegisteredMesh(node.id)
263
+ if (!mesh) return null
264
+
265
+ const previewNode = {
266
+ ...node,
267
+ ...buildWallSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
268
+ }
269
+
270
+ return previewMeshMaterial(mesh, getVisibleWallMaterials(previewNode))
271
+ }
272
+
273
+ function applyRoofPaintPreview(
274
+ node: RoofNode,
275
+ role: 'top' | 'edge' | 'wall',
276
+ material: ActivePaintMaterial,
277
+ ): PaintPreviewCleanup | null {
278
+ const root = getRegisteredNodeObject(node.id)
279
+ const mesh = root?.getObjectByName('merged-roof') as Mesh | undefined
280
+ if (!mesh) return null
281
+
282
+ const previewNode = {
283
+ ...node,
284
+ ...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
285
+ }
286
+ const previewMaterial = getRoofMaterialArray(previewNode)
287
+ if (!previewMaterial) return null
288
+
289
+ return previewMeshMaterial(mesh, previewMaterial)
290
+ }
291
+
292
+ function applyStairPaintPreview(
293
+ node: StairNode,
294
+ role: StairSurfaceMaterialRole,
295
+ material: ActivePaintMaterial,
296
+ ): PaintPreviewCleanup | null {
297
+ const root = getRegisteredNodeObject(node.id)
298
+ if (!root) return null
299
+
300
+ const previewNode = {
301
+ ...node,
302
+ ...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
303
+ }
304
+ const bodyMaterials = getStairBodyMaterials(previewNode)
305
+ const railingMaterial = getStairRailingMaterial(previewNode)
306
+ const restores: PaintPreviewCleanup[] = []
307
+
308
+ root.traverse((object) => {
309
+ if (!(object as Mesh).isMesh) return
310
+ const mesh = object as Mesh
311
+ if (mesh.name.startsWith('stair-railing')) {
312
+ restores.push(previewMeshMaterial(mesh, railingMaterial))
313
+ return
314
+ }
315
+ if (Array.isArray(mesh.material) && mesh.material.length === 2) {
316
+ restores.push(previewMeshMaterial(mesh, bodyMaterials))
317
+ return
318
+ }
319
+ if (mesh.name === 'merged-stair') {
320
+ restores.push(previewMeshMaterial(mesh, bodyMaterials))
321
+ return
322
+ }
323
+ if (mesh.name.startsWith('stair-side')) {
324
+ restores.push(previewMeshMaterial(mesh, bodyMaterials[1]))
325
+ }
326
+ })
327
+
328
+ if (restores.length === 0) return null
329
+
330
+ return () => {
331
+ for (let index = restores.length - 1; index >= 0; index -= 1) {
332
+ restores[index]?.()
333
+ }
334
+ }
335
+ }
336
+
337
+ function applySingleSurfacePaintPreview(
338
+ node: FenceNode | ColumnNode | SlabNode | CeilingNode,
339
+ material: ActivePaintMaterial,
340
+ ): PaintPreviewCleanup | null {
341
+ if (node.type === 'ceiling') {
342
+ const root = getRegisteredMesh(node.id)
343
+ const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
344
+ if (!root || !overlay) return null
345
+
346
+ const previewColor =
347
+ getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
348
+ resolveMaterial(material.material).color ??
349
+ '#999999'
350
+
351
+ const previousRootMaterial = root.material
352
+ const previousOverlayMaterial = overlay.material
353
+ const rootPreviewMaterial = Array.isArray(previousRootMaterial)
354
+ ? previousRootMaterial.map((entry) => entry.clone())
355
+ : previousRootMaterial.clone()
356
+ const overlayPreviewMaterial = Array.isArray(previousOverlayMaterial)
357
+ ? previousOverlayMaterial.map((entry) => entry.clone())
358
+ : previousOverlayMaterial.clone()
359
+
360
+ const applyColor = (input: Material | Material[]) => {
361
+ const materials = Array.isArray(input) ? input : [input]
362
+ for (const entry of materials) {
363
+ const materialWithColor = entry as Material & { color?: Color; needsUpdate?: boolean }
364
+ if (materialWithColor.color instanceof Color) {
365
+ materialWithColor.color = new Color(previewColor)
366
+ }
367
+ materialWithColor.needsUpdate = true
368
+ }
369
+ }
370
+
371
+ applyColor(rootPreviewMaterial)
372
+ applyColor(overlayPreviewMaterial)
373
+ root.material = rootPreviewMaterial
374
+ overlay.material = overlayPreviewMaterial
375
+
376
+ return () => {
377
+ root.material = previousRootMaterial
378
+ overlay.material = previousOverlayMaterial
379
+ }
380
+ }
381
+
382
+ const registeredObject = getRegisteredNodeObject(node.id)
383
+ const mesh =
384
+ registeredObject && (registeredObject as Mesh).isMesh ? (registeredObject as Mesh) : null
385
+
386
+ const previewMaterial = getSingleSurfacePreviewMaterial(material)
387
+ if (!previewMaterial) return null
388
+
389
+ if (node.type === 'column') {
390
+ if (!registeredObject) return null
391
+ const restores: PaintPreviewCleanup[] = []
392
+
393
+ registeredObject.traverse((object) => {
394
+ if (!(object as Mesh).isMesh) return
395
+ restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
396
+ })
397
+
398
+ if (restores.length === 0) return null
399
+ return () => {
400
+ for (let index = restores.length - 1; index >= 0; index -= 1) {
401
+ restores[index]?.()
402
+ }
403
+ }
404
+ }
405
+
406
+ if (!mesh) return null
407
+
408
+ if (node.type === 'slab') {
409
+ const slabMaterial = previewMaterial.clone()
410
+ applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
411
+ const previewMeshMaterialInput = slabMaterial as Material & {
412
+ alphaMap?: unknown
413
+ depthWrite?: boolean
414
+ needsUpdate?: boolean
415
+ opacity?: number
416
+ side?: number
417
+ transparent?: boolean
418
+ }
419
+ previewMeshMaterialInput.transparent = false
420
+ previewMeshMaterialInput.opacity = 1
421
+ previewMeshMaterialInput.alphaMap = null
422
+ previewMeshMaterialInput.depthWrite = true
423
+ previewMeshMaterialInput.needsUpdate = true
424
+ return previewMeshMaterial(mesh, slabMaterial)
425
+ }
426
+
427
+ return previewMeshMaterial(mesh, previewMaterial)
428
+ }
429
+
430
+ function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
431
+ if (!role) {
432
+ const currentTarget = useEditor.getState().selectedMaterialTarget
433
+ if (currentTarget?.nodeId !== node.id) {
434
+ useEditor.getState().setSelectedMaterialTarget(null)
435
+ }
436
+ return
437
+ }
438
+
439
+ useEditor.getState().setSelectedMaterialTarget({
440
+ nodeId: node.id as AnyNodeId,
441
+ role,
442
+ })
443
+ }
444
+
71
445
  const HIGHLIGHT_PROFILES = {
72
446
  delete: {
73
447
  color: new Color('#dc2626'),
@@ -84,6 +458,7 @@ const HIGHLIGHT_PROFILES = {
84
458
  } as const
85
459
 
86
460
  type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
461
+ type HoverHighlightMode = 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
87
462
 
88
463
  type HighlightableMaterial = Material & {
89
464
  color?: Color
@@ -189,6 +564,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
189
564
  'wall',
190
565
  'fence',
191
566
  'item',
567
+ 'column',
192
568
  'zone',
193
569
  'slab',
194
570
  'ceiling',
@@ -196,6 +572,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
196
572
  'roof-segment',
197
573
  'stair',
198
574
  'stair-segment',
575
+ 'spawn',
199
576
  'window',
200
577
  'door',
201
578
  ],
@@ -241,12 +618,14 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
241
618
  if (
242
619
  node.type === 'wall' ||
243
620
  node.type === 'fence' ||
621
+ node.type === 'column' ||
244
622
  node.type === 'slab' ||
245
623
  node.type === 'ceiling' ||
246
624
  node.type === 'roof' ||
247
625
  node.type === 'roof-segment' ||
248
626
  node.type === 'stair' ||
249
- node.type === 'stair-segment'
627
+ node.type === 'stair-segment' ||
628
+ node.type === 'spawn'
250
629
  )
251
630
  return true
252
631
  if (node.type === 'item') {
@@ -303,12 +682,14 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
303
682
  if (
304
683
  node.type === 'wall' ||
305
684
  node.type === 'fence' ||
685
+ node.type === 'column' ||
306
686
  node.type === 'slab' ||
307
687
  node.type === 'ceiling' ||
308
688
  node.type === 'roof' ||
309
689
  node.type === 'roof-segment' ||
310
690
  node.type === 'stair' ||
311
691
  node.type === 'stair-segment' ||
692
+ node.type === 'spawn' ||
312
693
  node.type === 'window' ||
313
694
  node.type === 'door'
314
695
  ) {
@@ -346,15 +727,302 @@ export const SelectionManager = () => {
346
727
  const clickHandledRef = useRef(false)
347
728
 
348
729
  const movingNode = useEditor((s) => s.movingNode)
730
+ const curvingWall = useEditor((s) => s.curvingWall)
731
+ const curvingFence = useEditor((s) => s.curvingFence)
349
732
 
350
733
  useEffect(() => {
351
- setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
734
+ const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
735
+ setHoverHighlightMode(nextHoverMode)
352
736
 
353
737
  return () => {
354
738
  setHoverHighlightMode('default')
355
739
  }
356
740
  }, [mode, setHoverHighlightMode])
357
741
 
742
+ useEffect(() => {
743
+ if (mode !== 'material-paint') return
744
+ if (movingNode || curvingWall) return
745
+
746
+ let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null
747
+
748
+ const clearActivePreview = () => {
749
+ activePreview?.restore()
750
+ activePreview = null
751
+ }
752
+
753
+ const resolveActivePaintMaterial = () =>
754
+ useEditor.getState().activePaintMaterial ??
755
+ resolveActivePaintMaterialFromSelection({
756
+ nodes: useScene.getState().nodes,
757
+ selectedId:
758
+ useViewer.getState().selection.selectedIds.length === 1
759
+ ? (useViewer.getState().selection.selectedIds[0] ?? null)
760
+ : null,
761
+ selectedMaterialTarget: useEditor.getState().selectedMaterialTarget,
762
+ })
763
+
764
+ const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
765
+ const activePaintMaterial = resolveActivePaintMaterial()
766
+ const node = event.node
767
+
768
+ if (!isNodeInCurrentLevel(node)) return null
769
+
770
+ if (node.type === 'wall') {
771
+ const role = resolveWallMaterialTarget(event as WallEvent)
772
+ const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
773
+ return {
774
+ key: `wall:${node.id}:${role ?? 'unsupported'}`,
775
+ hoveredId: node.id as AnyNodeId,
776
+ hoverMode:
777
+ compatible && hasActivePaintMaterial(activePaintMaterial) && role
778
+ ? 'paint-ready'
779
+ : 'paint-disabled',
780
+ apply:
781
+ compatible && hasActivePaintMaterial(activePaintMaterial)
782
+ ? () => {
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
+ )
794
+ }
795
+ : null,
796
+ preview:
797
+ compatible && hasActivePaintMaterial(activePaintMaterial) && role
798
+ ? () => applyWallPaintPreview(node as WallNode, role, activePaintMaterial)
799
+ : () => previewCursor('not-allowed'),
800
+ }
801
+ }
802
+
803
+ if (node.type === 'roof' || node.type === 'roof-segment') {
804
+ const roofNode =
805
+ node.type === 'roof'
806
+ ? node
807
+ : node.parentId
808
+ ? useScene.getState().nodes[node.parentId as AnyNodeId]
809
+ : null
810
+ if (!roofNode || roofNode.type !== 'roof') return null
811
+
812
+ const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
813
+ const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
814
+ 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',
821
+ apply:
822
+ compatible && hasActivePaintMaterial(activePaintMaterial)
823
+ ? () => {
824
+ useScene
825
+ .getState()
826
+ .updateNode(
827
+ roofNode.id as AnyNodeId,
828
+ buildRoofSurfaceMaterialPatch(
829
+ roofNode as RoofNode,
830
+ role!,
831
+ activePaintMaterial.material,
832
+ activePaintMaterial.materialPreset,
833
+ ),
834
+ )
835
+ }
836
+ : null,
837
+ preview:
838
+ compatible && hasActivePaintMaterial(activePaintMaterial) && role
839
+ ? () => applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial)
840
+ : () => previewCursor('not-allowed'),
841
+ }
842
+ }
843
+
844
+ if (node.type === 'stair' || node.type === 'stair-segment') {
845
+ const stairNode =
846
+ node.type === 'stair'
847
+ ? node
848
+ : node.parentId
849
+ ? useScene.getState().nodes[node.parentId as AnyNodeId]
850
+ : null
851
+ if (!stairNode || stairNode.type !== 'stair') return null
852
+
853
+ const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent)
854
+ const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
855
+ return {
856
+ key: `stair:${stairNode.id}:${role ?? 'unsupported'}`,
857
+ hoveredId: stairNode.id as AnyNodeId,
858
+ hoverMode:
859
+ compatible && hasActivePaintMaterial(activePaintMaterial) && role
860
+ ? 'paint-ready'
861
+ : 'paint-disabled',
862
+ apply:
863
+ compatible && hasActivePaintMaterial(activePaintMaterial)
864
+ ? () => {
865
+ useScene
866
+ .getState()
867
+ .updateNode(
868
+ stairNode.id as AnyNodeId,
869
+ buildStairSurfaceMaterialPatch(
870
+ stairNode as StairNode,
871
+ role!,
872
+ activePaintMaterial.material,
873
+ activePaintMaterial.materialPreset,
874
+ ),
875
+ )
876
+ }
877
+ : null,
878
+ preview:
879
+ compatible && hasActivePaintMaterial(activePaintMaterial) && role
880
+ ? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial)
881
+ : () => previewCursor('not-allowed'),
882
+ }
883
+ }
884
+
885
+ if (
886
+ node.type === 'fence' ||
887
+ node.type === 'column' ||
888
+ node.type === 'slab' ||
889
+ node.type === 'ceiling'
890
+ ) {
891
+ const compatible = hasActivePaintMaterial(activePaintMaterial)
892
+
893
+ return {
894
+ key: `${node.type}:${node.id}:surface`,
895
+ hoveredId: node.id as AnyNodeId,
896
+ hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
897
+ apply: compatible
898
+ ? () => {
899
+ useScene
900
+ .getState()
901
+ .updateNode(
902
+ node.id as AnyNodeId,
903
+ buildSingleSurfaceMaterialPatch<
904
+ FenceNode | ColumnNode | SlabNode | CeilingNode
905
+ >(activePaintMaterial.material, activePaintMaterial.materialPreset),
906
+ )
907
+ }
908
+ : null,
909
+ preview: compatible
910
+ ? () =>
911
+ applySingleSurfacePaintPreview(
912
+ node as FenceNode | ColumnNode | SlabNode | CeilingNode,
913
+ activePaintMaterial,
914
+ )
915
+ : () => previewCursor('not-allowed'),
916
+ }
917
+ }
918
+
919
+ const disabledNodeTypes = ['item', 'window', 'door', 'zone']
920
+ if (disabledNodeTypes.includes(node.type)) {
921
+ return {
922
+ key: `${node.type}:${node.id}:unsupported`,
923
+ hoveredId: node.id as AnyNodeId,
924
+ hoverMode: 'paint-disabled',
925
+ apply: null,
926
+ preview: () => previewCursor('not-allowed'),
927
+ }
928
+ }
929
+
930
+ return null
931
+ }
932
+
933
+ const onEnter = (event: NodeEvent) => {
934
+ if (boxSelectHandled) return
935
+
936
+ const interaction = getPaintInteraction(event)
937
+ if (!interaction) return
938
+
939
+ event.stopPropagation()
940
+
941
+ if (activePreview?.key === interaction.key) {
942
+ return
943
+ }
944
+
945
+ clearActivePreview()
946
+ useViewer.setState({ hoveredId: interaction.hoveredId })
947
+ setHoverHighlightMode(interaction.hoverMode)
948
+
949
+ const restore = interaction.preview?.()
950
+ if (restore) {
951
+ activePreview = { key: interaction.key, restore }
952
+ }
953
+ }
954
+
955
+ const onLeave = (event: NodeEvent) => {
956
+ const interaction = getPaintInteraction(event)
957
+ if (!interaction) return
958
+
959
+ if (activePreview?.key !== interaction.key) {
960
+ return
961
+ }
962
+
963
+ clearActivePreview()
964
+ if (useViewer.getState().hoveredId === interaction.hoveredId) {
965
+ useViewer.setState({ hoveredId: null })
966
+ }
967
+ setHoverHighlightMode('default')
968
+ }
969
+
970
+ const onClick = (event: NodeEvent) => {
971
+ if (boxSelectHandled) return
972
+
973
+ const interaction = getPaintInteraction(event)
974
+ if (!interaction) return
975
+
976
+ event.stopPropagation()
977
+
978
+ if (!interaction.apply) {
979
+ return
980
+ }
981
+
982
+ interaction.apply()
983
+ if (activePreview?.key === interaction.key) {
984
+ activePreview = null
985
+ } else {
986
+ clearActivePreview()
987
+ }
988
+ setHoverHighlightMode(interaction.hoverMode)
989
+ }
990
+
991
+ const allTypes = [
992
+ 'wall',
993
+ 'fence',
994
+ 'item',
995
+ 'column',
996
+ 'slab',
997
+ 'ceiling',
998
+ 'roof',
999
+ 'roof-segment',
1000
+ 'stair',
1001
+ 'stair-segment',
1002
+ 'spawn',
1003
+ 'window',
1004
+ 'door',
1005
+ 'zone',
1006
+ ] as const
1007
+
1008
+ for (const type of allTypes) {
1009
+ emitter.on(`${type}:enter` as any, onEnter as any)
1010
+ emitter.on(`${type}:leave` as any, onLeave as any)
1011
+ emitter.on(`${type}:click` as any, onClick as any)
1012
+ }
1013
+
1014
+ return () => {
1015
+ for (const type of allTypes) {
1016
+ emitter.off(`${type}:enter` as any, onEnter as any)
1017
+ emitter.off(`${type}:leave` as any, onLeave as any)
1018
+ emitter.off(`${type}:click` as any, onClick as any)
1019
+ }
1020
+ clearActivePreview()
1021
+ useViewer.setState({ hoveredId: null })
1022
+ setHoverHighlightMode('default')
1023
+ }
1024
+ }, [curvingWall, mode, movingNode, setHoverHighlightMode])
1025
+
358
1026
  useEffect(() => {
359
1027
  const onKeyDown = (event: KeyboardEvent) => {
360
1028
  if (event.key === 'Meta') modifierKeysRef.current.meta = true
@@ -384,7 +1052,7 @@ export const SelectionManager = () => {
384
1052
 
385
1053
  useEffect(() => {
386
1054
  if (mode !== 'select') return
387
- if (movingNode) return
1055
+ if (movingNode || curvingWall || curvingFence) return
388
1056
 
389
1057
  const onClick = (event: NodeEvent) => {
390
1058
  // Skip if box-select just completed (drag ended over a node)
@@ -438,6 +1106,50 @@ export const SelectionManager = () => {
438
1106
 
439
1107
  activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
440
1108
 
1109
+ let nextMaterialTargetHandled = false
1110
+
1111
+ if (node.type === 'wall' && nodeToSelect.type === 'wall') {
1112
+ setSelectedMaterialTargetForNode(
1113
+ nodeToSelect,
1114
+ resolveWallMaterialTarget(event as WallEvent),
1115
+ )
1116
+ nextMaterialTargetHandled = true
1117
+ }
1118
+
1119
+ if (
1120
+ (node.type === 'stair' || node.type === 'stair-segment') &&
1121
+ nodeToSelect.type === 'stair'
1122
+ ) {
1123
+ setSelectedMaterialTargetForNode(
1124
+ nodeToSelect,
1125
+ resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent),
1126
+ )
1127
+ nextMaterialTargetHandled = true
1128
+ }
1129
+
1130
+ if (
1131
+ (node.type === 'roof' || node.type === 'roof-segment') &&
1132
+ nodeToSelect.type === 'roof'
1133
+ ) {
1134
+ setSelectedMaterialTargetForNode(
1135
+ nodeToSelect,
1136
+ resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent),
1137
+ )
1138
+ nextMaterialTargetHandled = true
1139
+ }
1140
+
1141
+ if (
1142
+ (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') &&
1143
+ nodeToSelect.type === node.type
1144
+ ) {
1145
+ setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
1146
+ nextMaterialTargetHandled = true
1147
+ }
1148
+
1149
+ if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
1150
+ useEditor.getState().setSelectedMaterialTarget(null)
1151
+ }
1152
+
441
1153
  // Reset the handled flag after a short delay to allow grid:click to be ignored
442
1154
  setTimeout(() => {
443
1155
  clickHandledRef.current = false
@@ -449,6 +1161,7 @@ export const SelectionManager = () => {
449
1161
  'wall',
450
1162
  'fence',
451
1163
  'item',
1164
+ 'column',
452
1165
  'building',
453
1166
  'zone',
454
1167
  'slab',
@@ -457,6 +1170,7 @@ export const SelectionManager = () => {
457
1170
  'roof-segment',
458
1171
  'stair',
459
1172
  'stair-segment',
1173
+ 'spawn',
460
1174
  'window',
461
1175
  'door',
462
1176
  ]
@@ -470,6 +1184,7 @@ export const SelectionManager = () => {
470
1184
  const { phase, structureLayer } = useEditor.getState()
471
1185
  const activeStrategy = SELECTION_STRATEGIES[phase]
472
1186
  if (activeStrategy) activeStrategy.handleDeselect()
1187
+ useEditor.getState().setSelectedMaterialTarget(null)
473
1188
 
474
1189
  // When deselecting from zone mode, return to structure select
475
1190
  if (phase === 'structure' && structureLayer === 'zones') {
@@ -485,12 +1200,12 @@ export const SelectionManager = () => {
485
1200
  })
486
1201
  emitter.off('grid:click', onGridClick)
487
1202
  }
488
- }, [mode, movingNode])
1203
+ }, [curvingFence, curvingWall, mode, movingNode])
489
1204
 
490
1205
  // Global double-click handler for auto-switching phases and cross-phase hover
491
1206
  useEffect(() => {
492
1207
  if (mode !== 'select') return
493
- if (movingNode) return
1208
+ if (movingNode || curvingWall || curvingFence) return
494
1209
 
495
1210
  const onEnter = (event: NodeEvent) => {
496
1211
  const node = event.node
@@ -543,12 +1258,14 @@ export const SelectionManager = () => {
543
1258
  } else if (
544
1259
  node.type === 'wall' ||
545
1260
  node.type === 'fence' ||
1261
+ node.type === 'column' ||
546
1262
  node.type === 'slab' ||
547
1263
  node.type === 'ceiling' ||
548
1264
  node.type === 'roof' ||
549
1265
  node.type === 'roof-segment' ||
550
1266
  node.type === 'stair' ||
551
1267
  node.type === 'stair-segment' ||
1268
+ node.type === 'spawn' ||
552
1269
  node.type === 'window' ||
553
1270
  node.type === 'door'
554
1271
  ) {
@@ -594,6 +1311,7 @@ export const SelectionManager = () => {
594
1311
  'wall',
595
1312
  'fence',
596
1313
  'item',
1314
+ 'column',
597
1315
  'building',
598
1316
  'slab',
599
1317
  'ceiling',
@@ -601,6 +1319,7 @@ export const SelectionManager = () => {
601
1319
  'roof-segment',
602
1320
  'stair',
603
1321
  'stair-segment',
1322
+ 'spawn',
604
1323
  'window',
605
1324
  'door',
606
1325
  'zone',
@@ -619,7 +1338,7 @@ export const SelectionManager = () => {
619
1338
  emitter.off(`${type}:double-click` as any, onDoubleClick as any)
620
1339
  })
621
1340
  }
622
- }, [mode, movingNode])
1341
+ }, [curvingFence, curvingWall, mode, movingNode])
623
1342
 
624
1343
  // Delete mode: click-to-delete (sledgehammer tool)
625
1344
  useEffect(() => {
@@ -666,6 +1385,7 @@ export const SelectionManager = () => {
666
1385
  'wall',
667
1386
  'fence',
668
1387
  'item',
1388
+ 'column',
669
1389
  'slab',
670
1390
  'ceiling',
671
1391
  'roof',
@@ -703,6 +1423,12 @@ export const SelectionManager = () => {
703
1423
  }
704
1424
 
705
1425
  const SelectionStateSync = () => {
1426
+ const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
1427
+ const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget)
1428
+ const singleSelectedId = useViewer((s) =>
1429
+ s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
1430
+ )
1431
+
706
1432
  useEffect(() => {
707
1433
  return useScene.subscribe((state) => {
708
1434
  const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
@@ -731,6 +1457,33 @@ const SelectionStateSync = () => {
731
1457
  })
732
1458
  }, [])
733
1459
 
1460
+ useEffect(() => {
1461
+ if (!selectedMaterialTarget) return
1462
+
1463
+ if (!singleSelectedId) {
1464
+ setSelectedMaterialTarget(null)
1465
+ return
1466
+ }
1467
+
1468
+ const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
1469
+ if (
1470
+ !selectedNode ||
1471
+ (selectedNode.type !== 'wall' &&
1472
+ selectedNode.type !== 'fence' &&
1473
+ selectedNode.type !== 'slab' &&
1474
+ selectedNode.type !== 'ceiling' &&
1475
+ selectedNode.type !== 'stair' &&
1476
+ selectedNode.type !== 'roof')
1477
+ ) {
1478
+ setSelectedMaterialTarget(null)
1479
+ return
1480
+ }
1481
+
1482
+ if (selectedMaterialTarget.nodeId !== selectedNode.id) {
1483
+ setSelectedMaterialTarget(null)
1484
+ }
1485
+ }, [selectedMaterialTarget, setSelectedMaterialTarget, singleSelectedId])
1486
+
734
1487
  return null
735
1488
  }
736
1489
 
@@ -830,7 +1583,8 @@ const SelectionMaterialSync = () => {
830
1583
  }, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
831
1584
 
832
1585
  useEffect(() => {
833
- return useScene.subscribe(() => {
1586
+ return useScene.subscribe((state, prevState) => {
1587
+ if (state.nodes === prevState.nodes) return
834
1588
  syncSelectionMaterials()
835
1589
  })
836
1590
  }, [syncSelectionMaterials])
@@ -919,13 +1673,13 @@ const EditorOutlinerSync = () => {
919
1673
  outliner.selectedObjects.length = 0
920
1674
  for (const id of idsToHighlight) {
921
1675
  const obj = sceneRegistry.nodes.get(id)
922
- if (obj) outliner.selectedObjects.push(obj)
1676
+ if (obj?.parent) outliner.selectedObjects.push(obj)
923
1677
  }
924
1678
 
925
1679
  outliner.hoveredObjects.length = 0
926
1680
  if (hoveredId) {
927
1681
  const obj = sceneRegistry.nodes.get(hoveredId)
928
- if (obj) outliner.hoveredObjects.push(obj)
1682
+ if (obj?.parent) outliner.hoveredObjects.push(obj)
929
1683
  }
930
1684
  }, [phase, previewSelectedIds, selection, hoveredId, outliner])
931
1685