@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
@@ -3,8 +3,15 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
+ getEffectiveStairSurfaceMaterial,
7
+ type LevelNode,
6
8
  type MaterialSchema,
7
9
  type StairNode,
10
+ type StairRailingMode,
11
+ type StairSurfaceMaterialRole,
12
+ type StairSlabOpeningMode,
13
+ type StairTopLandingMode,
14
+ type StairType,
8
15
  StairNode as StairNodeSchema,
9
16
  type StairSegmentNode,
10
17
  StairSegmentNode as StairSegmentNodeSchema,
@@ -13,27 +20,98 @@ import {
13
20
  import { useViewer } from '@pascal-app/viewer'
14
21
  import { Copy, Move, Plus, Trash2 } from 'lucide-react'
15
22
  import { useCallback } from 'react'
23
+ import { useShallow } from 'zustand/react/shallow'
16
24
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
25
  import useEditor from '../../../store/use-editor'
26
+ import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
18
27
  import { ActionButton, ActionGroup } from '../controls/action-button'
19
28
  import { MaterialPicker } from '../controls/material-picker'
20
29
  import { MetricControl } from '../controls/metric-control'
21
30
  import { PanelSection } from '../controls/panel-section'
31
+ import { SegmentedControl } from '../controls/segmented-control'
22
32
  import { SliderControl } from '../controls/slider-control'
33
+ import { ToggleControl } from '../controls/toggle-control'
23
34
  import { PanelWrapper } from './panel-wrapper'
24
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
+
62
+ const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
63
+ { label: 'None', value: 'none' },
64
+ { label: 'Left', value: 'left' },
65
+ { label: 'Right', value: 'right' },
66
+ { label: 'Both', value: 'both' },
67
+ ]
68
+
69
+ const STAIR_TYPE_OPTIONS: { label: string; value: StairType }[] = [
70
+ { label: 'Straight', value: 'straight' },
71
+ { label: 'Curved', value: 'curved' },
72
+ { label: 'Spiral', value: 'spiral' },
73
+ ]
74
+
75
+ const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[] = [
76
+ { label: 'None', value: 'none' },
77
+ { label: 'Integrated', value: 'integrated' },
78
+ ]
79
+
80
+ const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }[] = [
81
+ { label: 'None', value: 'none' },
82
+ { label: 'Destination', value: 'destination' },
83
+ ]
84
+
25
85
  export function StairPanel() {
26
- 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)
27
88
  const setSelection = useViewer((s) => s.setSelection)
28
- const nodes = useScene((s) => s.nodes)
29
89
  const updateNode = useScene((s) => s.updateNode)
30
90
  const createNode = useScene((s) => s.createNode)
91
+ const createNodes = useScene((s) => s.createNodes)
31
92
  const setMovingNode = useEditor((s) => s.setMovingNode)
93
+ const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
32
94
 
33
- const selectedId = selectedIds[0]
34
- const node = selectedId
35
- ? (nodes[selectedId as AnyNode['id']] as StairNode | undefined)
36
- : 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
+ )
37
115
 
38
116
  const handleUpdate = useCallback(
39
117
  (updates: Partial<StairNode>) => {
@@ -43,11 +121,31 @@ export function StairPanel() {
43
121
  [selectedId, updateNode],
44
122
  )
45
123
 
46
- 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(
47
136
  (material: MaterialSchema) => {
48
- handleUpdate({ material })
137
+ if (!node || !materialTargetRole) return
138
+ handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
49
139
  },
50
- [handleUpdate],
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))
147
+ },
148
+ [handleUpdate, materialTargetRole, node],
51
149
  )
52
150
 
