@pascal-app/editor 0.7.0 → 0.8.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 (103) hide show
  1. package/package.json +6 -6
  2. package/src/components/editor/custom-camera-controls.tsx +2 -1
  3. package/src/components/editor/editor-layout-v2.tsx +4 -3
  4. package/src/components/editor/first-person/build-collider-world.ts +5 -7
  5. package/src/components/editor/first-person/bvh-ecctrl.tsx +119 -54
  6. package/src/components/editor/first-person-controls.tsx +11 -11
  7. package/src/components/editor/floating-action-menu.tsx +0 -0
  8. package/src/components/editor/floorplan-panel.tsx +44 -37
  9. package/src/components/editor/index.tsx +68 -53
  10. package/src/components/editor/selection-manager.tsx +2 -2
  11. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  12. package/src/components/editor/thumbnail-generator.tsx +18 -61
  13. package/src/components/editor/use-floorplan-background-placement.ts +3 -3
  14. package/src/components/editor/wall-measurement-label.tsx +0 -0
  15. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  16. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +6 -1
  17. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +5 -5
  18. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  19. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  20. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  21. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  22. package/src/components/systems/zone/zone-system.tsx +0 -0
  23. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  24. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  25. package/src/components/tools/fence/fence-tool.tsx +2 -2
  26. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +11 -8
  27. package/src/components/tools/fence/move-fence-tool.tsx +13 -9
  28. package/src/components/tools/item/move-tool.tsx +3 -6
  29. package/src/components/tools/item/placement-math.ts +2 -4
  30. package/src/components/tools/item/placement-strategies.ts +11 -10
  31. package/src/components/tools/item/use-draft-node.ts +0 -1
  32. package/src/components/tools/item/use-placement-coordinator.tsx +9 -111
  33. package/src/components/tools/roof/move-roof-tool.tsx +7 -2
  34. package/src/components/tools/select/box-select-tool.tsx +12 -17
  35. package/src/components/tools/shared/segment-angle.ts +1 -1
  36. package/src/components/tools/tool-manager.tsx +12 -12
  37. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  38. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +11 -8
  39. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  40. package/src/components/tools/wall/wall-drafting.ts +0 -0
  41. package/src/components/tools/wall/wall-tool.tsx +3 -3
  42. package/src/components/tools/zone/zone-tool.tsx +20 -5
  43. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  44. package/src/components/ui/action-menu/control-modes.tsx +7 -1
  45. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  46. package/src/components/ui/action-menu/index.tsx +35 -86
  47. package/src/components/ui/action-menu/view-toggles.tsx +19 -31
  48. package/src/components/ui/command-palette/editor-commands.tsx +6 -4
  49. package/src/components/ui/command-palette/index.tsx +4 -255
  50. package/src/components/ui/controls/material-picker.tsx +8 -5
  51. package/src/components/ui/floating-level-selector.tsx +1 -1
  52. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  53. package/src/components/ui/item-catalog/catalog-items.tsx +1742 -315
  54. package/src/components/ui/item-catalog/item-catalog.tsx +88 -46
  55. package/src/components/ui/level-duplicate-dialog.tsx +3 -5
  56. package/src/components/ui/panels/ceiling-panel.tsx +2 -3
  57. package/src/components/ui/panels/column-panel.tsx +62 -18
  58. package/src/components/ui/panels/door-panel.tsx +272 -265
  59. package/src/components/ui/panels/fence-panel.tsx +0 -5
  60. package/src/components/ui/panels/paint-panel.tsx +66 -41
  61. package/src/components/ui/panels/panel-manager.tsx +3 -32
  62. package/src/components/ui/panels/reference-panel.tsx +28 -13
  63. package/src/components/ui/panels/roof-panel.tsx +52 -2
  64. package/src/components/ui/panels/roof-segment-panel.tsx +0 -0
  65. package/src/components/ui/panels/slab-panel.tsx +0 -0
  66. package/src/components/ui/panels/spawn-panel.tsx +10 -4
  67. package/src/components/ui/panels/stair-panel.tsx +66 -14
  68. package/src/components/ui/panels/wall-panel.tsx +97 -1
  69. package/src/components/ui/panels/window-panel.tsx +13 -5
  70. package/src/components/ui/primitives/number-input.tsx +1 -1
  71. package/src/components/ui/primitives/sidebar.tsx +0 -0
  72. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  73. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  74. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  75. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  76. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  77. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  78. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  79. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -7
  80. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -1
  81. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  82. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  83. package/src/components/ui/slider.tsx +1 -1
  84. package/src/components/viewer-overlay.tsx +0 -0
  85. package/src/components/viewer-zone-system.tsx +0 -0
  86. package/src/hooks/use-auto-save.ts +14 -0
  87. package/src/hooks/use-keyboard.ts +10 -0
  88. package/src/index.tsx +8 -1
  89. package/src/lib/level-duplication.test.ts +0 -2
  90. package/src/lib/level-duplication.ts +1 -1
  91. package/src/lib/material-paint.ts +1 -1
  92. package/src/lib/roof-duplication.ts +1 -1
  93. package/src/lib/scene-bounds.ts +1 -1
  94. package/src/lib/scene.ts +0 -0
  95. package/src/lib/sfx-bus.ts +2 -0
  96. package/src/lib/sfx-player.ts +5 -5
  97. package/src/lib/stair-duplication.ts +2 -2
  98. package/src/store/use-editor.tsx +27 -59
  99. package/tsconfig.json +2 -1
  100. package/src/components/feedback-dialog.tsx +0 -265
  101. package/src/components/pascal-radio.tsx +0 -280
  102. package/src/components/preview-button.tsx +0 -16
  103. package/src/components/ui/viewer-toolbar.tsx +0 -436
