@pascal-app/editor 0.5.1 → 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 (79) 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 +255 -34
  4. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  5. package/src/components/editor/floorplan-panel.tsx +1323 -713
  6. package/src/components/editor/index.tsx +2 -0
  7. package/src/components/editor/node-action-menu.tsx +14 -1
  8. package/src/components/editor/selection-manager.tsx +200 -8
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +319 -157
  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/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  15. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  16. package/src/components/tools/door/door-tool.tsx +12 -0
  17. package/src/components/tools/door/move-door-tool.tsx +10 -0
  18. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  19. package/src/components/tools/fence/fence-drafting.ts +19 -7
  20. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  21. package/src/components/tools/fence/move-fence-tool.tsx +8 -0
  22. package/src/components/tools/item/move-tool.tsx +9 -0
  23. package/src/components/tools/item/placement-math.ts +14 -6
  24. package/src/components/tools/item/placement-strategies.ts +2 -2
  25. package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
  26. package/src/components/tools/roof/move-roof-tool.tsx +89 -28
  27. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  28. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  29. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  30. package/src/components/tools/stair/stair-tool.tsx +11 -3
  31. package/src/components/tools/tool-manager.tsx +12 -0
  32. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  33. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  34. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  35. package/src/components/tools/wall/wall-drafting.ts +331 -9
  36. package/src/components/tools/window/move-window-tool.tsx +10 -0
  37. package/src/components/tools/window/window-tool.tsx +12 -0
  38. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  39. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  40. package/src/components/ui/command-palette/index.tsx +0 -1
  41. package/src/components/ui/controls/material-picker.tsx +127 -94
  42. package/src/components/ui/controls/slider-control.tsx +28 -14
  43. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  44. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  45. package/src/components/ui/panels/door-panel.tsx +5 -5
  46. package/src/components/ui/panels/fence-panel.tsx +97 -12
  47. package/src/components/ui/panels/item-panel.tsx +5 -5
  48. package/src/components/ui/panels/panel-manager.tsx +31 -29
  49. package/src/components/ui/panels/reference-panel.tsx +5 -4
  50. package/src/components/ui/panels/roof-panel.tsx +91 -22
  51. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  52. package/src/components/ui/panels/slab-panel.tsx +63 -15
  53. package/src/components/ui/panels/stair-panel.tsx +173 -19
  54. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  55. package/src/components/ui/panels/wall-panel.tsx +159 -11
  56. package/src/components/ui/panels/window-panel.tsx +5 -7
  57. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
  58. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  59. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  60. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  61. package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
  62. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  63. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
  64. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  65. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  66. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  67. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  68. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
  69. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  70. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  71. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
  72. package/src/components/ui/viewer-toolbar.tsx +55 -2
  73. package/src/components/viewer-overlay.tsx +25 -19
  74. package/src/hooks/use-contextual-tools.ts +14 -13
  75. package/src/hooks/use-keyboard.ts +3 -2
  76. package/src/index.tsx +2 -1
  77. package/src/lib/history.ts +20 -0
  78. package/src/lib/sfx-player.ts +96 -13
  79. package/src/store/use-editor.tsx +118 -10
@@ -1,32 +1,53 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNode, type AnyNodeId, type FenceBaseStyle, type FenceNode, type FenceStyle, useScene } from '@pascal-app/core'
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'
4
14
  import { useViewer } from '@pascal-app/viewer'
15
+ import { Move, Spline } from 'lucide-react'
5
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'
6
21
  import { PanelSection } from '../controls/panel-section'
7
22
  import { SegmentedControl } from '../controls/segmented-control'
8
23
  import { SliderControl } from '../controls/slider-control'
9
24
  import { PanelWrapper } from './panel-wrapper'
10
25
 
11
- const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyle }[] = [
26
+ type FenceStyleValue = 'slat' | 'rail' | 'privacy'
27
+ type FenceBaseStyleValue = 'grounded' | 'floating'
28
+
29
+ const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyleValue }[] = [
12
30
  { label: 'Slat', value: 'slat' },
13
31
  { label: 'Rail', value: 'rail' },
14
32
  { label: 'Privacy', value: 'privacy' },
15
33
  ]