53
151
  const handleClose = useCallback(() => {
@@ -59,13 +157,15 @@ export function StairPanel() {
59
157
  const children = node.children ?? []
60
158
  const lastChildId = children[children.length - 1]
61
159
  if (lastChildId) {
62
- const lastChild = nodes[lastChildId as AnyNodeId] as StairSegmentNode | undefined
160
+ const lastChild = useScene.getState().nodes[lastChildId as AnyNodeId] as
161
+ | StairSegmentNode
162
+ | undefined
63
163
  if (lastChild?.type === 'stair-segment') {
64
164
  return { fillToFloor: lastChild.fillToFloor }
65
165
  }
66
166
  }
67
167
  return { fillToFloor: true }
68
- }, [node, nodes])
168
+ }, [node])
69
169
 
70
170
  const handleAddFlight = useCallback(() => {
71
171
  if (!node) return
@@ -114,7 +214,8 @@ export function StairPanel() {
114
214
 
115
215
  let duplicateInfo = structuredClone(node) as any
116
216
  delete duplicateInfo.id
117
- duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
217
+ duplicateInfo.metadata = { ...duplicateInfo.metadata }
218
+ duplicateInfo.children = []
118
219
  duplicateInfo.position = [
119
220
  duplicateInfo.position[0] + 1,
120
221
  duplicateInfo.position[1],
@@ -123,29 +224,31 @@ export function StairPanel() {
123
224
 
124
225
  try {
125
226
  const duplicate = StairNodeSchema.parse(duplicateInfo)
126
- useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
127
227
 
128
- // Also duplicate all child segments
129
228
  const nodesState = useScene.getState().nodes
130
229
  const children = node.children || []
230
+ const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
231
+ { node: duplicate, parentId: duplicate.parentId as AnyNodeId },
232
+ ]
131
233
 
132
234
  for (const childId of children) {
133
235
  const childNode = nodesState[childId]
134
236
  if (childNode && childNode.type === 'stair-segment') {
135
237
  let childDuplicateInfo = structuredClone(childNode) as any
136
238
  delete childDuplicateInfo.id
137
- childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
239
+ childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
138
240
  const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
139
- useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
241
+ createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
140
242
  }
141
243
  }
142
244
 
143
- setSelection({ selectedIds: [] })
144
- setMovingNode(duplicate)
245
+ createNodes(createOps)
246
+
247
+ setSelection({ selectedIds: [duplicate.id as AnyNode['id']] })
145
248
  } catch (e) {
146
249
  console.error('Failed to duplicate stair', e)
147
250
  }
148
- }, [node, setSelection, setMovingNode])
251
+ }, [createNodes, node, setSelection])
149
252
 