@@ -1,9 +1,8 @@
1
1
  'use client'
2
2
 
3
3
  import useEditor from '../../../store/use-editor'
4
- import { SliderControl } from '../controls/slider-control'
5
- import { Input } from '../primitives/input'
6
4
  import { PanelSection } from '../controls/panel-section'
5
+ import { Input } from '../primitives/input'
7
6
  import { PanelWrapper } from './panel-wrapper'
8
7
 
9
8
  function buildDefaultCustomMaterial() {
@@ -53,11 +52,7 @@ export function PaintPanel() {
53
52
  }
54
53
 
55
54
  return (
56
- <PanelWrapper
57
- onClose={() => setPaintPanelOpen(false)}
58
- title="Material"
59
- width={320}
60
- >
55
+ <PanelWrapper onClose={() => setPaintPanelOpen(false)} title="Material" width={320}>
61
56
  <PanelSection title="Custom Material">
62
57
  <div className="space-y-3">
63
58
  <div className="space-y-2">
@@ -78,41 +73,71 @@ export function PaintPanel() {
78
73
  </div>
79
74
  </div>
80
75
 
81
- <div className="space-y-1">
82
- <label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
83
- Surface
84
- </label>
85
- <div className="space-y-1 rounded-lg border border-border/50 bg-background/40 p-2">
86
- <SliderControl
87
- label="Roughness"
88
- max={1}
89
- min={0}
90
- onChange={(roughness) => updateCustomMaterial({ roughness })}
91
- precision={2}
92
- step={0.01}
93
- value={currentProps.roughness}
94
- />
95
- <SliderControl
96
- label="Metalness"
97
- max={1}
98
- min={0}
99
- onChange={(metalness) => updateCustomMaterial({ metalness })}
100
- precision={2}
101
- step={0.01}
102
- value={currentProps.metalness}
103
- />
104
- <SliderControl
105
- label="Opacity"
106
- max={1}
107
- min={0}
108
- onChange={(opacity) =>
109
- updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
110
- }
111
- precision={2}
112
- step={0.01}
113
- value={currentProps.opacity}
114
- />
76
+ <div className="space-y-2">
77
+ <div className="flex items-center justify-between">
78
+ <label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
79
+ Roughness
80
+ </label>
81
+ <span className="font-mono text-muted-foreground text-xs">
82
+ {currentProps.roughness.toFixed(2)}
83
+ </span>
84
+ </div>
85
+ <input
86
+ className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
87
+ max={1}
88
+ min={0}
89
+ onChange={(e) =>
90
+ updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })
91
+ }
92
+ step={0.01}
93
+ type="range"
94
+ value={currentProps.roughness}
95
+ />
96
+ </div>
97
+
98
+ <div className="space-y-2">
99
+ <div className="flex items-center justify-between">
100
+ <label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
101
+ Metalness
102
+ </label>
103
+ <span className="font-mono text-muted-foreground text-xs">
104
+ {currentProps.metalness.toFixed(2)}
105
+ </span>
106
+ </div>
107
+ <input
108
+ className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
109
+ max={1}
110
+ min={0}
111
+ onChange={(e) =>
112
+ updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })
113
+ }
114
+ step={0.01}
115
+ type="range"
116
+ value={currentProps.metalness}
117
+ />
118
+ </div>
119
+
120
+ <div className="space-y-2">
121
+ <div className="flex items-center justify-between">
122
+ <label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
123
+ Opacity
124
+ </label>
125
+ <span className="font-mono text-muted-foreground text-xs">
126
+ {currentProps.opacity.toFixed(2)}
127
+ </span>
115
128
  </div>