16
34
 
17
- const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyle }[] = [
35
+ const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyleValue }[] = [
18
36
  { label: 'Grounded', value: 'grounded' },
19
37
  { label: 'Floating', value: 'floating' },
20
38
  ]
21
39
 
22
40
  export function FencePanel() {
23
- const selectedIds = useViewer((s) => s.selection.selectedIds)
41
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
42
+ const selectedCount = useViewer((s) => s.selection.selectedIds.length)
24
43
  const setSelection = useViewer((s) => s.setSelection)
25
- const nodes = useScene((s) => s.nodes)
26
44
  const updateNode = useScene((s) => s.updateNode)
45
+ const setMovingNode = useEditor((s) => s.setMovingNode)
46
+ const setCurvingFence = useEditor((s) => s.setCurvingFence)
27
47
 
28
- const selectedId = selectedIds[0]
29
- const node = selectedId ? (nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined
48
+ const node = useScene((s) =>
49
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
50
+ )
30
51
 
31
52
  const handleUpdate = useCallback(
32
53
  (updates: Partial<FenceNode>) => {
@@ -62,14 +83,47 @@ export function FencePanel() {
62
83
  setSelection({ selectedIds: [] })
63
84
  }, [setSelection])
64
85
 
65
- if (!node || node.type !== 'fence' || selectedIds.length !== 1) return null
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
+ )
66
113
 
67
- const dx = node.end[0] - node.start[0]
68
- const dz = node.end[1] - node.start[1]
69
- const length = Math.sqrt(dx * dx + dz * dz)
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)
70
119
 
71
120
  return (
72
- <PanelWrapper icon="/icons/build.png" onClose={handleClose} title={node.name || 'Fence'} width={300}>
121
+ <PanelWrapper
122
+ icon="/icons/build.png"
123
+ onClose={handleClose}
124
+ title={node.name || 'Fence'}
125
+ width={300}
126
+ >
73
127
  <PanelSection title="Style">
74
128
  <SegmentedControl
75
129
  onChange={(value) => handleUpdate({ style: value })}
@@ -95,6 +149,16 @@ export function FencePanel() {
95
149
  unit="m"
96
150
  value={length}
97
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
+ />
98
162
  <SliderControl
99
163
  label="Height"
100
164
  max={4}
@@ -179,6 +243,27 @@ export function FencePanel() {
179
243
  value={node.edgeInset}
180
244
  />
181
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>
182
267
  </PanelWrapper>
183
268
  )
184
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
@@ -19,7 +19,13 @@ import { WindowPanel } from './window-panel'
19
19
  export function PanelManager() {
20
20
  const selectedIds = useViewer((s) => s.selection.selectedIds)
21
21
  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
22
- 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
+ })
23
29
 
24
30
  // Show reference panel if a reference is selected
25
31
  if (selectedReferenceId) {
@@ -27,34 +33,30 @@ export function PanelManager() {
27
33
  }
28
34
 
29
35
  // Show appropriate panel based on selected node type
30
- if (selectedIds.length === 1) {
31
- const selectedNode = selectedIds[0]
32
- const node = nodes[selectedNode as AnyNodeId]
33
- if (node) {
34
- switch (node.type) {
35
- case 'item':
36
- return <ItemPanel />
37
- case 'roof':
38
- return <RoofPanel />
39
- case 'roof-segment':
40
- return <RoofSegmentPanel />
41
- case 'stair':
42
- return <StairPanel />
43
- case 'stair-segment':
44
- return <StairSegmentPanel />
45
- case 'slab':
46
- return <SlabPanel />
47
- case 'ceiling':
48
- return <CeilingPanel />
49
- case 'wall':
50
- return <WallPanel />
51
- case 'fence':
52
- return <FencePanel />
53
- case 'door':
54
- return <DoorPanel />
55
- case 'window':
56
- return <WindowPanel />
57
- }
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 />
58
60
  }
59
61
  }
60
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
  )
@@ -16,7 +16,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
16
16
  import useEditor from '../../../store/use-editor'
17
17
  import { ActionButton, ActionGroup } from '../controls/action-button'
18
18
  import { MaterialPicker } from '../controls/material-picker'
19
- import { MetricControl } from '../controls/metric-control'
20
19
  import { PanelSection } from '../controls/panel-section'
21
20
  import { SegmentedControl } from '../controls/segmented-control'
22
21
  import { SliderControl } from '../controls/slider-control'
@@ -36,16 +35,14 @@ const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [
36
35
  ]
37
36
 
38
37
  export function RoofSegmentPanel() {
39
- const selectedIds = useViewer((s) => s.selection.selectedIds)
38
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
40
39
  const setSelection = useViewer((s) => s.setSelection)
41
- const nodes = useScene((s) => s.nodes)
42
40
  const updateNode = useScene((s) => s.updateNode)
43
41
  const setMovingNode = useEditor((s) => s.setMovingNode)
44
42
 
45
- const selectedId = selectedIds[0]
46
- const node = selectedId
47
- ? (nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined)
48
- : undefined
43
+ const node = useScene((s) =>
44
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) : undefined,
45
+ )
49
46
 
50
47
  const handleUpdate = useCallback(
51
48
  (updates: Partial<RoofSegmentNode>) => {
@@ -57,7 +54,14 @@ export function RoofSegmentPanel() {
57
54
 
58
55
  const handleMaterialChange = useCallback(
59
56
  (material: MaterialSchema) => {
60
- handleUpdate({ material })
57
+ handleUpdate({ material, materialPreset: undefined })
58
+ },
59
+ [handleUpdate],
60
+ )
61
+
62
+ const handleMaterialPresetChange = useCallback(
63
+ (materialPreset: string) => {
64
+ handleUpdate({ materialPreset, material: undefined })
61
65
  },
62
66
  [handleUpdate],
63
67
  )
@@ -117,7 +121,7 @@ export function RoofSegmentPanel() {
117
121
  }
118
122
  }, [selectedId, node, setSelection])
119
123
 
120
- if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null
124
+ if (!(node && node.type === 'roof-segment' && selectedId)) return null
121
125
 
122
126
  return (
123
127
  <PanelWrapper
@@ -230,7 +234,7 @@ export function RoofSegmentPanel() {
230
234
  </PanelSection>
231
235
 
232
236
  <PanelSection title="Position">
233
- <MetricControl
237
+ <SliderControl
234
238
  label="X"
235
239
  max={50}
236
240
  min={-50}
@@ -244,7 +248,7 @@ export function RoofSegmentPanel() {
244
248
  unit="m"
245
249
  value={Math.round(node.position[0] * 100) / 100}
246
250
  />
247
- <MetricControl
251
+ <SliderControl
248
252
  label="Y"
249
253
  max={50}
250
254
  min={-50}
@@ -258,7 +262,7 @@ export function RoofSegmentPanel() {
258
262
  unit="m"
259
263
  value={Math.round(node.position[1] * 100) / 100}
260
264
  />
261
- <MetricControl
265
+ <SliderControl
262
266
  label="Z"
263
267
  max={50}
264
268
  min={-50}
@@ -319,7 +323,13 @@ export function RoofSegmentPanel() {
319
323
  </ActionGroup>
320
324
  </PanelSection>
321
325
  <PanelSection title="Material">
322
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
326
+ <MaterialPicker
327
+ nodeType="roof-segment"
328
+ onChange={handleMaterialChange}
329
+ onSelectMaterialPreset={handleMaterialPresetChange}
330
+ selectedMaterialPreset={node.materialPreset}
331
+ value={node.material}
332
+ />
323
333
  </PanelSection>
324
334
  </PanelWrapper>
325
335
  )