@pascal-app/editor 0.6.0 → 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 (122) hide show
  1. package/package.json +9 -5
  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 +20 -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 +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
@@ -2,27 +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,
8
12
  type RoofEvent,
13
+ type RoofNode,
9
14
  type RoofSegmentEvent,
10
15
  resolveLevelId,
11
- sceneRegistry,
16
+ resolveMaterial,
17
+ type SlabNode,
12
18
  type StairEvent,
13
19
  type StairNode,
14
- type StairSurfaceMaterialRole,
15
20
  type StairSegmentEvent,
21
+ type StairSurfaceMaterialRole,
22
+ sceneRegistry,
16
23
  useScene,
17
24
  type WallEvent,
25
+ type WallNode,
18
26
  type WallSurfaceSide,
19
27
  } from '@pascal-app/core'
20
28
 
21
- 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'
22
39
  import { useCallback, useEffect, useRef } from 'react'
23
- import { Color, type BufferGeometry, 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'
24
50
  import { sfxEmitter } from '../../lib/sfx-bus'
25
- import useEditor, { type MaterialTargetRole, 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'
26
56
  import { boxSelectHandled } from '../tools/select/box-select-tool'
27
57
 
28
58
  const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -36,6 +66,7 @@ type SelectableNodeType =
36
66
  | 'wall'
37
67
  | 'fence'
38
68
  | 'item'
69
+ | 'column'
39
70
  | 'building'
40
71
  | 'zone'
41
72
  | 'slab'
@@ -44,6 +75,7 @@ type SelectableNodeType =
44
75
  | 'roof-segment'
45
76
  | 'stair'
46
77
  | 'stair-segment'
78
+ | 'spawn'
47
79
  | 'window'
48
80
  | 'door'
49
81
 
@@ -52,6 +84,16 @@ type ModifierKeys = {
52
84
  ctrl: boolean
53
85
  }
54
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
+
55
97
  interface SelectionStrategy {
56
98
  types: SelectableNodeType[]
57
99
  handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
@@ -175,10 +217,217 @@ function getIntersectionMaterialIndex(
175
217
  return group?.materialIndex
176
218
  }
177
219
 
178
- function setSelectedMaterialTargetForNode(
179
- node: AnyNode,
180
- role: MaterialTargetRole | null,
181
- ) {
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) {
182
431
  if (!role) {
183
432
  const currentTarget = useEditor.getState().selectedMaterialTarget
184
433
  if (currentTarget?.nodeId !== node.id) {
@@ -209,6 +458,7 @@ const HIGHLIGHT_PROFILES = {
209
458
  } as const
210
459
 
211
460
  type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
461
+ type HoverHighlightMode = 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
212
462
 
213
463
  type HighlightableMaterial = Material & {
214
464
  color?: Color
@@ -314,6 +564,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
314
564
  'wall',
315
565
  'fence',
316
566
  'item',
567
+ 'column',
317
568
  'zone',
318
569
  'slab',
319
570
  'ceiling',
@@ -321,6 +572,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
321
572
  'roof-segment',
322
573
  'stair',
323
574
  'stair-segment',
575
+ 'spawn',
324
576
  'window',
325
577
  'door',
326
578
  ],
@@ -366,12 +618,14 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
366
618
  if (
367
619
  node.type === 'wall' ||
368
620
  node.type === 'fence' ||
621
+ node.type === 'column' ||
369
622
  node.type === 'slab' ||
370
623
  node.type === 'ceiling' ||
371
624
  node.type === 'roof' ||
372
625
  node.type === 'roof-segment' ||
373
626
  node.type === 'stair' ||
374
- node.type === 'stair-segment'
627
+ node.type === 'stair-segment' ||
628
+ node.type === 'spawn'
375
629
  )
376
630
  return true
377
631
  if (node.type === 'item') {
@@ -428,12 +682,14 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
428
682
  if (
429
683
  node.type === 'wall' ||
430
684
  node.type === 'fence' ||
685
+ node.type === 'column' ||
431
686
  node.type === 'slab' ||
432
687
  node.type === 'ceiling' ||
433
688
  node.type === 'roof' ||
434
689
  node.type === 'roof-segment' ||
435
690
  node.type === 'stair' ||
436
691
  node.type === 'stair-segment' ||
692
+ node.type === 'spawn' ||
437
693
  node.type === 'window' ||
438
694
  node.type === 'door'
439
695
  ) {
@@ -475,13 +731,298 @@ export const SelectionManager = () => {
475
731
  const curvingFence = useEditor((s) => s.curvingFence)
476
732
 
477
733
  useEffect(() => {
478
- setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
734
+ const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
735
+ setHoverHighlightMode(nextHoverMode)
479
736
 
480
737
  return () => {
481
738
  setHoverHighlightMode('default')
482
739
  }
483
740
  }, [mode, setHoverHighlightMode])
484
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
+
485
1026
  useEffect(() => {
486
1027
  const onKeyDown = (event: KeyboardEvent) => {
487
1028
  if (event.key === 'Meta') modifierKeysRef.current.meta = true
@@ -597,6 +1138,14 @@ export const SelectionManager = () => {
597
1138
  nextMaterialTargetHandled = true
598
1139
  }
599
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
+
600
1149
  if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
601
1150
  useEditor.getState().setSelectedMaterialTarget(null)
602
1151
  }
@@ -612,6 +1161,7 @@ export const SelectionManager = () => {
612
1161
  'wall',
613
1162
  'fence',
614
1163
  'item',
1164
+ 'column',
615
1165
  'building',
616
1166
  'zone',
617
1167
  'slab',
@@ -620,6 +1170,7 @@ export const SelectionManager = () => {
620
1170
  'roof-segment',
621
1171
  'stair',
622
1172
  'stair-segment',
1173
+ 'spawn',
623
1174
  'window',
624
1175
  'door',
625
1176
  ]
@@ -707,12 +1258,14 @@ export const SelectionManager = () => {
707
1258
  } else if (
708
1259
  node.type === 'wall' ||
709
1260
  node.type === 'fence' ||
1261
+ node.type === 'column' ||
710
1262
  node.type === 'slab' ||
711
1263
  node.type === 'ceiling' ||
712
1264
  node.type === 'roof' ||
713
1265
  node.type === 'roof-segment' ||
714
1266
  node.type === 'stair' ||
715
1267
  node.type === 'stair-segment' ||
1268
+ node.type === 'spawn' ||
716
1269
  node.type === 'window' ||
717
1270
  node.type === 'door'
718
1271
  ) {
@@ -758,6 +1311,7 @@ export const SelectionManager = () => {
758
1311
  'wall',
759
1312
  'fence',
760
1313
  'item',
1314
+ 'column',
761
1315
  'building',
762
1316
  'slab',
763
1317
  'ceiling',
@@ -765,6 +1319,7 @@ export const SelectionManager = () => {
765
1319
  'roof-segment',
766
1320
  'stair',
767
1321
  'stair-segment',
1322
+ 'spawn',
768
1323
  'window',
769
1324
  'door',
770
1325
  'zone',
@@ -830,6 +1385,7 @@ export const SelectionManager = () => {
830
1385
  'wall',
831
1386
  'fence',
832
1387
  'item',
1388
+ 'column',
833
1389
  'slab',
834
1390
  'ceiling',
835
1391
  'roof',
@@ -912,7 +1468,12 @@ const SelectionStateSync = () => {
912
1468
  const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
913
1469
  if (
914
1470
  !selectedNode ||
915
- (selectedNode.type !== 'wall' && selectedNode.type !== 'stair' && selectedNode.type !== 'roof')
1471
+ (selectedNode.type !== 'wall' &&
1472
+ selectedNode.type !== 'fence' &&
1473
+ selectedNode.type !== 'slab' &&
1474
+ selectedNode.type !== 'ceiling' &&
1475
+ selectedNode.type !== 'stair' &&
1476
+ selectedNode.type !== 'roof')
916
1477
  ) {
917
1478
  setSelectedMaterialTarget(null)
918
1479
  return
@@ -1022,7 +1583,8 @@ const SelectionMaterialSync = () => {
1022
1583
  }, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
1023
1584
 
1024
1585
  useEffect(() => {
1025
- return useScene.subscribe(() => {
1586
+ return useScene.subscribe((state, prevState) => {
1587
+ if (state.nodes === prevState.nodes) return
1026
1588
  syncSelectionMaterials()
1027
1589
  })
1028
1590
  }, [syncSelectionMaterials])