@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
@@ -25,17 +25,17 @@ import { PanelWrapper } from './panel-wrapper'
25
25
  import { PresetsPopover } from './presets/presets-popover'
26
26
 
27
27
  export function DoorPanel() {
28
- const selectedIds = useViewer((s) => s.selection.selectedIds)
28
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
29
29
  const setSelection = useViewer((s) => s.setSelection)
30
- const nodes = useScene((s) => s.nodes)
31
30
  const updateNode = useScene((s) => s.updateNode)
32
31
  const deleteNode = useScene((s) => s.deleteNode)
33
32
  const setMovingNode = useEditor((s) => s.setMovingNode)
34
33
 
35
34
  const adapter = usePresetsAdapter()
36
35
 
37
- const selectedId = selectedIds[0]
38
- const node = selectedId ? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined
36
+ const node = useScene((s) =>
37
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined,
38
+ )
39
39
 
40
40
  const handleUpdate = useCallback(
41
41
  (updates: Partial<DoorNode>) => {
@@ -182,7 +182,7 @@ export function DoorPanel() {
182
182
  [handleUpdate],
183
183
  )
184
184
 
185
- if (!node || node.type !== 'door' || selectedIds.length !== 1) return null
185
+ if (!(node && node.type === 'door' && selectedId)) return null
186
186
 
187
187
  const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
188
188
  const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
@@ -0,0 +1,269 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type FenceNode,
7
+ getClampedWallCurveOffset,
8
+ getMaxWallCurveOffset,
9
+ getWallCurveLength,
10
+ type MaterialSchema,
11
+ normalizeWallCurveOffset,
12
+ useScene,
13
+ } from '@pascal-app/core'
14
+ import { useViewer } from '@pascal-app/viewer'
15
+ import { Move, Spline } from 'lucide-react'
16
+ import { useCallback } from 'react'
17
+ import { sfxEmitter } from '../../../lib/sfx-bus'
18
+ import useEditor from '../../../store/use-editor'
19
+ import { ActionButton, ActionGroup } from '../controls/action-button'
20
+ import { MaterialPicker } from '../controls/material-picker'
21
+ import { PanelSection } from '../controls/panel-section'
22
+ import { SegmentedControl } from '../controls/segmented-control'
23
+ import { SliderControl } from '../controls/slider-control'
24
+ import { PanelWrapper } from './panel-wrapper'
25
+
26
+ type FenceStyleValue = 'slat' | 'rail' | 'privacy'
27
+ type FenceBaseStyleValue = 'grounded' | 'floating'
28
+
29
+ const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyleValue }[] = [
30
+ { label: 'Slat', value: 'slat' },
31
+ { label: 'Rail', value: 'rail' },
32
+ { label: 'Privacy', value: 'privacy' },
33
+ ]
34
+
35
+ const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyleValue }[] = [
36
+ { label: 'Grounded', value: 'grounded' },
37
+ { label: 'Floating', value: 'floating' },
38
+ ]
39
+
40
+ export function FencePanel() {
41
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
42
+ const selectedCount = useViewer((s) => s.selection.selectedIds.length)
43
+ const setSelection = useViewer((s) => s.setSelection)
44
+ const updateNode = useScene((s) => s.updateNode)
45
+ const setMovingNode = useEditor((s) => s.setMovingNode)
46
+ const setCurvingFence = useEditor((s) => s.setCurvingFence)
47
+
48
+ const node = useScene((s) =>
49
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
50
+ )
51
+
52
+ const handleUpdate = useCallback(
53
+ (updates: Partial<FenceNode>) => {
54
+ if (!selectedId) return
55
+ updateNode(selectedId as AnyNode['id'], updates)
56
+ useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
57
+ },
58
+ [selectedId, updateNode],
59
+ )
60
+
61
+ const handleUpdateLength = useCallback(
62
+ (newLength: number) => {
63
+ if (!node || newLength <= 0) return
64
+
65
+ const dx = node.end[0] - node.start[0]
66
+ const dz = node.end[1] - node.start[1]
67
+ const currentLength = Math.sqrt(dx * dx + dz * dz)
68
+ if (currentLength === 0) return
69
+
70
+ const dirX = dx / currentLength
71
+ const dirZ = dz / currentLength
72
+ const newEnd: [number, number] = [
73
+ node.start[0] + dirX * newLength,
74
+ node.start[1] + dirZ * newLength,
75
+ ]
76
+
77
+ handleUpdate({ end: newEnd })
78
+ },
79
+ [node, handleUpdate],
80
+ )
81
+
82
+ const handleClose = useCallback(() => {
83
+ setSelection({ selectedIds: [] })
84
+ }, [setSelection])
85
+
86
+ const handleMove = useCallback(() => {
87
+ if (!node) return
88
+ sfxEmitter.emit('sfx:item-pick')
89
+ setMovingNode(node)
90
+ setSelection({ selectedIds: [] })
91
+ }, [node, setMovingNode, setSelection])
92
+
93
+ const handleCurve = useCallback(() => {
94
+ if (!node) return
95
+ sfxEmitter.emit('sfx:item-pick')
96
+ setCurvingFence(node)
97
+ setSelection({ selectedIds: [] })
98
+ }, [node, setCurvingFence, setSelection])
99
+
100
+ const handleMaterialPresetChange = useCallback(
101
+ (materialPreset: string) => {
102
+ handleUpdate({ materialPreset, material: undefined })
103
+ },
104
+ [handleUpdate],
105
+ )
106
+
107
+ const handleCustomMaterialChange = useCallback(
108
+ (material: MaterialSchema) => {
109
+ handleUpdate({ material, materialPreset: undefined })
110
+ },
111
+ [handleUpdate],
112
+ )
113
+
114
+ if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
115
+
116
+ const length = getWallCurveLength(node)
117
+ const curveOffset = getClampedWallCurveOffset(node)
118
+ const maxCurveOffset = getMaxWallCurveOffset(node)
119
+
120
+ return (
121
+ <PanelWrapper
122
+ icon="/icons/build.png"
123
+ onClose={handleClose}
124
+ title={node.name || 'Fence'}
125
+ width={300}
126
+ >
127
+ <PanelSection title="Style">
128
+ <SegmentedControl
129
+ onChange={(value) => handleUpdate({ style: value })}
130
+ options={FENCE_STYLE_OPTIONS}
131
+ value={node.style}
132
+ />
133
+ <SegmentedControl
134
+ className="mt-2"
135
+ onChange={(value) => handleUpdate({ baseStyle: value })}
136
+ options={FENCE_BASE_STYLE_OPTIONS}
137
+ value={node.baseStyle}
138
+ />
139
+ </PanelSection>
140
+
141
+ <PanelSection title="Dimensions">
142
+ <SliderControl
143
+ label="Length"
144
+ max={50}
145
+ min={0.1}
146
+ onChange={handleUpdateLength}
147
+ precision={2}
148
+ step={0.01}
149
+ unit="m"
150
+ value={length}
151
+ />
152
+ <SliderControl
153
+ label="Curve"
154
+ max={Math.max(0.01, maxCurveOffset)}
155
+ min={-Math.max(0.01, maxCurveOffset)}
156
+ onChange={(value) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })}
157
+ precision={2}
158
+ step={0.1}
159
+ unit="m"
160
+ value={Math.round(curveOffset * 100) / 100}
161
+ />
162
+ <SliderControl
163
+ label="Height"
164
+ max={4}
165
+ min={0.4}
166
+ onChange={(value) => handleUpdate({ height: Math.max(0.4, value) })}
167
+ precision={2}
168
+ step={0.05}
169
+ unit="m"
170
+ value={node.height}
171
+ />
172
+ <SliderControl
173
+ label="Thickness"
174
+ max={0.5}
175
+ min={0.03}
176
+ onChange={(value) => handleUpdate({ thickness: Math.max(0.03, value) })}
177
+ precision={3}
178
+ step={0.005}
179
+ unit="m"
180
+ value={node.thickness}
181
+ />
182
+ </PanelSection>
183
+
184
+ <PanelSection title="Structure">
185
+ <SliderControl
186
+ label="Base Height"
187
+ max={1}
188
+ min={0.04}
189
+ onChange={(value) => handleUpdate({ baseHeight: Math.max(0.04, value) })}
190
+ precision={3}
191
+ step={0.01}
192
+ unit="m"
193
+ value={node.baseHeight}
194
+ />
195
+ <SliderControl
196
+ label="Top Rail"
197
+ max={0.25}
198
+ min={0.01}
199
+ onChange={(value) => handleUpdate({ topRailHeight: Math.max(0.01, value) })}
200
+ precision={3}
201
+ step={0.005}
202
+ unit="m"
203
+ value={node.topRailHeight}
204
+ />
205
+ <SliderControl
206
+ label="Post Spacing"
207
+ max={5}
208
+ min={0.2}
209
+ onChange={(value) => handleUpdate({ postSpacing: Math.max(0.2, value) })}
210
+ precision={2}
211
+ step={0.05}
212
+ unit="m"
213
+ value={node.postSpacing}
214
+ />
215
+ <SliderControl
216
+ label="Post Size"
217
+ max={0.4}
218
+ min={0.01}
219
+ onChange={(value) => handleUpdate({ postSize: Math.max(0.01, value) })}
220
+ precision={3}
221
+ step={0.005}
222
+ unit="m"
223
+ value={node.postSize}
224
+ />
225
+ <SliderControl
226
+ label="Ground Clear"
227
+ max={0.6}
228
+ min={0}
229
+ onChange={(value) => handleUpdate({ groundClearance: Math.max(0, value) })}
230
+ precision={3}
231
+ step={0.005}
232
+ unit="m"
233
+ value={node.groundClearance}
234
+ />
235
+ <SliderControl
236
+ label="Edge Inset"
237
+ max={0.25}
238
+ min={0.005}
239
+ onChange={(value) => handleUpdate({ edgeInset: Math.max(0.005, value) })}
240
+ precision={3}
241
+ step={0.005}
242
+ unit="m"
243
+ value={node.edgeInset}
244
+ />
245
+ </PanelSection>
246
+
247
+ <PanelSection title="Material">
248
+ <MaterialPicker
249
+ nodeType="fence"
250
+ onChange={handleCustomMaterialChange}
251
+ onSelectMaterialPreset={handleMaterialPresetChange}
252
+ selectedMaterialPreset={node.materialPreset}
253
+ value={node.material}
254
+ />
255
+ </PanelSection>
256
+
257
+ <PanelSection title="Actions">
258
+ <ActionGroup>
259
+ <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
260
+ <ActionButton
261
+ icon={<Spline className="h-3.5 w-3.5" />}
262
+ label="Curve"
263
+ onClick={handleCurve}
264
+ />
265
+ </ActionGroup>
266
+ </PanelSection>
267
+ </PanelWrapper>
268
+ )
269
+ }
@@ -14,15 +14,15 @@ import { CollectionsPopover } from './collections/collections-popover'
14
14
  import { PanelWrapper } from './panel-wrapper'