129
+ <input
130
+ className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
131
+ max={1}
132
+ min={0}
133
+ onChange={(e) => {
134
+ const opacity = Number.parseFloat(e.target.value)
135
+ updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
136
+ }}
137
+ step={0.01}
138
+ type="range"
139
+ value={currentProps.opacity}
140
+ />
116
141
  </div>
117
142
 
118
143
  <div className="space-y-2">
@@ -92,6 +92,8 @@ function panelForType(type: string | null) {
92
92
  return <StairSegmentPanel />
93
93
  case 'slab':
94
94
  return <SlabPanel />
95
+ case 'spawn':
96
+ return <SpawnPanel />
95
97
  case 'ceiling':
96
98
  return <CeilingPanel />
97
99
  case 'column':
@@ -238,36 +240,5 @@ export function PanelManager() {
238
240
  }
239
241
 
240
242
  // Show appropriate panel based on selected node type
241
- if (selectedNodeType) {
242
- switch (selectedNodeType) {
243
- case 'item':
244
- return <ItemPanel />
245
- case 'roof':
246
- return <RoofPanel />
247
- case 'roof-segment':
248
- return <RoofSegmentPanel />
249
- case 'stair':
250
- return <StairPanel />
251
- case 'stair-segment':
252
- return <StairSegmentPanel />
253
- case 'slab':
254
- return <SlabPanel />
255
- case 'spawn':
256
- return <SpawnPanel />
257
- case 'ceiling':
258
- return <CeilingPanel />
259
- case 'column':
260
- return <ColumnPanel />
261
- case 'wall':
262
- return <WallPanel />
263
- case 'fence':
264
- return <FencePanel />
265
- case 'door':
266
- return <DoorPanel />
267
- case 'window':
268
- return <WindowPanel />
269
- }
270
- }
271
-
272
- return null
243
+ return panelForType(selectedNodeType)
273
244
  }
@@ -4,11 +4,21 @@ import {
4
4
  type AnyNode,
5
5
  type GuideNode,
6
6
  loadAssetUrl,
7
- saveAsset,
8
7
  type ScanNode,
8
+ saveAsset,
9
9
  useScene,
10
10
  } from '@pascal-app/core'
11
- import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react'
11
+ import {
12
+ Eye,
13
+ EyeOff,
14
+ LocateFixed,
15
+ Lock,
16
+ RotateCcw,
17
+ Ruler,
18
+ Trash2,
19
+ Unlock,
20
+ Upload,
21
+ } from 'lucide-react'
12
22
  import { useCallback, useEffect, useRef, useState } from 'react'
13
23
  import { guideEmitter } from '../../../lib/guide-events'
14
24
  import { getGuideImageName } from '../../../lib/local-guide-image'