150
253
  const handleMove = useCallback(() => {
151
254
  if (node) {
@@ -166,11 +269,10 @@ export function StairPanel() {
166
269
  setSelection({ selectedIds: [] })
167
270
  }, [selectedId, node, setSelection])
168
271
 
169
- if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
272
+ if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
170
273
 
171
- const segments = (node.children ?? [])
172
- .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
173
- .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
174
276
 
175
277
  return (
176
278
  <PanelWrapper
@@ -179,36 +281,228 @@ export function StairPanel() {
179
281
  title={node.name || 'Staircase'}
180
282
  width={300}
181
283
  >
182
- <PanelSection title="Segments">
183
- <div className="flex flex-col gap-1">
184
- {segments.map((seg, i) => (
185
- <button
186
- className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
187
- key={seg.id}
188
- onClick={() => handleSelectSegment(seg.id)}
189
- type="button"
190
- >
191
- <span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
192
- <span className="text-muted-foreground text-xs capitalize">{seg.segmentType}</span>
193
- </button>
194
- ))}
195
- </div>
196
- <div className="flex gap-1.5">
197
- <ActionButton
198
- icon={<Plus className="h-3.5 w-3.5" />}
199
- label="Add flight"
200
- onClick={handleAddFlight}
284
+ <PanelSection title="Type">
285
+ <SegmentedControl
286
+ onChange={(value) =>
287
+ handleUpdate(
288
+ value === 'spiral' && node.stairType !== 'spiral'
289
+ ? {
290
+ stairType: value,
291
+ sweepAngle: DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
292
+ position: [node.position[0], 0, node.position[2]],
293
+ }
294
+ : { stairType: value },
295
+ )
296
+ }
297
+ options={STAIR_TYPE_OPTIONS}
298
+ value={node.stairType ?? 'straight'}
299
+ />
300
+ </PanelSection>
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
+ }
201
312
  />
202
- <ActionButton
203
- icon={<Plus className="h-3.5 w-3.5" />}
204
- label="Add landing"
205
- onClick={handleAddLanding}
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'}
206
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}
207
366
  </div>
208
367
  </PanelSection>
209
368
 
369
+ {node.stairType === 'straight' && (
370
+ <PanelSection title="Segments">
371
+ <div className="flex flex-col gap-1">
372
+ {segments.map((seg, i) => (
373
+ <button
374
+ className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
375
+ key={seg.id}
376
+ onClick={() => handleSelectSegment(seg.id)}
377
+ type="button"
378
+ >
379
+ <span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
380
+ <span className="text-muted-foreground text-xs capitalize">{seg.segmentType}</span>
381
+ </button>
382
+ ))}
383
+ </div>
384
+ <div className="flex gap-1.5">
385
+ <ActionButton
386
+ icon={<Plus className="h-3.5 w-3.5" />}
387
+ label="Add flight"
388
+ onClick={handleAddFlight}
389
+ />
390
+ <ActionButton
391
+ icon={<Plus className="h-3.5 w-3.5" />}
392
+ label="Add landing"
393
+ onClick={handleAddLanding}
394
+ />
395
+ </div>
396
+ </PanelSection>
397
+ )}
398
+
399
+ {(node.stairType === 'curved' || node.stairType === 'spiral') && (
400
+ <PanelSection title="Geometry">
401
+ <MetricControl
402
+ label="Width"
403
+ max={10}
404
+ min={0.4}
405
+ onChange={(value) => handleUpdate({ width: value })}
406
+ precision={2}
407
+ step={0.05}
408
+ unit="m"
409
+ value={Math.round((node.width ?? 1) * 100) / 100}
410
+ />
411
+ <MetricControl
412
+ label="Rise"
413
+ max={10}
414
+ min={0.2}
415
+ onChange={(value) => handleUpdate({ totalRise: value })}
416
+ precision={2}
417
+ step={0.05}
418
+ unit="m"
419
+ value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
420
+ />
421
+ <MetricControl
422
+ label="Steps"
423
+ max={32}
424
+ min={2}
425
+ onChange={(value) => handleUpdate({ stepCount: Math.max(2, Math.round(value)) })}
426
+ precision={0}
427
+ step={1}
428
+ unit=""
429
+ value={Math.max(2, Math.round(node.stepCount ?? 10))}
430
+ />
431
+ {node.stairType !== 'spiral' && (
432
+ <ToggleControl
433
+ checked={node.fillToFloor ?? true}
434
+ label="Fit To Floor"
435
+ onChange={(checked) => handleUpdate({ fillToFloor: checked })}
436
+ />
437
+ )}
438
+ {(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
439
+ <MetricControl
440
+ label="Thickness"
441
+ max={1}
442
+ min={0.02}
443
+ onChange={(value) => handleUpdate({ thickness: value })}
444
+ precision={2}
445
+ step={0.01}
446
+ unit="m"
447
+ value={Math.round((node.thickness ?? 0.25) * 100) / 100}
448
+ />
449
+ )}
450
+ <MetricControl
451
+ label="Inner Radius"
452
+ max={10}
453
+ min={node.stairType === 'spiral' ? 0.05 : 0.2}
454
+ onChange={(value) => handleUpdate({ innerRadius: value })}
455
+ precision={2}
456
+ step={0.05}
457
+ unit="m"
458
+ value={Math.round((node.innerRadius ?? 0.9) * 100) / 100}
459
+ />
460
+ <SliderControl
461
+ label="Sweep"
462
+ max={node.stairType === 'spiral' ? 720 : 270}
463
+ min={node.stairType === 'spiral' ? -720 : -270}
464
+ onChange={(degrees) => handleUpdate({ sweepAngle: (degrees * Math.PI) / 180 })}
465
+ precision={0}
466
+ step={1}
467
+ unit="°"
468
+ value={Math.round(((node.sweepAngle ?? Math.PI / 2) * 180) / Math.PI)}
469
+ />
470
+ {node.stairType === 'spiral' && (
471
+ <>
472
+ <SegmentedControl
473
+ onChange={(value) => handleUpdate({ topLandingMode: value })}
474
+ options={TOP_LANDING_MODE_OPTIONS}
475
+ value={node.topLandingMode ?? 'none'}
476
+ />
477
+ {(node.topLandingMode ?? 'none') === 'integrated' && (
478
+ <MetricControl
479
+ label="Top Landing"
480
+ max={5}
481
+ min={0.3}
482
+ onChange={(value) => handleUpdate({ topLandingDepth: value })}
483
+ precision={2}
484
+ step={0.05}
485
+ unit="m"
486
+ value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100}
487
+ />
488
+ )}
489
+ <ToggleControl
490
+ checked={node.showCenterColumn ?? true}
491
+ label="Center Column"
492
+ onChange={(checked) => handleUpdate({ showCenterColumn: checked })}
493
+ />
494
+ <ToggleControl
495
+ checked={node.showStepSupports ?? true}
496
+ label="Step Supports"
497
+ onChange={(checked) => handleUpdate({ showStepSupports: checked })}
498
+ />
499
+ </>
500
+ )}
501
+ </PanelSection>
502
+ )}
503
+
210
504
  <PanelSection title="Position">
211
- <MetricControl
505
+ <SliderControl
212
506
  label="X"
213
507
  max={50}
214
508
  min={-50}
@@ -222,7 +516,7 @@ export function StairPanel() {
222
516
  unit="m"
223
517
  value={Math.round(node.position[0] * 100) / 100}
224
518
  />
225
- <MetricControl
519
+ <SliderControl
226
520
  label="Y"
227
521
  max={50}
228
522
  min={-50}
@@ -236,7 +530,7 @@ export function StairPanel() {
236
530
  unit="m"
237
531
  value={Math.round(node.position[1] * 100) / 100}
238
532
  />
239
- <MetricControl
533
+ <SliderControl
240
534
  label="Z"
241
535
  max={50}
242
536
  min={-50}
@@ -280,6 +574,26 @@ export function StairPanel() {
280
574
  </div>
281
575
  </PanelSection>
282
576
 
577
+ <PanelSection title="Railing">
578
+ <SegmentedControl
579
+ onChange={(value) => handleUpdate({ railingMode: value })}
580
+ options={RAILING_MODE_OPTIONS}
581
+ value={node.railingMode ?? 'none'}
582
+ />
583
+ {(node.railingMode ?? 'none') !== 'none' && (
584
+ <SliderControl
585
+ label="Height"
586
+ max={1.4}
587
+ min={0.7}
588
+ onChange={(value) => handleUpdate({ railingHeight: value })}
589
+ precision={2}
590
+ step={0.02}
591
+ unit="m"
592
+ value={Math.round((node.railingHeight ?? 0.92) * 100) / 100}
593
+ />
594
+ )}
595
+ </PanelSection>
596
+
283
597
  <PanelSection title="Actions">
284
598
  <ActionGroup>
285
599
  <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -297,7 +611,20 @@ export function StairPanel() {
297
611
  </ActionGroup>
298
612
  </PanelSection>
299
613
  <PanelSection title="Material">
300
- <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
+ />
301
628
  </PanelSection>
302
629
  </PanelWrapper>
303
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
  )