15
15
 
16
16
  export function ItemPanel() {
17
- const selectedIds = useViewer((s) => s.selection.selectedIds)
17
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
18
18
  const setSelection = useViewer((s) => s.setSelection)
19
- const nodes = useScene((s) => s.nodes)
20
19
  const updateNode = useScene((s) => s.updateNode)
21
20
  const deleteNode = useScene((s) => s.deleteNode)
22
21
  const setMovingNode = useEditor((s) => s.setMovingNode)
23
22
 
24
- const selectedId = selectedIds[0]
25
- const node = selectedId ? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined
23
+ const node = useScene((s) =>
24
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined,
25
+ )
26
26
 
27
27
  const [uniformScale, setUniformScale] = useState(true)
28
28
 
@@ -75,7 +75,7 @@ export function ItemPanel() {
75
75
  setSelection({ selectedIds: [] })
76
76
  }, [selectedId, deleteNode, setSelection])
77
77
 
78
- if (!node || node.type !== 'item' || selectedIds.length !== 1) return null
78
+ if (!(node && node.type === 'item' && selectedId)) return null
79
79
 
80
80
  return (
81
81
  <PanelWrapper
@@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer'
5
5
  import useEditor from '../../../store/use-editor'
6
6
  import { CeilingPanel } from './ceiling-panel'
7
7
  import { DoorPanel } from './door-panel'
8
+ import { FencePanel } from './fence-panel'
8
9
  import { ItemPanel } from './item-panel'
9
10
  import { ReferencePanel } from './reference-panel'
10
11
  import { RoofPanel } from './roof-panel'
@@ -18,7 +19,13 @@ import { WindowPanel } from './window-panel'
18
19
  export function PanelManager() {
19
20
  const selectedIds = useViewer((s) => s.selection.selectedIds)
20
21
  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
21
- const nodes = useScene((s) => s.nodes)
22
+ // Only subscribe to the *type* of the single-selected node — string primitive
23
+ // so we don't re-render on unrelated scene mutations.
24
+ const selectedNodeType = useScene((s) => {
25
+ if (selectedIds.length !== 1) return null
26
+ const id = selectedIds[0]
27
+ return id ? (s.nodes[id as AnyNodeId]?.type ?? null) : null
28
+ })
22
29
 
23
30
  // Show reference panel if a reference is selected
24
31
  if (selectedReferenceId) {
@@ -26,32 +33,30 @@ export function PanelManager() {
26
33
  }
27
34
 
28
35
  // Show appropriate panel based on selected node type
29
- if (selectedIds.length === 1) {
30
- const selectedNode = selectedIds[0]
31
- const node = nodes[selectedNode as AnyNodeId]
32
- if (node) {
33
- switch (node.type) {
34
- case 'item':
35
- return <ItemPanel />
36
- case 'roof':
37
- return <RoofPanel />
38
- case 'roof-segment':
39
- return <RoofSegmentPanel />
40
- case 'stair':
41
- return <StairPanel />
42
- case 'stair-segment':
43
- return <StairSegmentPanel />
44
- case 'slab':
45
- return <SlabPanel />
46
- case 'ceiling':
47
- return <CeilingPanel />
48
- case 'wall':
49
- return <WallPanel />
50
- case 'door':
51
- return <DoorPanel />
52
- case 'window':
53
- return <WindowPanel />
54
- }
36
+ if (selectedNodeType) {
37
+ switch (selectedNodeType) {
38
+ case 'item':
39
+ return <ItemPanel />
40
+ case 'roof':
41
+ return <RoofPanel />
42
+ case 'roof-segment':
43
+ return <RoofSegmentPanel />
44
+ case 'stair':
45
+ return <StairPanel />
46
+ case 'stair-segment':
47
+ return <StairSegmentPanel />
48
+ case 'slab':
49
+ return <SlabPanel />
50
+ case 'ceiling':
51
+ return <CeilingPanel />
52
+ case 'wall':
53
+ return <WallPanel />
54
+ case 'fence':
55
+ return <FencePanel />
56
+ case 'door':
57
+ return <DoorPanel />
58
+ case 'window':
59
+ return <WindowPanel />
55
60
  }
56
61
  }
57
62
 
@@ -15,12 +15,13 @@ type ReferenceNode = ScanNode | GuideNode
15
15
  export function ReferencePanel() {
16
16
  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
17
17
  const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
18
- const nodes = useScene((s) => s.nodes)
19
18
  const updateNode = useScene((s) => s.updateNode)
20
19
 
21
- const node = selectedReferenceId
22
- ? (nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
23
- : undefined
20
+ const node = useScene((s) =>
21
+ selectedReferenceId
22
+ ? (s.nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
23
+ : undefined,
24
+ )
24
25
 
25
26
  const handleUpdate = useCallback(
26
27
  (updates: Partial<ReferenceNode>) => {
@@ -3,8 +3,10 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
+ getEffectiveRoofSurfaceMaterial,
6
7
  type MaterialSchema,
7
8
  type RoofNode,
9
+ type RoofSurfaceMaterialRole,
8
10
  RoofNode as RoofNodeSchema,
9
11
  type RoofSegmentNode,
10
12
  RoofSegmentNode as RoofSegmentNodeSchema,
@@ -13,25 +15,61 @@ import {
13
15
  import { useViewer } from '@pascal-app/viewer'
14
16
  import { Copy, Move, Plus, Trash2 } from 'lucide-react'
15
17
  import { useCallback } from 'react'
18
+ import { useShallow } from 'zustand/react/shallow'
16
19
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
20
  import useEditor from '../../../store/use-editor'
18
21
  import { ActionButton, ActionGroup } from '../controls/action-button'
19
22
  import { MaterialPicker } from '../controls/material-picker'
20
- import { MetricControl } from '../controls/metric-control'
21
23
  import { PanelSection } from '../controls/panel-section'
22
24
  import { SliderControl } from '../controls/slider-control'
23
25
  import { PanelWrapper } from './panel-wrapper'
24
26
 
27
+ function buildRoofSurfaceMaterialPatch(
28
+ node: RoofNode,
29
+ targetRole: RoofSurfaceMaterialRole,
30
+ material: MaterialSchema | undefined,
31
+ materialPreset: string | undefined,
32
+ ): Partial<RoofNode> {
33
+ const nextSurfaceMaterial = { material, materialPreset }
34
+ const nextTop =
35
+ targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
36
+ const nextEdge =
37
+ targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
38
+ const nextWall =
39
+ targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
40
+
41
+ return {
42
+ topMaterial: nextTop.material,
43
+ topMaterialPreset: nextTop.materialPreset,
44
+ edgeMaterial: nextEdge.material,
45
+ edgeMaterialPreset: nextEdge.materialPreset,
46
+ wallMaterial: nextWall.material,
47
+ wallMaterialPreset: nextWall.materialPreset,
48
+ material: undefined,
49
+ materialPreset: undefined,
50
+ }
51
+ }
52
+
25
53
  export function RoofPanel() {
26
- const selectedIds = useViewer((s) => s.selection.selectedIds)
54
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
27
55
  const setSelection = useViewer((s) => s.setSelection)
28
- const nodes = useScene((s) => s.nodes)
29
56
  const updateNode = useScene((s) => s.updateNode)
30
57
  const createNode = useScene((s) => s.createNode)
31
58
  const setMovingNode = useEditor((s) => s.setMovingNode)
59
+ const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
32
60
 
33
- const selectedId = selectedIds[0]
34
- const node = selectedId ? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined
61
+ const node = useScene((s) =>
62
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
63
+ )
64
+ // Shallow selector — only re-renders when the segment list content changes.
65
+ const segments = useScene(
66
+ useShallow((s) => {
67
+ if (!node) return []
68
+ return (node.children ?? [])
69
+ .map((childId) => s.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
70
+ .filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
71
+ }),
72
+ )
35
73
 
36
74
  const handleUpdate = useCallback(
37
75
  (updates: Partial<RoofNode>) => {
@@ -41,11 +79,31 @@ export function RoofPanel() {
41
79
  [selectedId, updateNode],
42
80
  )
43
81
 
44
- const handleMaterialChange = useCallback(
82
+ const materialTargetRole =
83
+ selectedMaterialTarget &&
84
+ selectedMaterialTarget.nodeId === node?.id &&
85
+ (selectedMaterialTarget.role === 'top' ||
86
+ selectedMaterialTarget.role === 'edge' ||
87
+ selectedMaterialTarget.role === 'wall')
88
+ ? selectedMaterialTarget.role
89
+ : null
90
+ const materialPickerValue =
91
+ node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
92
+
93
+ const handleTargetedMaterialChange = useCallback(
45
94
  (material: MaterialSchema) => {
46
- handleUpdate({ material })
95
+ if (!node || !materialTargetRole) return
96
+ handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
47
97
  },
48
- [handleUpdate],
98
+ [handleUpdate, materialTargetRole, node],
99
+ )
100
+
101
+ const handleTargetedMaterialPresetChange = useCallback(
102
+ (materialPreset: string) => {
103
+ if (!node || !materialTargetRole) return
104
+ handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
105
+ },
106
+ [handleUpdate, materialTargetRole, node],
49
107
  )
50
108
 
51
109
  const handleClose = useCallback(() => {
@@ -131,11 +189,7 @@ export function RoofPanel() {
131
189
  setSelection({ selectedIds: [] })
132
190
  }, [selectedId, node, setSelection])
133
191
 
134
- if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
135
-
136
- const segments = (node.children ?? [])
137
- .map((childId) => nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
138
- .filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
192
+ if (!(node && node.type === 'roof' && selectedId)) return null
139
193
 
140
194
  return (
141
195
  <PanelWrapper
@@ -158,15 +212,17 @@ export function RoofPanel() {
158
212
  </button>
159
213
  ))}
160
214
  </div>
161
- <ActionButton
162
- icon={<Plus className="h-3.5 w-3.5" />}
163
- label="Add Segment"
164
- onClick={handleAddSegment}
165
- />
215
+ <ActionGroup>
216
+ <ActionButton
217
+ icon={<Plus className="h-3.5 w-3.5" />}
218
+ label="Add Segment"
219
+ onClick={handleAddSegment}
220
+ />
221
+ </ActionGroup>
166
222
  </PanelSection>
167
223
 
168
224
  <PanelSection title="Position">
169
- <MetricControl
225
+ <SliderControl
170
226
  label="X"
171
227
  max={50}
172
228
  min={-50}
@@ -180,7 +236,7 @@ export function RoofPanel() {
180
236
  unit="m"
181
237
  value={Math.round(node.position[0] * 100) / 100}
182
238
  />
183
- <MetricControl
239
+ <SliderControl
184
240
  label="Y"
185
241
  max={50}
186
242
  min={-50}
@@ -194,7 +250,7 @@ export function RoofPanel() {
194
250
  unit="m"
195
251
  value={Math.round(node.position[1] * 100) / 100}
196
252
  />
197
- <MetricControl
253
+ <SliderControl
198
254
  label="Z"
199
255
  max={50}
200
256
  min={-50}
@@ -255,7 +311,20 @@ export function RoofPanel() {
255
311
  </ActionGroup>
256
312
  </PanelSection>
257
313
  <PanelSection title="Material">
258
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
314
+ {!materialTargetRole ? (
315
+ <div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
316
+ Click the roof surface you want to edit. Materials apply to one target at a time.
317
+ </div>
318
+ ) : null}
319
+ <MaterialPicker
320
+ disabled={!materialTargetRole}
321
+ hideSideControl
322
+ nodeType="roof"
323
+ onChange={handleTargetedMaterialChange}
324
+ onSelectMaterialPreset={handleTargetedMaterialPresetChange}
325
+ selectedMaterialPreset={materialPickerValue.materialPreset}
326
+ value={materialPickerValue.material}
327
+ />
259
328
  </PanelSection>
260
329
  </PanelWrapper>
261
330
  )