@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
@@ -2,8 +2,9 @@
2
2
 
3
3
  import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
- import { Edit, Plus, Trash2 } from 'lucide-react'
5
+ import { Edit, Move, Plus, Trash2 } from 'lucide-react'
6
6
  import { useCallback, useEffect } from 'react'
7
+ import { sfxEmitter } from '../../../lib/sfx-bus'
7
8
  import useEditor from '../../../store/use-editor'
8
9
  import { ActionButton, ActionGroup } from '../controls/action-button'
9
10
  import { MaterialPicker } from '../controls/material-picker'
@@ -12,15 +13,16 @@ import { SliderControl } from '../controls/slider-control'
12
13
  import { PanelWrapper } from './panel-wrapper'
13
14
 
14
15
  export function SlabPanel() {
15
- const selectedIds = useViewer((s) => s.selection.selectedIds)
16
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
16
17
  const setSelection = useViewer((s) => s.setSelection)
17
- const nodes = useScene((s) => s.nodes)
18
18
  const updateNode = useScene((s) => s.updateNode)
19
19
  const editingHole = useEditor((s) => s.editingHole)
20
20
  const setEditingHole = useEditor((s) => s.setEditingHole)
21
+ const setMovingNode = useEditor((s) => s.setMovingNode)
21
22
 
22
- const selectedId = selectedIds[0]
23
- const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined
23
+ const node = useScene((s) =>
24
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined,
25
+ )
24
26
 
25
27
  const handleUpdate = useCallback(
26
28
  (updates: Partial<SlabNode>) => {
@@ -30,9 +32,16 @@ export function SlabPanel() {
30
32
  [selectedId, updateNode],
31
33
  )
32
34
 
33
- const handleMaterialChange = useCallback(
35
+ const handleMaterialPresetChange = useCallback(
36
+ (materialPreset: string) => {
37
+ handleUpdate({ materialPreset, material: undefined })
38
+ },
39
+ [handleUpdate],
40
+ )
41
+
42
+ const handleCustomMaterialChange = useCallback(
34
43
  (material: MaterialSchema) => {
35
- handleUpdate({ material })
44
+ handleUpdate({ material, materialPreset: undefined })
36
45
  },
37
46
  [handleUpdate],
38
47
  )
@@ -75,7 +84,13 @@ export function SlabPanel() {
75
84
  [cx - holeSize, cz + holeSize],
76
85
  ]
77
86
  const currentHoles = node?.holes || []
78
- handleUpdate({ holes: [...currentHoles, newHole] })
87
+ const currentMetadata = currentHoles.map(
88
+ (_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
89
+ )
90
+ handleUpdate({
91
+ holes: [...currentHoles, newHole],
92
+ holeMetadata: [...currentMetadata, { source: 'manual' }],
93
+ })
79
94
  setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
80
95
  }, [node, selectedId, handleUpdate, setEditingHole])
81
96
 
@@ -91,16 +106,28 @@ export function SlabPanel() {
91
106
  (index: number) => {
92
107
  if (!selectedId) return
93
108
  const currentHoles = node?.holes || []
109
+ if (node?.holeMetadata?.[index]?.source === 'stair') return
94
110
  const newHoles = currentHoles.filter((_, i) => i !== index)
95
- handleUpdate({ holes: newHoles })
111
+ const currentMetadata = currentHoles.map(
112
+ (_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
113
+ )
114
+ const newMetadata = currentMetadata.filter((_, i) => i !== index)
115
+ handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
96
116
  if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
97
117
  setEditingHole(null)
98
118
  }
99
119
  },
100
- [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
120
+ [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
101
121
  )
102
122
 
103
- if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
123
+ const handleMove = useCallback(() => {
124
+ if (!node) return
125
+ sfxEmitter.emit('sfx:item-pick')
126
+ setMovingNode(node)
127
+ setSelection({ selectedIds: [] })
128
+ }, [node, setMovingNode, setSelection])
129
+
130
+ if (!(node && node.type === 'slab' && selectedId)) return null
104
131
 
105
132
  const calculateArea = (polygon: Array<[number, number]>): number => {
106
133
  if (polygon.length < 3) return 0
@@ -108,8 +135,11 @@ export function SlabPanel() {
108
135
  const n = polygon.length
109
136
  for (let i = 0; i < n; i++) {
110
137
  const j = (i + 1) % n
111
- area += polygon[i]?.[0] * polygon[j]?.[1]
112
- area -= polygon[j]?.[0] * polygon[i]?.[1]
138
+ const current = polygon[i]
139
+ const next = polygon[j]
140
+ if (!(current && next)) continue
141
+ area += current[0] * next[1]
142
+ area -= next[0] * current[1]
113
143
  }
114
144
  return Math.abs(area) / 2
115
145
  }
@@ -157,6 +187,8 @@ export function SlabPanel() {
157
187
  const holeArea = calculateArea(hole)
158
188
  const isEditing =
159
189
  editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
190
+ const source = node.holeMetadata?.[index]?.source ?? 'manual'
191
+ const isAutoHole = source === 'stair'
160
192
  return (
161
193
  <div
162
194
  className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
@@ -173,7 +205,8 @@ export function SlabPanel() {
173
205
  Hole {index + 1} {isEditing && '(Editing)'}
174
206
  </p>
175
207
  <p className="text-[10px] text-muted-foreground">
176
- {holeArea.toFixed(2)} m² · {hole.length} pts
208
+ {holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
209
+ {isAutoHole ? 'Auto stair cutout' : 'Manual'}
177
210
  </p>
178
211
  </div>
179
212
  <div className="flex items-center gap-1">
@@ -183,6 +216,10 @@ export function SlabPanel() {
183
216
  label="Done"
184
217
  onClick={() => setEditingHole(null)}
185
218
  />
219
+ ) : isAutoHole ? (
220
+ <div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
221
+ Auto
222
+ </div>
186
223
  ) : (
187
224
  <>
188
225
  <button
@@ -221,7 +258,18 @@ export function SlabPanel() {
221
258
  </div>
222
259
  </PanelSection>
223
260
  <PanelSection title="Material">
224
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
261
+ <MaterialPicker
262
+ nodeType="slab"
263
+ onChange={handleCustomMaterialChange}
264
+ onSelectMaterialPreset={handleMaterialPresetChange}
265
+ selectedMaterialPreset={node.materialPreset}
266
+ value={node.material}
267
+ />
268
+ </PanelSection>
269
+ <PanelSection title="Actions">
270
+ <ActionGroup>
271
+ <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
272
+ </ActionGroup>
225
273
  </PanelSection>
226
274
  </PanelWrapper>
227
275
  )
@@ -3,9 +3,13 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
+ getEffectiveStairSurfaceMaterial,
7
+ type LevelNode,
6
8
  type MaterialSchema,
7
9
  type StairNode,
8
10
  type StairRailingMode,
11
+ type StairSurfaceMaterialRole,
12
+ type StairSlabOpeningMode,
9
13
  type StairTopLandingMode,
10
14
  type StairType,
11
15
  StairNode as StairNodeSchema,
@@ -16,6 +20,7 @@ import {
16
20
  import { useViewer } from '@pascal-app/viewer'
17
21
  import { Copy, Move, Plus, Trash2 } from 'lucide-react'
18
22
  import { useCallback } from 'react'
23
+ import { useShallow } from 'zustand/react/shallow'
19
24
  import { sfxEmitter } from '../../../lib/sfx-bus'
20
25
  import useEditor from '../../../store/use-editor'
21
26
  import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
@@ -28,6 +33,32 @@ import { SliderControl } from '../controls/slider-control'
28
33
  import { ToggleControl } from '../controls/toggle-control'
29
34
  import { PanelWrapper } from './panel-wrapper'
30
35
 
36
+ function buildStairSurfaceMaterialPatch(
37
+ node: StairNode,
38
+ targetRole: StairSurfaceMaterialRole,
39
+ material: MaterialSchema | undefined,
40
+ materialPreset: string | undefined,
41
+ ): Partial<StairNode> {
42
+ const nextSurfaceMaterial = { material, materialPreset }
43
+ const nextRailing =
44
+ targetRole === 'railing' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'railing')
45
+ const nextTread =
46
+ targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
47
+ const nextSide =
48
+ targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
49
+
50
+ return {
51
+ railingMaterial: nextRailing.material,
52
+ railingMaterialPreset: nextRailing.materialPreset,
53
+ treadMaterial: nextTread.material,
54
+ treadMaterialPreset: nextTread.materialPreset,
55
+ sideMaterial: nextSide.material,
56
+ sideMaterialPreset: nextSide.materialPreset,
57
+ material: undefined,
58
+ materialPreset: undefined,
59
+ }
60
+ }
61
+
31
62
  const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
32
63
  { label: 'None', value: 'none' },
33
64
  { label: 'Left', value: 'left' },
@@ -46,19 +77,41 @@ const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[]
46
77
  { label: 'Integrated', value: 'integrated' },
47
78
  ]
48
79
 
80
+ const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }[] = [
81
+ { label: 'None', value: 'none' },
82
+ { label: 'Destination', value: 'destination' },
83
+ ]
84
+
49
85
  export function StairPanel() {
50
- const selectedIds = useViewer((s) => s.selection.selectedIds)
86
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
87
+ const selectedCount = useViewer((s) => s.selection.selectedIds.length)
51
88
  const setSelection = useViewer((s) => s.setSelection)
52
- const nodes = useScene((s) => s.nodes)
53
89
  const updateNode = useScene((s) => s.updateNode)
54
90
  const createNode = useScene((s) => s.createNode)
55
91
  const createNodes = useScene((s) => s.createNodes)
56
92
  const setMovingNode = useEditor((s) => s.setMovingNode)
93
+ const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
57
94
 
58
- const selectedId = selectedIds[0]
59
- const node = selectedId
60
- ? (nodes[selectedId as AnyNode['id']] as StairNode | undefined)
61
- : undefined
95
+ const node = useScene((s) =>
96
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
97
+ )
98
+ const levels = useScene(
99
+ useShallow((s) =>
100
+ Object.values(s.nodes)
101
+ .filter((entry): entry is LevelNode => entry.type === 'level')
102
+ .sort((left, right) => left.level - right.level),
103
+ ),
104
+ )
105
+ const segments = useScene(
106
+ useShallow((s) => {
107
+ if (!selectedId) return []
108
+ const stairNode = s.nodes[selectedId as AnyNode['id']] as StairNode | undefined
109
+ if (stairNode?.type !== 'stair') return []
110
+ return (stairNode.children ?? [])
111
+ .map((childId) => s.nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
112
+ .filter((entry): entry is StairSegmentNode => entry?.type === 'stair-segment')
113
+ }),
114
+ )
62
115
 
63
116
  const handleUpdate = useCallback(
64
117
  (updates: Partial<StairNode>) => {
@@ -68,11 +121,31 @@ export function StairPanel() {
68
121
  [selectedId, updateNode],
69
122
  )
70
123
 
71
- const handleMaterialChange = useCallback(
124
+ const materialTargetRole =
125
+ selectedMaterialTarget &&
126
+ selectedMaterialTarget.nodeId === node?.id &&
127
+ (selectedMaterialTarget.role === 'railing' ||
128
+ selectedMaterialTarget.role === 'tread' ||
129
+ selectedMaterialTarget.role === 'side')
130
+ ? selectedMaterialTarget.role
131
+ : null
132
+ const materialPickerValue =
133
+ node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
134
+
135
+ const handleTargetedMaterialChange = useCallback(
72
136
  (material: MaterialSchema) => {
73
- handleUpdate({ material })
137
+ if (!node || !materialTargetRole) return
138
+ handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
139
+ },
140
+ [handleUpdate, materialTargetRole, node],
141
+ )
142
+
143
+ const handleTargetedMaterialPresetChange = useCallback(
144
+ (materialPreset: string) => {
145
+ if (!node || !materialTargetRole) return
146
+ handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
74
147
  },
75
- [handleUpdate],
148
+ [handleUpdate, materialTargetRole, node],
76
149
  )
77
150
 
78
151
  const handleClose = useCallback(() => {
@@ -84,13 +157,15 @@ export function StairPanel() {
84
157
  const children = node.children ?? []
85
158
  const lastChildId = children[children.length - 1]
86
159
  if (lastChildId) {
87
- const lastChild = nodes[lastChildId as AnyNodeId] as StairSegmentNode | undefined
160
+ const lastChild = useScene.getState().nodes[lastChildId as AnyNodeId] as
161
+ | StairSegmentNode
162
+ | undefined
88
163
  if (lastChild?.type === 'stair-segment') {
89
164
  return { fillToFloor: lastChild.fillToFloor }
90
165
  }
91
166
  }
92
167
  return { fillToFloor: true }
93
- }, [node, nodes])
168
+ }, [node])
94
169
 
95
170
  const handleAddFlight = useCallback(() => {
96
171
  if (!node) return
@@ -194,11 +269,10 @@ export function StairPanel() {
194
269
  setSelection({ selectedIds: [] })
195
270
  }, [selectedId, node, setSelection])
196
271
 
197
- if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
272
+ if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
198
273
 
199
- const segments = (node.children ?? [])
200
- .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
201
- .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
274
+ const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null
275
+ const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId
202
276
 
203
277
  return (
204
278
  <PanelWrapper
@@ -225,6 +299,73 @@ export function StairPanel() {
225
299
  />
226
300
  </PanelSection>
227
301
 
302
+ <PanelSection title="Opening">
303
+ <div className="space-y-3">
304
+ <ToggleControl
305
+ checked={(node.slabOpeningMode ?? 'none') === 'destination'}
306
+ label="Auto Cutout"
307
+ onChange={(checked) =>
308
+ handleUpdate({
309
+ slabOpeningMode: checked ? 'destination' : 'none',
310
+ })
311
+ }
312
+ />
313
+
314
+ <div className="space-y-1.5">
315
+ <div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
316
+ From Level
317
+ </div>
318
+ <select
319
+ className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
320
+ onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
321
+ value={resolvedFromLevelId ?? ''}
322
+ >
323
+ {levels.map((level) => (
324
+ <option key={level.id} value={level.id}>
325
+ {level.name || `Level ${level.level + 1}`}
326
+ </option>
327
+ ))}
328
+ </select>
329
+ </div>
330
+
331
+ <div className="space-y-1.5">
332
+ <div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
333
+ To Level
334
+ </div>
335
+ <select
336
+ className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
337
+ onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
338
+ value={resolvedToLevelId ?? ''}
339
+ >
340
+ {levels.map((level) => (
341
+ <option key={level.id} value={level.id}>
342
+ {level.name || `Level ${level.level + 1}`}
343
+ </option>
344
+ ))}
345
+ </select>
346
+ </div>
347
+
348
+ <SegmentedControl
349
+ onChange={(value) => handleUpdate({ slabOpeningMode: value as StairSlabOpeningMode })}
350
+ options={STAIR_SLAB_OPENING_OPTIONS}
351
+ value={node.slabOpeningMode ?? 'none'}
352
+ />
353
+
354
+ {(node.slabOpeningMode ?? 'none') === 'destination' ? (
355
+ <MetricControl
356
+ label="Opening Offset"
357
+ max={0.5}
358
+ min={0}
359
+ onChange={(value) => handleUpdate({ openingOffset: value })}
360
+ precision={2}
361
+ step={0.01}
362
+ unit="m"
363
+ value={Math.round((node.openingOffset ?? 0) * 100) / 100}
364
+ />
365
+ ) : null}
366
+ </div>
367
+ </PanelSection>
368
+
228
369
  {node.stairType === 'straight' && (
229
370
  <PanelSection title="Segments">
230
371
  <div className="flex flex-col gap-1">
@@ -361,7 +502,7 @@ export function StairPanel() {
361
502
  )}
362
503
 
363
504
  <PanelSection title="Position">
364
- <MetricControl
505
+ <SliderControl
365
506
  label="X"
366
507
  max={50}
367
508
  min={-50}
@@ -375,7 +516,7 @@ export function StairPanel() {
375
516
  unit="m"
376
517
  value={Math.round(node.position[0] * 100) / 100}
377
518
  />
378
- <MetricControl
519
+ <SliderControl
379
520
  label="Y"
380
521
  max={50}
381
522
  min={-50}
@@ -389,7 +530,7 @@ export function StairPanel() {
389
530
  unit="m"
390
531
  value={Math.round(node.position[1] * 100) / 100}
391
532
  />
392
- <MetricControl
533
+ <SliderControl
393
534
  label="Z"
394
535
  max={50}
395
536
  min={-50}
@@ -470,7 +611,20 @@ export function StairPanel() {
470
611
  </ActionGroup>
471
612
  </PanelSection>
472
613
  <PanelSection title="Material">
473
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
614
+ {!materialTargetRole ? (
615
+ <div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
616
+ Click the stair surface you want to edit. Materials apply to one target at a time.
617
+ </div>
618
+ ) : null}
619
+ <MaterialPicker
620
+ disabled={!materialTargetRole}
621
+ hideSideControl
622
+ nodeType="stair"
623
+ onChange={handleTargetedMaterialChange}
624
+ onSelectMaterialPreset={handleTargetedMaterialPresetChange}
625
+ selectedMaterialPreset={materialPickerValue.materialPreset}
626
+ value={materialPickerValue.material}
627
+ />
474
628
  </PanelSection>
475
629
  </PanelWrapper>
476
630
  )
@@ -17,7 +17,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
17
17
  import useEditor from '../../../store/use-editor'
18
18
  import { ActionButton, ActionGroup } from '../controls/action-button'
19
19
  import { MaterialPicker } from '../controls/material-picker'
20
- import { MetricControl } from '../controls/metric-control'
21
20
  import { PanelSection } from '../controls/panel-section'
22
21
  import { SegmentedControl } from '../controls/segmented-control'
23
22
  import { SliderControl } from '../controls/slider-control'
@@ -35,25 +34,24 @@ const ATTACHMENT_SIDE_OPTIONS: { label: string; value: AttachmentSide }[] = [
35
34
  ]
36
35
 
37
36
  export function StairSegmentPanel() {
38
- const selectedIds = useViewer((s) => s.selection.selectedIds)
37
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
39
38
  const setSelection = useViewer((s) => s.setSelection)
40
- const nodes = useScene((s) => s.nodes)
41
39
  const updateNode = useScene((s) => s.updateNode)
42
40
  const setMovingNode = useEditor((s) => s.setMovingNode)
43
41
 
44
- const selectedId = selectedIds[0]
45
- const node = selectedId
46
- ? (nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined)
47
- : undefined
42
+ const node = useScene((s) =>
43
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined) : undefined,
44
+ )
48
45
 
49
- // Check if this is the first segment in the parent stair
50
- const isFirstSegment = (() => {
46
+ // Boolean selector re-renders only when this segment's position among the
47
+ // parent stair's children flips to/from "first".
48
+ const isFirstSegment = useScene((s) => {
51
49
  if (!node?.parentId) return true
52
- const parent = nodes[node.parentId as AnyNodeId]
50
+ const parent = s.nodes[node.parentId as AnyNodeId]
53
51
  if (!parent || parent.type !== 'stair') return true
54
52
  const children = (parent as any).children ?? []
55
53
  return children[0] === node.id
56
- })()
54
+ })
57
55
 
58
56
  const handleUpdate = useCallback(
59
57
  (updates: Partial<StairSegmentNode>) => {
@@ -65,7 +63,14 @@ export function StairSegmentPanel() {
65
63
 
66
64
  const handleMaterialChange = useCallback(
67
65
  (material: MaterialSchema) => {
68
- handleUpdate({ material })
66
+ handleUpdate({ material, materialPreset: undefined })
67
+ },
68
+ [handleUpdate],
69
+ )
70
+
71
+ const handleMaterialPresetChange = useCallback(
72
+ (materialPreset: string) => {
73
+ handleUpdate({ materialPreset, material: undefined })
69
74
  },
70
75
  [handleUpdate],
71
76
  )
@@ -124,7 +129,7 @@ export function StairSegmentPanel() {
124
129
  }
125
130
  }, [selectedId, node, setSelection])
126
131
 
127
- if (!node || node.type !== 'stair-segment' || selectedIds.length !== 1) return null
132
+ if (!(node && node.type === 'stair-segment' && selectedId)) return null
128
133
 
129
134
  return (
130
135
  <PanelWrapper
@@ -243,7 +248,7 @@ export function StairSegmentPanel() {
243
248
  </PanelSection>
244
249
 
245
250
  <PanelSection title="Position">
246
- <MetricControl
251
+ <SliderControl
247
252
  label="X"
248
253
  max={50}
249
254
  min={-50}
@@ -257,7 +262,7 @@ export function StairSegmentPanel() {
257
262
  unit="m"
258
263
  value={Math.round(node.position[0] * 100) / 100}
259
264
  />
260
- <MetricControl
265
+ <SliderControl
261
266
  label="Y"
262
267
  max={50}
263
268
  min={-50}
@@ -271,7 +276,7 @@ export function StairSegmentPanel() {
271
276
  unit="m"
272
277
  value={Math.round(node.position[1] * 100) / 100}
273
278
  />
274
- <MetricControl
279
+ <SliderControl
275
280
  label="Z"
276
281
  max={50}
277
282
  min={-50}
@@ -332,7 +337,13 @@ export function StairSegmentPanel() {
332
337
  </ActionGroup>
333
338
  </PanelSection>
334
339
  <PanelSection title="Material">
335
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
340
+ <MaterialPicker
341
+ nodeType="stair-segment"
342
+ onChange={handleMaterialChange}
343
+ onSelectMaterialPreset={handleMaterialPresetChange}
344
+ selectedMaterialPreset={node.materialPreset}
345
+ value={node.material}
346
+ />
336
347
  </PanelSection>
337
348
  </PanelWrapper>
338
349
  )