@pascal-app/editor 0.4.0 → 0.6.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 (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. package/src/store/use-editor.tsx +125 -10
@@ -5,16 +5,24 @@ import {
5
5
  emitter,
6
6
  type ItemNode,
7
7
  type NodeEvent,
8
+ type RoofEvent,
9
+ type RoofSegmentEvent,
8
10
  resolveLevelId,
9
11
  sceneRegistry,
12
+ type StairEvent,
13
+ type StairNode,
14
+ type StairSurfaceMaterialRole,
15
+ type StairSegmentEvent,
10
16
  useScene,
17
+ type WallEvent,
18
+ type WallSurfaceSide,
11
19
  } from '@pascal-app/core'
12
20
 
13
21
  import { useViewer } from '@pascal-app/viewer'
14
22
  import { useCallback, useEffect, useRef } from 'react'
15
- import { Color, type Material, type Mesh, type Object3D } from 'three'
23
+ import { Color, type BufferGeometry, type Material, type Mesh, type Object3D } from 'three'
16
24
  import { sfxEmitter } from '../../lib/sfx-bus'
17
- import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
25
+ import useEditor, { type MaterialTargetRole, type Phase, type StructureLayer } from './../../store/use-editor'
18
26
  import { boxSelectHandled } from '../tools/select/box-select-tool'
19
27
 
20
28
  const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -26,6 +34,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => {
26
34
 
27
35
  type SelectableNodeType =
28
36
  | 'wall'
37
+ | 'fence'
29
38
  | 'item'
30
39
  | 'building'
31
40
  | 'zone'
@@ -67,6 +76,123 @@ export const resolveBuildingId = (
67
76
  return null
68
77
  }
69
78
 
79
+ function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
80
+ const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
81
+ if (materialIndex === 1) return 'interior'
82
+ if (materialIndex === 2) return 'exterior'
83
+
84
+ const normalZ = event.normal?.[2]
85
+ const localZ = event.localPosition[2]
86
+ const thickness = event.node.thickness ?? 0.1
87
+
88
+ if (
89
+ normalZ === undefined ||
90
+ Math.abs(normalZ) < 0.65 ||
91
+ Math.abs(localZ) < Math.max(thickness * 0.2, 0.01)
92
+ ) {
93
+ return null
94
+ }
95
+
96
+ const hitFace = localZ >= 0 ? 'front' : 'back'
97
+ const semantic = hitFace === 'front' ? event.node.frontSide : event.node.backSide
98
+
99
+ if (semantic === 'interior' || semantic === 'exterior') {
100
+ return semantic
101
+ }
102
+
103
+ return hitFace === 'front' ? 'interior' : 'exterior'
104
+ }
105
+
106
+ function resolveStairMaterialTarget(
107
+ event: StairEvent | StairSegmentEvent,
108
+ ): StairSurfaceMaterialRole | null {
109
+ const hitObjectName = event.nativeEvent.object?.name ?? ''
110
+ const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
111
+
112
+ if (hitObjectName.startsWith('stair-railing')) {
113
+ return 'railing'
114
+ }
115
+
116
+ if (hitObjectName.startsWith('stair-side')) {
117
+ return 'side'
118
+ }
119
+
120
+ if (materialIndex === 0) {
121
+ return 'tread'
122
+ }
123
+
124
+ if (materialIndex === 1) {
125
+ return 'side'
126
+ }
127
+
128
+ const normalY = event.normal?.[1]
129
+ if (normalY !== undefined && normalY > 0.75) {
130
+ return 'tread'
131
+ }
132
+
133
+ if (normalY !== undefined && Math.abs(normalY) <= 0.75) {
134
+ return 'side'
135
+ }
136
+
137
+ return null
138
+ }
139
+
140
+ function resolveRoofMaterialTarget(
141
+ event: RoofEvent | RoofSegmentEvent,
142
+ ): 'top' | 'edge' | 'wall' | null {
143
+ const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
144
+ if (materialIndex === 3) return 'top'
145
+ if (materialIndex === 0) return 'edge'
146
+ if (materialIndex === 1 || materialIndex === 2) return 'wall'
147
+
148
+ const normalY = event.normal?.[1]
149
+ if (normalY !== undefined && normalY > 0.35) return 'top'
150
+ if (normalY !== undefined && Math.abs(normalY) <= 0.35) return 'edge'
151
+ if (normalY !== undefined && normalY < -0.35) return 'wall'
152
+
153
+ return null
154
+ }
155
+
156
+ function getEventObject(event: NodeEvent): Object3D {
157
+ const eventWithObject = event as NodeEvent & { object?: Object3D }
158
+ return eventWithObject.object ?? event.nativeEvent.object
159
+ }
160
+
161
+ function getIntersectionMaterialIndex(
162
+ object: Object3D,
163
+ faceIndex: number | undefined,
164
+ ): number | undefined {
165
+ if (faceIndex === undefined) return undefined
166
+
167
+ const geometry = (object as Mesh).geometry as BufferGeometry | undefined
168
+ if (!geometry || geometry.groups.length === 0) return undefined
169
+
170
+ const triangleStart = faceIndex * 3
171
+ const group = geometry.groups.find(
172
+ (entry) => triangleStart >= entry.start && triangleStart < entry.start + entry.count,
173
+ )
174
+
175
+ return group?.materialIndex
176
+ }
177
+
178
+ function setSelectedMaterialTargetForNode(
179
+ node: AnyNode,
180
+ role: MaterialTargetRole | null,
181
+ ) {
182
+ if (!role) {
183
+ const currentTarget = useEditor.getState().selectedMaterialTarget
184
+ if (currentTarget?.nodeId !== node.id) {
185
+ useEditor.getState().setSelectedMaterialTarget(null)
186
+ }
187
+ return
188
+ }
189
+
190
+ useEditor.getState().setSelectedMaterialTarget({
191
+ nodeId: node.id as AnyNodeId,
192
+ role,
193
+ })
194
+ }
195
+
70
196
  const HIGHLIGHT_PROFILES = {
71
197
  delete: {
72
198
  color: new Color('#dc2626'),
@@ -142,7 +268,9 @@ function createHighlightedMaterials(
142
268
 
143
269
  function disposeHighlightedMaterials(material: Material | Material[]) {
144
270
  if (Array.isArray(material)) {
145
- material.forEach((entry) => entry.dispose())
271
+ material.forEach((entry) => {
272
+ entry.dispose()
273
+ })
146
274
  return
147
275
  }
148
276
 
@@ -184,6 +312,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
184
312
  structure: {
185
313
  types: [
186
314
  'wall',
315
+ 'fence',
187
316
  'item',
188
317
  'zone',
189
318
  'slab',
@@ -236,6 +365,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
236
365
  }
237
366
  if (
238
367
  node.type === 'wall' ||
368
+ node.type === 'fence' ||
239
369
  node.type === 'slab' ||
240
370
  node.type === 'ceiling' ||
241
371
  node.type === 'roof' ||
@@ -297,6 +427,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
297
427
 
298
428
  if (
299
429
  node.type === 'wall' ||
430
+ node.type === 'fence' ||
300
431
  node.type === 'slab' ||
301
432
  node.type === 'ceiling' ||
302
433
  node.type === 'roof' ||
@@ -340,6 +471,8 @@ export const SelectionManager = () => {
340
471
  const clickHandledRef = useRef(false)
341
472
 
342
473
  const movingNode = useEditor((s) => s.movingNode)
474
+ const curvingWall = useEditor((s) => s.curvingWall)
475
+ const curvingFence = useEditor((s) => s.curvingFence)
343
476
 
344
477
  useEffect(() => {
345
478
  setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
@@ -378,7 +511,7 @@ export const SelectionManager = () => {
378
511
 
379
512
  useEffect(() => {
380
513
  if (mode !== 'select') return
381
- if (movingNode) return
514
+ if (movingNode || curvingWall || curvingFence) return
382
515
 
383
516
  const onClick = (event: NodeEvent) => {
384
517
  // Skip if box-select just completed (drag ended over a node)
@@ -389,7 +522,8 @@ export const SelectionManager = () => {
389
522
  let currentStructureLayer = useEditor.getState().structureLayer
390
523
 
391
524
  // Auto-switch between zones, structure, and furnish when clicking elements on the same level.
392
- if (currentPhase === 'structure' || currentPhase === 'furnish') {
525
+ // Also auto-switch from site phase when clicking structural/furnish elements (e.g. 2D floorplan).
526
+ if (currentPhase === 'structure' || currentPhase === 'furnish' || currentPhase === 'site') {
393
527
  if (isNodeInCurrentLevel(node)) {
394
528
  const target = getSelectionTarget(node)
395
529
  if (target) {
@@ -431,6 +565,42 @@ export const SelectionManager = () => {
431
565
 
432
566
  activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
433
567
 
568
+ let nextMaterialTargetHandled = false
569
+
570
+ if (node.type === 'wall' && nodeToSelect.type === 'wall') {
571
+ setSelectedMaterialTargetForNode(
572
+ nodeToSelect,
573
+ resolveWallMaterialTarget(event as WallEvent),
574
+ )
575
+ nextMaterialTargetHandled = true
576
+ }
577
+
578
+ if (
579
+ (node.type === 'stair' || node.type === 'stair-segment') &&
580
+ nodeToSelect.type === 'stair'
581
+ ) {
582
+ setSelectedMaterialTargetForNode(
583
+ nodeToSelect,
584
+ resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent),
585
+ )
586
+ nextMaterialTargetHandled = true
587
+ }
588
+
589
+ if (
590
+ (node.type === 'roof' || node.type === 'roof-segment') &&
591
+ nodeToSelect.type === 'roof'
592
+ ) {
593
+ setSelectedMaterialTargetForNode(
594
+ nodeToSelect,
595
+ resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent),
596
+ )
597
+ nextMaterialTargetHandled = true
598
+ }
599
+
600
+ if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
601
+ useEditor.getState().setSelectedMaterialTarget(null)
602
+ }
603
+
434
604
  // Reset the handled flag after a short delay to allow grid:click to be ignored
435
605
  setTimeout(() => {
436
606
  clickHandledRef.current = false
@@ -440,6 +610,7 @@ export const SelectionManager = () => {
440
610
 
441
611
  const allTypes = [
442
612
  'wall',
613
+ 'fence',
443
614
  'item',
444
615
  'building',
445
616
  'zone',
@@ -462,6 +633,7 @@ export const SelectionManager = () => {
462
633
  const { phase, structureLayer } = useEditor.getState()
463
634
  const activeStrategy = SELECTION_STRATEGIES[phase]
464
635
  if (activeStrategy) activeStrategy.handleDeselect()
636
+ useEditor.getState().setSelectedMaterialTarget(null)
465
637
 
466
638
  // When deselecting from zone mode, return to structure select
467
639
  if (phase === 'structure' && structureLayer === 'zones') {
@@ -477,12 +649,12 @@ export const SelectionManager = () => {
477
649
  })
478
650
  emitter.off('grid:click', onGridClick)
479
651
  }
480
- }, [mode, movingNode])
652
+ }, [curvingFence, curvingWall, mode, movingNode])
481
653
 
482
654
  // Global double-click handler for auto-switching phases and cross-phase hover
483
655
  useEffect(() => {
484
656
  if (mode !== 'select') return
485
- if (movingNode) return
657
+ if (movingNode || curvingWall || curvingFence) return
486
658
 
487
659
  const onEnter = (event: NodeEvent) => {
488
660
  const node = event.node
@@ -534,6 +706,7 @@ export const SelectionManager = () => {
534
706
  }
535
707
  } else if (
536
708
  node.type === 'wall' ||
709
+ node.type === 'fence' ||
537
710
  node.type === 'slab' ||
538
711
  node.type === 'ceiling' ||
539
712
  node.type === 'roof' ||
@@ -583,6 +756,7 @@ export const SelectionManager = () => {
583
756
 
584
757
  const allTypes = [
585
758
  'wall',
759
+ 'fence',
586
760
  'item',
587
761
  'building',
588
762
  'slab',
@@ -609,7 +783,7 @@ export const SelectionManager = () => {
609
783
  emitter.off(`${type}:double-click` as any, onDoubleClick as any)
610
784
  })
611
785
  }
612
- }, [mode, movingNode])
786
+ }, [curvingFence, curvingWall, mode, movingNode])
613
787
 
614
788
  // Delete mode: click-to-delete (sledgehammer tool)
615
789
  useEffect(() => {
@@ -654,6 +828,7 @@ export const SelectionManager = () => {
654
828
 
655
829
  const allTypes = [
656
830
  'wall',
831
+ 'fence',
657
832
  'item',
658
833
  'slab',
659
834
  'ceiling',
@@ -692,6 +867,12 @@ export const SelectionManager = () => {
692
867
  }
693
868
 
694
869
  const SelectionStateSync = () => {
870
+ const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
871
+ const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget)
872
+ const singleSelectedId = useViewer((s) =>
873
+ s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
874
+ )
875
+
695
876
  useEffect(() => {
696
877
  return useScene.subscribe((state) => {
697
878
  const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
@@ -720,6 +901,28 @@ const SelectionStateSync = () => {
720
901
  })
721
902
  }, [])
722
903
 
904
+ useEffect(() => {
905
+ if (!selectedMaterialTarget) return
906
+
907
+ if (!singleSelectedId) {
908
+ setSelectedMaterialTarget(null)
909
+ return
910
+ }
911
+
912
+ const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
913
+ if (
914
+ !selectedNode ||
915
+ (selectedNode.type !== 'wall' && selectedNode.type !== 'stair' && selectedNode.type !== 'roof')
916
+ ) {
917
+ setSelectedMaterialTarget(null)
918
+ return
919
+ }
920
+
921
+ if (selectedMaterialTarget.nodeId !== selectedNode.id) {
922
+ setSelectedMaterialTarget(null)
923
+ }
924
+ }, [selectedMaterialTarget, setSelectedMaterialTarget, singleSelectedId])
925
+
723
926
  return null
724
927
  }
725
928
 
@@ -824,6 +1027,31 @@ const SelectionMaterialSync = () => {
824
1027
  })
825
1028
  }, [syncSelectionMaterials])
826
1029
 
1030
+ useEffect(() => {
1031
+ const restoreForCapture = () => {
1032
+ for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
1033
+ if (mesh.material === entry.highlightedMaterial) {
1034
+ mesh.material = entry.originalMaterial
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ const reapplyAfterCapture = () => {
1040
+ for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
1041
+ if (mesh.material === entry.originalMaterial) {
1042
+ mesh.material = entry.highlightedMaterial
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ emitter.on('thumbnail:before-capture', restoreForCapture)
1048
+ emitter.on('thumbnail:after-capture', reapplyAfterCapture)
1049
+ return () => {
1050
+ emitter.off('thumbnail:before-capture', restoreForCapture)
1051
+ emitter.off('thumbnail:after-capture', reapplyAfterCapture)
1052
+ }
1053
+ }, [])
1054
+
827
1055
  useEffect(() => {
828
1056
  return () => {
829
1057
  for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
@@ -883,13 +1111,13 @@ const EditorOutlinerSync = () => {
883
1111
  outliner.selectedObjects.length = 0
884
1112
  for (const id of idsToHighlight) {
885
1113
  const obj = sceneRegistry.nodes.get(id)
886
- if (obj) outliner.selectedObjects.push(obj)
1114
+ if (obj?.parent) outliner.selectedObjects.push(obj)
887
1115
  }
888
1116
 
889
1117
  outliner.hoveredObjects.length = 0
890
1118
  if (hoveredId) {
891
1119
  const obj = sceneRegistry.nodes.get(hoveredId)
892
- if (obj) outliner.hoveredObjects.push(obj)
1120
+ if (obj?.parent) outliner.hoveredObjects.push(obj)
893
1121
  }
894
1122
  }, [phase, previewSelectedIds, selection, hoveredId, outliner])
895
1123
 
@@ -20,12 +20,18 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
20
20
  }
21
21
 
22
22
  export function SiteEdgeLabels() {
23
- const rootNodeIds = useScene((state) => state.rootNodeIds)
24
- const nodes = useScene((state) => state.nodes)
23
+ // Narrow subscription to just the site node — subscribing to the full
24
+ // s.nodes dict re-rendered this on every wall/level mutation even though
25
+ // the site itself rarely changes.
26
+ const siteNode = useScene((state) => {
27
+ const firstRoot = state.rootNodeIds[0]
28
+ if (!firstRoot) return null
29
+ const node = state.nodes[firstRoot]
30
+ return node?.type === 'site' ? (node as SiteNode) : null
31
+ })
25
32
  const unit = useViewer((state) => state.unit)
26
33
  const theme = useViewer((state) => state.theme)
27
34
 
28
- const siteNode = rootNodeIds[0] ? (nodes[rootNodeIds[0]] as SiteNode) : null
29
35
  const siteNodeId = siteNode?.id
30
36
 
31
37
  const isNight = theme === 'dark'