@@ -32,7 +42,9 @@ function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) {
32
42
  export function ReferencePanel() {
33
43
  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
34
44
  const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
35
- const guideUi = useEditor((s) => (selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined))
45
+ const guideUi = useEditor((s) =>
46
+ selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined,
47
+ )
36
48
  const setGuideLocked = useEditor((s) => s.setGuideLocked)
37
49
  const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
38
50
  const clearGuideUi = useEditor((s) => s.clearGuideUi)
@@ -77,11 +89,14 @@ export function ReferencePanel() {
77
89
 
78
90
  try {
79
91
  const assetUrl = await saveAsset(file)
80
- updateNode(selectedReferenceId as AnyNode['id'], {
81
- name: getGuideImageName(file.name),
82
- url: assetUrl,
83
- scaleReference: null,
84
- } as Partial<GuideNode>)
92
+ updateNode(
93
+ selectedReferenceId as AnyNode['id'],
94
+ {
95
+ name: getGuideImageName(file.name),
96
+ url: assetUrl,
97
+ scaleReference: null,
98
+ } as Partial<GuideNode>,
99
+ )
85
100
  setGuideScaleReferenceVisible(selectedReferenceId, true)
86
101
  } catch {
87
102
  setReplaceError('Could not replace that image.')
@@ -138,7 +153,7 @@ export function ReferencePanel() {
138
153
  const isScan = node.type === 'scan'
139
154
  const guideLocked = !isScan && guideUi?.locked === true
140
155
  const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false
141
- const scaleStatus = !isScan ? getScaleStatus(node, scaleReferenceVisible) : null
156
+ const scaleStatus = isScan ? null : getScaleStatus(node, scaleReferenceVisible)
142
157
 
143
158
  return (
144
159
  <PanelWrapper
@@ -165,16 +180,16 @@ export function ReferencePanel() {
165
180
 
166
181
  <ActionGroup>
167
182
  <ActionButton
183
+ disabled={isReplacing}
168
184
  icon={<Upload className="h-3.5 w-3.5" />}
169
185
  label={isReplacing ? 'Replacing...' : 'Replace'}
170
186
  onClick={() => replaceInputRef.current?.click()}
171
- disabled={isReplacing}
172
187
  />
173
188
  <ActionButton
189
+ className="text-destructive hover:bg-destructive/10"
174
190
  icon={<Trash2 className="h-3.5 w-3.5" />}
175
191
  label="Delete"
176
192
  onClick={handleDeleteGuide}
177
- className="text-destructive hover:bg-destructive/10"
178
193
  />
179
194
  </ActionGroup>
180
195
 
@@ -232,16 +247,16 @@ export function ReferencePanel() {
232
247
 
233
248
  <ActionGroup>
234
249
  <ActionButton
235
- label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
236
250
  disabled={!node.scaleReference}
251
+ label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
237
252
  onClick={() => {
238
253
  if (!node.scaleReference) return
239
254
  setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
240
255
  }}
241
256
  />
242
257
  <ActionButton
243
- label="Clear Scale"
244
258
  disabled={!node.scaleReference}
259
+ label="Clear Scale"
245
260
  onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
246
261
  />
247
262
  </ActionGroup>
@@ -3,21 +3,25 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
+ getEffectiveRoofSurfaceMaterial,
7
+ type MaterialSchema,
6
8
  type RoofNode,
7
- type RoofSurfaceMaterialRole,
8
9
  RoofNode as RoofNodeSchema,
9
10
  type RoofSegmentNode,
10
11
  RoofSegmentNode as RoofSegmentNodeSchema,
12
+ type RoofSurfaceMaterialRole,
11
13
  useScene,
12
14
  } from '@pascal-app/core'
13
15
  import { useViewer } from '@pascal-app/viewer'
14
16
  import { Copy, Move, Plus, Trash2 } from 'lucide-react'
15
17
  import { useCallback } from 'react'
16
18
  import { useShallow } from 'zustand/react/shallow'
17
- import { sfxEmitter } from '../../../lib/sfx-bus'
19
+ import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint'
18
20
  import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
21
+ import { sfxEmitter } from '../../../lib/sfx-bus'
19
22
  import useEditor from '../../../store/use-editor'
20
23
  import { ActionButton, ActionGroup } from '../controls/action-button'
24
+ import { MaterialPicker } from '../controls/material-picker'
21
25
  import { PanelSection } from '../controls/panel-section'
22
26
  import { SliderControl } from '../controls/slider-control'
23
27
  import { PanelWrapper } from './panel-wrapper'
@@ -28,6 +32,7 @@ export function RoofPanel() {
28
32
  const updateNode = useScene((s) => s.updateNode)
29
33
  const createNode = useScene((s) => s.createNode)
30
34
  const setMovingNode = useEditor((s) => s.setMovingNode)
35
+ const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
31
36
 
32
37
  const node = useScene((s) =>
33
38
  selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
@@ -50,6 +55,35 @@ export function RoofPanel() {
50
55
  [selectedId, updateNode],
51
56
  )
52
57
 
58
+ const materialTargetRole =
59
+ selectedMaterialTarget &&
60
+ selectedMaterialTarget.nodeId === node?.id &&
61
+ (selectedMaterialTarget.role === 'top' ||
62
+ selectedMaterialTarget.role === 'edge' ||
63
+ selectedMaterialTarget.role === 'wall')
64
+ ? selectedMaterialTarget.role
65
+ : null
66
+ const materialPickerValue =
67
+ node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
68
+
69
+ const handleTargetedMaterialChange = useCallback(
70
+ (material: MaterialSchema) => {
71
+ if (!(node && materialTargetRole)) return
72
+ handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
73
+ },
74
+ [handleUpdate, materialTargetRole, node],
75
+ )
76
+
77
+ const handleTargetedMaterialPresetChange = useCallback(
78
+ (materialPreset: string) => {
79
+ if (!(node && materialTargetRole)) return
80
+ handleUpdate(
81
+ buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
82
+ )
83
+ },
84
+ [handleUpdate, materialTargetRole, node],
85
+ )
86
+
53
87
  const handleClose = useCallback(() => {
54
88
  setSelection({ selectedIds: [] })
55
89
  }, [setSelection])
@@ -225,6 +259,22 @@ export function RoofPanel() {
225
259
  />
226
260
  </ActionGroup>
227
261
  </PanelSection>
262
+ <PanelSection title="Material">
263
+ {materialTargetRole ? null : (
264
+ <div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
265
+ Click the roof surface you want to edit. Materials apply to one target at a time.
266
+ </div>
267
+ )}
268
+ <MaterialPicker
269
+ disabled={!materialTargetRole}
270
+ hideSideControl
271
+ nodeType="roof"
272
+ onChange={handleTargetedMaterialChange}
273
+ onSelectMaterialPreset={handleTargetedMaterialPresetChange}
274
+ selectedMaterialPreset={materialPickerValue.materialPreset}
275
+ value={materialPickerValue.material}
276
+ />
277
+ </PanelSection>
228
278
  </PanelWrapper>
229
279
  )
230
280
  }
File without changes
File without changes
@@ -87,7 +87,7 @@ export function SpawnPanel() {
87
87
 
88
88
  if (!(node && node.type === 'spawn' && selectedId)) return null
89
89
 
90
- const rotationDegrees = Math.round((((draftRotation ?? node.rotation) * 180) / Math.PI))
90
+ const rotationDegrees = Math.round(((draftRotation ?? node.rotation) * 180) / Math.PI)
91
91
  const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
92
92
 
93
93
  return (
@@ -97,7 +97,9 @@ export function SpawnPanel() {
97
97
  label="X"
98
98
  max={node.position[0] + 2}
99
99
  min={node.position[0] - 2}
100
- onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
100
+ onChange={(value) =>
101
+ handleUpdate({ position: [value, node.position[1], node.position[2]] })
102
+ }
101
103
  precision={2}
102
104
  step={0.01}
103
105
  unit="m"
@@ -107,7 +109,9 @@ export function SpawnPanel() {
107
109
  label="Y"
108
110
  max={node.position[1] + 2}
109
111
  min={node.position[1] - 2}
110
- onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
112
+ onChange={(value) =>
113
+ handleUpdate({ position: [node.position[0], value, node.position[2]] })
114
+ }
111
115
  precision={2}
112
116
  step={0.01}
113
117
  unit="m"
@@ -117,7 +121,9 @@ export function SpawnPanel() {
117
121
  label="Z"
118
122
  max={node.position[2] + 2}
119
123
  min={node.position[2] - 2}
120
- onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
124
+ onChange={(value) =>
125
+ handleUpdate({ position: [node.position[0], node.position[1], value] })
126
+ }
121
127
  precision={2}
122
128
  step={0.01}
123
129
  unit="m"
@@ -3,25 +3,31 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
+ getEffectiveStairSurfaceMaterial,
6
7
  type LevelNode,
8
+ type MaterialSchema,
7
9
  type StairNode,
10
+ StairNode as StairNodeSchema,
8
11
  type StairRailingMode,
12
+ type StairSegmentNode,
13
+ StairSegmentNode as StairSegmentNodeSchema,
9
14
  type StairSlabOpeningMode,
15
+ type StairSurfaceMaterialRole,
10
16
  type StairTopLandingMode,
11
17
  type StairType,
12
- type StairSegmentNode,
13
- StairSegmentNode as StairSegmentNodeSchema,
14
18
  useScene,
15
19
  } from '@pascal-app/core'
16
20
  import { useViewer } from '@pascal-app/viewer'
17
21
  import { Copy, Move, Plus, Trash2 } from 'lucide-react'
18
22
  import { useCallback } from 'react'
19
- import { duplicateStairSubtree } from '../../../lib/stair-duplication'
20
23
  import { useShallow } from 'zustand/react/shallow'
24
+ import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint'
21
25
  import { sfxEmitter } from '../../../lib/sfx-bus'
26
+ import { duplicateStairSubtree } from '../../../lib/stair-duplication'
22
27
  import useEditor from '../../../store/use-editor'
23
28
  import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
24
29
  import { ActionButton, ActionGroup } from '../controls/action-button'
30
+ import { MaterialPicker } from '../controls/material-picker'
25
31
  import { MetricControl } from '../controls/metric-control'
26
32
  import { PanelSection } from '../controls/panel-section'
27
33
  import { SegmentedControl } from '../controls/segmented-control'
@@ -59,6 +65,7 @@ export function StairPanel() {
59
65
  const updateNode = useScene((s) => s.updateNode)
60
66
  const createNode = useScene((s) => s.createNode)
61
67
  const setMovingNode = useEditor((s) => s.setMovingNode)
68
+ const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
62
69
 
63
70
  const node = useScene((s) =>
64
71
  selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -89,6 +96,35 @@ export function StairPanel() {
89
96
  [selectedId, updateNode],
90
97
  )
91
98
 
99
+ const materialTargetRole =
100
+ selectedMaterialTarget &&
101
+ selectedMaterialTarget.nodeId === node?.id &&
102
+ (selectedMaterialTarget.role === 'railing' ||
103
+ selectedMaterialTarget.role === 'tread' ||
104
+ selectedMaterialTarget.role === 'side')
105
+ ? selectedMaterialTarget.role
106
+ : null
107
+ const materialPickerValue =
108
+ node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
109
+
110
+ const handleTargetedMaterialChange = useCallback(
111
+ (material: MaterialSchema) => {
112
+ if (!(node && materialTargetRole)) return
113
+ handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
114
+ },
115
+ [handleUpdate, materialTargetRole, node],
116
+ )
117
+
118
+ const handleTargetedMaterialPresetChange = useCallback(
119
+ (materialPreset: string) => {
120
+ if (!(node && materialTargetRole)) return
121
+ handleUpdate(
122
+ buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
123
+ )
124
+ },
125
+ [handleUpdate, materialTargetRole, node],
126
+ )
127
+
92
128
  const handleClose = useCallback(() => {
93
129
  setSelection({ selectedIds: [] })
94
130
  }, [setSelection])
@@ -222,11 +258,11 @@ export function StairPanel() {
222
258
  />
223
259
 
224
260
  <div className="space-y-1.5">
225
- <div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
261
+ <div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
226
262
  From Level
227
263
  </div>
228
264
  <select
229
- className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
265
+ className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
230
266
  onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
231
267
  value={resolvedFromLevelId ?? ''}
232
268
  >
@@ -239,11 +275,11 @@ export function StairPanel() {
239
275
  </div>
240
276
 
241
277
  <div className="space-y-1.5">
242
- <div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
278
+ <div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
243
279
  To Level
244
280
  </div>
245
281
  <select
246
- className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
282
+ className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
247
283
  onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
248
284
  value={resolvedToLevelId ?? ''}
249
285
  >
@@ -262,7 +298,7 @@ export function StairPanel() {
262
298
  />
263
299
 
264
300
  {(node.slabOpeningMode ?? 'none') === 'destination' ? (
265
- <SliderControl
301
+ <MetricControl
266
302
  label="Opening Offset"
267
303
  max={0.5}
268
304
  min={0}
@@ -308,7 +344,7 @@ export function StairPanel() {
308
344
 
309
345
  {(node.stairType === 'curved' || node.stairType === 'spiral') && (
310
346
  <PanelSection title="Geometry">
311
- <SliderControl
347
+ <MetricControl
312
348
  label="Width"
313
349
  max={10}
314
350
  min={0.4}
@@ -318,7 +354,7 @@ export function StairPanel() {
318
354
  unit="m"
319
355
  value={Math.round((node.width ?? 1) * 100) / 100}
320
356
  />
321
- <SliderControl
357
+ <MetricControl
322
358
  label="Rise"
323
359
  max={10}
324
360
  min={0.2}
@@ -328,7 +364,7 @@ export function StairPanel() {
328
364
  unit="m"
329
365
  value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
330
366
  />
331
- <SliderControl
367
+ <MetricControl
332
368
  label="Steps"
333
369
  max={32}
334
370
  min={2}
@@ -346,7 +382,7 @@ export function StairPanel() {
346
382
  />
347
383
  )}
348
384
  {(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
349
- <SliderControl
385
+ <MetricControl
350
386
  label="Thickness"
351
387
  max={1}
352
388
  min={0.02}
@@ -357,7 +393,7 @@ export function StairPanel() {
357
393
  value={Math.round((node.thickness ?? 0.25) * 100) / 100}
358
394
  />
359
395
  )}
360
- <SliderControl
396
+ <MetricControl
361
397
  label="Inner Radius"
362
398
  max={10}
363
399
  min={node.stairType === 'spiral' ? 0.05 : 0.2}
@@ -385,7 +421,7 @@ export function StairPanel() {
385
421
  value={node.topLandingMode ?? 'none'}
386
422
  />
387
423
  {(node.topLandingMode ?? 'none') === 'integrated' && (
388
- <SliderControl
424
+ <MetricControl
389
425
  label="Top Landing"
390
426
  max={5}
391
427
  min={0.3}
@@ -520,6 +556,22 @@ export function StairPanel() {
520
556
  />
521
557
  </ActionGroup>
522
558
  </PanelSection>
559
+ <PanelSection title="Material">
560
+ {materialTargetRole ? null : (
561
+ <div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
562
+ Click the stair surface you want to edit. Materials apply to one target at a time.
563
+ </div>
564
+ )}
565
+ <MaterialPicker
566
+ disabled={!materialTargetRole}
567
+ hideSideControl
568
+ nodeType="stair"
569
+ onChange={handleTargetedMaterialChange}
570
+ onSelectMaterialPreset={handleTargetedMaterialPresetChange}
571
+ selectedMaterialPreset={materialPickerValue.materialPreset}
572
+ value={materialPickerValue.material}
573
+ />
574
+ </PanelSection>
523
575
  </PanelWrapper>
524
576
  )
525
577
  }