@pascal-app/editor 0.4.0 → 0.5.1

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 (61) hide show
  1. package/package.json +5 -5
  2. package/src/components/editor/floating-action-menu.tsx +101 -29
  3. package/src/components/editor/floating-building-action-menu.tsx +69 -0
  4. package/src/components/editor/floorplan-panel.tsx +31 -13
  5. package/src/components/editor/index.tsx +219 -167
  6. package/src/components/editor/node-action-menu.tsx +26 -10
  7. package/src/components/editor/selection-manager.tsx +38 -2
  8. package/src/components/editor/thumbnail-generator.tsx +245 -64
  9. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  10. package/src/components/tools/building/move-building-tool.tsx +157 -0
  11. package/src/components/tools/door/door-math.ts +1 -1
  12. package/src/components/tools/door/door-tool.tsx +19 -7
  13. package/src/components/tools/door/move-door-tool.tsx +17 -8
  14. package/src/components/tools/fence/fence-drafting.ts +125 -0
  15. package/src/components/tools/fence/fence-tool.tsx +190 -0
  16. package/src/components/tools/fence/move-fence-tool.tsx +223 -0
  17. package/src/components/tools/item/item-tool.tsx +3 -3
  18. package/src/components/tools/item/move-tool.tsx +7 -0
  19. package/src/components/tools/item/placement-strategies.ts +15 -7
  20. package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
  21. package/src/components/tools/roof/move-roof-tool.tsx +5 -2
  22. package/src/components/tools/roof/roof-tool.tsx +6 -6
  23. package/src/components/tools/select/box-select-tool.tsx +2 -2
  24. package/src/components/tools/shared/polygon-editor.tsx +2 -2
  25. package/src/components/tools/slab/slab-tool.tsx +4 -4
  26. package/src/components/tools/stair/stair-defaults.ts +10 -0
  27. package/src/components/tools/stair/stair-tool.tsx +29 -6
  28. package/src/components/tools/tool-manager.tsx +42 -14
  29. package/src/components/tools/wall/wall-tool.tsx +19 -29
  30. package/src/components/tools/window/move-window-tool.tsx +17 -8
  31. package/src/components/tools/window/window-math.ts +1 -1
  32. package/src/components/tools/window/window-tool.tsx +19 -7
  33. package/src/components/tools/zone/zone-tool.tsx +7 -7
  34. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  35. package/src/components/ui/helpers/building-helper.tsx +32 -0
  36. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  37. package/src/components/ui/panels/fence-panel.tsx +184 -0
  38. package/src/components/ui/panels/panel-manager.tsx +3 -0
  39. package/src/components/ui/panels/stair-panel.tsx +206 -33
  40. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
  41. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
  42. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
  43. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
  44. package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
  45. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  46. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +59 -52
  47. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
  48. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
  49. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
  50. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
  51. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
  52. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
  53. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
  54. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
  55. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
  56. package/src/components/viewer-overlay.tsx +1 -0
  57. package/src/hooks/use-auto-save.ts +3 -6
  58. package/src/hooks/use-contextual-tools.ts +10 -2
  59. package/src/hooks/use-grid-events.ts +13 -1
  60. package/src/hooks/use-keyboard.ts +4 -0
  61. package/src/store/use-editor.tsx +7 -0
@@ -151,13 +151,25 @@ export const WindowTool: React.FC = () => {
151
151
  const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
152
152
 
153
153
  if (draftRef.current) {
154
- useScene.getState().updateNode(draftRef.current.id, {
155
- position: [clampedX, clampedY, 0],
156
- rotation: [0, itemRotation, 0],
157
- side,
158
- parentId: event.node.id,
159
- wallId: event.node.id,
160
- })
154
+ if (event.node.id !== draftRef.current.parentId) {
155
+ // Wall changed without enter/leave: must updateNode to reparent
156
+ useScene.getState().updateNode(draftRef.current.id, {
157
+ position: [clampedX, clampedY, 0],
158
+ rotation: [0, itemRotation, 0],
159
+ side,
160
+ parentId: event.node.id,
161
+ wallId: event.node.id,
162
+ })
163
+ } else {
164
+ // Same wall: update Three.js mesh directly to avoid store churn
165
+ const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
166
+ if (draftMesh) {
167
+ draftMesh.position.set(clampedX, clampedY, 0)
168
+ draftMesh.rotation.set(0, itemRotation, 0)
169
+ draftMesh.updateMatrixWorld(true)
170
+ }
171
+ markWallDirty(event.node.id)
172
+ }
161
173
  }
162
174
 
163
175
  const valid = !hasWallChildOverlap(
@@ -174,18 +174,18 @@ export const ZoneTool: React.FC = () => {
174
174
  if (!cursorRef.current) return
175
175
 
176
176
  // Snap to 0.5 grid
177
- const gridX = Math.round(event.position[0] * 2) / 2
178
- const gridZ = Math.round(event.position[2] * 2) / 2
177
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
178
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
179
179
  cursorPosition = [gridX, gridZ]
180
- levelYRef.current = event.position[1]
180
+ levelYRef.current = event.localPosition[1]
181
181
 
182
182
  // If we have points, snap to axis from last point
183
183
  const lastPoint = pointsRef.current[pointsRef.current.length - 1]
184
184
  if (lastPoint) {
185
185
  const snapped = calculateSnapPoint(lastPoint, cursorPosition)
186
- cursorRef.current.position.set(snapped[0], event.position[1], snapped[1])
186
+ cursorRef.current.position.set(snapped[0], event.localPosition[1], snapped[1])
187
187
  } else {
188
- cursorRef.current.position.set(gridX, event.position[1], gridZ)
188
+ cursorRef.current.position.set(gridX, event.localPosition[1], gridZ)
189
189
  }
190
190
 
191
191
  updatePreview()
@@ -194,8 +194,8 @@ export const ZoneTool: React.FC = () => {
194
194
  const onGridClick = (event: GridEvent) => {
195
195
  if (!currentLevelId) return
196
196
 
197
- const gridX = Math.round(event.position[0] * 2) / 2
198
- const gridZ = Math.round(event.position[2] * 2) / 2
197
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
198
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
199
199
  let clickPoint: [number, number] = [gridX, gridZ]
200
200
 
201
201
  // Snap to axis from last point
@@ -28,6 +28,7 @@ export const tools: ToolConfig[] = [
28
28
  { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
29
29
  { id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
30
30
  { id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
31
+ { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
31
32
  { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
32
33
  ]
33
34
 
@@ -0,0 +1,32 @@
1
+ import { ShortcutToken } from '../primitives/shortcut-token'
2
+
3
+ interface BuildingHelperProps {
4
+ showRotate?: boolean
5
+ }
6
+
7
+ export function BuildingHelper({ showRotate }: BuildingHelperProps) {
8
+ return (
9
+ <div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
10
+ <div className="flex items-center gap-2 text-sm">
11
+ <ShortcutToken value="Left click" />
12
+ <span className="text-muted-foreground">Place building</span>
13
+ </div>
14
+ {showRotate && (
15
+ <>
16
+ <div className="flex items-center gap-2 text-sm">
17
+ <ShortcutToken value="R" />
18
+ <span className="text-muted-foreground">Rotate counterclockwise</span>
19
+ </div>
20
+ <div className="flex items-center gap-2 text-sm">
21
+ <ShortcutToken value="T" />
22
+ <span className="text-muted-foreground">Rotate clockwise</span>
23
+ </div>
24
+ </>
25
+ )}
26
+ <div className="flex items-center gap-2 text-sm">
27
+ <ShortcutToken value="Esc" />
28
+ <span className="text-muted-foreground">Cancel</span>
29
+ </div>
30
+ </div>
31
+ )
32
+ }
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import useEditor from '../../../store/use-editor'
4
+ import { BuildingHelper } from './building-helper'
4
5
  import { CeilingHelper } from './ceiling-helper'
5
6
  import { ItemHelper } from './item-helper'
6
7
  import { RoofHelper } from './roof-helper'
@@ -12,6 +13,7 @@ export function HelperManager() {
12
13
  const movingNode = useEditor((state) => state.movingNode)
13
14
 
14
15
  if (movingNode) {
16
+ if (movingNode.type === 'building') return <BuildingHelper showRotate />
15
17
  return <ItemHelper showEsc />
16
18
  }
17
19
 
@@ -0,0 +1,184 @@
1
+ 'use client'
2
+
3
+ import { type AnyNode, type AnyNodeId, type FenceBaseStyle, type FenceNode, type FenceStyle, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { useCallback } from 'react'
6
+ import { PanelSection } from '../controls/panel-section'
7
+ import { SegmentedControl } from '../controls/segmented-control'
8
+ import { SliderControl } from '../controls/slider-control'
9
+ import { PanelWrapper } from './panel-wrapper'
10
+
11
+ const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyle }[] = [
12
+ { label: 'Slat', value: 'slat' },
13
+ { label: 'Rail', value: 'rail' },
14
+ { label: 'Privacy', value: 'privacy' },
15
+ ]
16
+
17
+ const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyle }[] = [
18
+ { label: 'Grounded', value: 'grounded' },
19
+ { label: 'Floating', value: 'floating' },
20
+ ]
21
+
22
+ export function FencePanel() {
23
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
24
+ const setSelection = useViewer((s) => s.setSelection)
25
+ const nodes = useScene((s) => s.nodes)
26
+ const updateNode = useScene((s) => s.updateNode)
27
+
28
+ const selectedId = selectedIds[0]
29
+ const node = selectedId ? (nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined
30
+
31
+ const handleUpdate = useCallback(
32
+ (updates: Partial<FenceNode>) => {
33
+ if (!selectedId) return
34
+ updateNode(selectedId as AnyNode['id'], updates)
35
+ useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
36
+ },
37
+ [selectedId, updateNode],
38
+ )
39
+
40
+ const handleUpdateLength = useCallback(
41
+ (newLength: number) => {
42
+ if (!node || newLength <= 0) return
43
+
44
+ const dx = node.end[0] - node.start[0]
45
+ const dz = node.end[1] - node.start[1]
46
+ const currentLength = Math.sqrt(dx * dx + dz * dz)
47
+ if (currentLength === 0) return
48
+
49
+ const dirX = dx / currentLength
50
+ const dirZ = dz / currentLength
51
+ const newEnd: [number, number] = [
52
+ node.start[0] + dirX * newLength,
53
+ node.start[1] + dirZ * newLength,
54
+ ]
55
+
56
+ handleUpdate({ end: newEnd })
57
+ },
58
+ [node, handleUpdate],
59
+ )
60
+
61
+ const handleClose = useCallback(() => {
62
+ setSelection({ selectedIds: [] })
63
+ }, [setSelection])
64
+
65
+ if (!node || node.type !== 'fence' || selectedIds.length !== 1) return null
66
+
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)
70
+
71
+ return (
72
+ <PanelWrapper icon="/icons/build.png" onClose={handleClose} title={node.name || 'Fence'} width={300}>
73
+ <PanelSection title="Style">
74
+ <SegmentedControl
75
+ onChange={(value) => handleUpdate({ style: value })}
76
+ options={FENCE_STYLE_OPTIONS}
77
+ value={node.style}
78
+ />
79
+ <SegmentedControl
80
+ className="mt-2"
81
+ onChange={(value) => handleUpdate({ baseStyle: value })}
82
+ options={FENCE_BASE_STYLE_OPTIONS}
83
+ value={node.baseStyle}
84
+ />
85
+ </PanelSection>
86
+
87
+ <PanelSection title="Dimensions">
88
+ <SliderControl
89
+ label="Length"
90
+ max={50}
91
+ min={0.1}
92
+ onChange={handleUpdateLength}
93
+ precision={2}
94
+ step={0.01}
95
+ unit="m"
96
+ value={length}
97
+ />
98
+ <SliderControl
99
+ label="Height"
100
+ max={4}
101
+ min={0.4}
102
+ onChange={(value) => handleUpdate({ height: Math.max(0.4, value) })}
103
+ precision={2}
104
+ step={0.05}
105
+ unit="m"
106
+ value={node.height}
107
+ />
108
+ <SliderControl
109
+ label="Thickness"
110
+ max={0.5}
111
+ min={0.03}
112
+ onChange={(value) => handleUpdate({ thickness: Math.max(0.03, value) })}
113
+ precision={3}
114
+ step={0.005}
115
+ unit="m"
116
+ value={node.thickness}
117
+ />
118
+ </PanelSection>
119
+
120
+ <PanelSection title="Structure">
121
+ <SliderControl
122
+ label="Base Height"
123
+ max={1}
124
+ min={0.04}
125
+ onChange={(value) => handleUpdate({ baseHeight: Math.max(0.04, value) })}
126
+ precision={3}
127
+ step={0.01}
128
+ unit="m"
129
+ value={node.baseHeight}
130
+ />
131
+ <SliderControl
132
+ label="Top Rail"
133
+ max={0.25}
134
+ min={0.01}
135
+ onChange={(value) => handleUpdate({ topRailHeight: Math.max(0.01, value) })}
136
+ precision={3}
137
+ step={0.005}
138
+ unit="m"
139
+ value={node.topRailHeight}
140
+ />
141
+ <SliderControl
142
+ label="Post Spacing"
143
+ max={5}
144
+ min={0.2}
145
+ onChange={(value) => handleUpdate({ postSpacing: Math.max(0.2, value) })}
146
+ precision={2}
147
+ step={0.05}
148
+ unit="m"
149
+ value={node.postSpacing}
150
+ />
151
+ <SliderControl
152
+ label="Post Size"
153
+ max={0.4}
154
+ min={0.01}
155
+ onChange={(value) => handleUpdate({ postSize: Math.max(0.01, value) })}
156
+ precision={3}
157
+ step={0.005}
158
+ unit="m"
159
+ value={node.postSize}
160
+ />
161
+ <SliderControl
162
+ label="Ground Clear"
163
+ max={0.6}
164
+ min={0}
165
+ onChange={(value) => handleUpdate({ groundClearance: Math.max(0, value) })}
166
+ precision={3}
167
+ step={0.005}
168
+ unit="m"
169
+ value={node.groundClearance}
170
+ />
171
+ <SliderControl
172
+ label="Edge Inset"
173
+ max={0.25}
174
+ min={0.005}
175
+ onChange={(value) => handleUpdate({ edgeInset: Math.max(0.005, value) })}
176
+ precision={3}
177
+ step={0.005}
178
+ unit="m"
179
+ value={node.edgeInset}
180
+ />
181
+ </PanelSection>
182
+ </PanelWrapper>
183
+ )
184
+ }
@@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer'
5
5
  import useEditor from '../../../store/use-editor'
6
6
  import { CeilingPanel } from './ceiling-panel'
7
7
  import { DoorPanel } from './door-panel'
8
+ import { FencePanel } from './fence-panel'
8
9
  import { ItemPanel } from './item-panel'
9
10
  import { ReferencePanel } from './reference-panel'
10
11
  import { RoofPanel } from './roof-panel'
@@ -47,6 +48,8 @@ export function PanelManager() {
47
48
  return <CeilingPanel />
48
49
  case 'wall':
49
50
  return <WallPanel />
51
+ case 'fence':
52
+ return <FencePanel />
50
53
  case 'door':
51
54
  return <DoorPanel />
52
55
  case 'window':
@@ -5,6 +5,9 @@ import {
5
5
  type AnyNodeId,
6
6
  type MaterialSchema,
7
7
  type StairNode,
8
+ type StairRailingMode,
9
+ type StairTopLandingMode,
10
+ type StairType,
8
11
  StairNode as StairNodeSchema,
9
12
  type StairSegmentNode,
10
13
  StairSegmentNode as StairSegmentNodeSchema,
@@ -15,19 +18,41 @@ import { Copy, Move, Plus, Trash2 } from 'lucide-react'
15
18
  import { useCallback } from 'react'
16
19
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
20
  import useEditor from '../../../store/use-editor'
21
+ import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
18
22
  import { ActionButton, ActionGroup } from '../controls/action-button'
19
23
  import { MaterialPicker } from '../controls/material-picker'
20
24
  import { MetricControl } from '../controls/metric-control'
21
25
  import { PanelSection } from '../controls/panel-section'
26
+ import { SegmentedControl } from '../controls/segmented-control'
22
27
  import { SliderControl } from '../controls/slider-control'
28
+ import { ToggleControl } from '../controls/toggle-control'
23
29
  import { PanelWrapper } from './panel-wrapper'
24
30
 
31
+ const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
32
+ { label: 'None', value: 'none' },
33
+ { label: 'Left', value: 'left' },
34
+ { label: 'Right', value: 'right' },
35
+ { label: 'Both', value: 'both' },
36
+ ]
37
+
38
+ const STAIR_TYPE_OPTIONS: { label: string; value: StairType }[] = [
39
+ { label: 'Straight', value: 'straight' },
40
+ { label: 'Curved', value: 'curved' },
41
+ { label: 'Spiral', value: 'spiral' },
42
+ ]
43
+
44
+ const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[] = [
45
+ { label: 'None', value: 'none' },
46
+ { label: 'Integrated', value: 'integrated' },
47
+ ]
48
+
25
49
  export function StairPanel() {
26
50
  const selectedIds = useViewer((s) => s.selection.selectedIds)
27
51
  const setSelection = useViewer((s) => s.setSelection)
28
52
  const nodes = useScene((s) => s.nodes)
29
53
  const updateNode = useScene((s) => s.updateNode)
30
54
  const createNode = useScene((s) => s.createNode)
55
+ const createNodes = useScene((s) => s.createNodes)
31
56
  const setMovingNode = useEditor((s) => s.setMovingNode)
32
57
 
33
58
  const selectedId = selectedIds[0]
@@ -114,7 +139,8 @@ export function StairPanel() {
114
139
 
115
140
  let duplicateInfo = structuredClone(node) as any
116
141
  delete duplicateInfo.id
117
- duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
142
+ duplicateInfo.metadata = { ...duplicateInfo.metadata }
143
+ duplicateInfo.children = []
118
144
  duplicateInfo.position = [
119
145
  duplicateInfo.position[0] + 1,
120
146
  duplicateInfo.position[1],
@@ -123,29 +149,31 @@ export function StairPanel() {
123
149
 
124
150
  try {
125
151
  const duplicate = StairNodeSchema.parse(duplicateInfo)
126
- useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
127
152
 
128
- // Also duplicate all child segments
129
153
  const nodesState = useScene.getState().nodes
130
154
  const children = node.children || []
155
+ const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
156
+ { node: duplicate, parentId: duplicate.parentId as AnyNodeId },
157
+ ]
131
158
 
132
159
  for (const childId of children) {
133
160
  const childNode = nodesState[childId]
134
161
  if (childNode && childNode.type === 'stair-segment') {
135
162
  let childDuplicateInfo = structuredClone(childNode) as any
136
163
  delete childDuplicateInfo.id
137
- childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
164
+ childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
138
165
  const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
139
- useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
166
+ createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
140
167
  }
141
168
  }
142
169
 
143
- setSelection({ selectedIds: [] })
144
- setMovingNode(duplicate)
170
+ createNodes(createOps)
171
+
172
+ setSelection({ selectedIds: [duplicate.id as AnyNode['id']] })
145
173
  } catch (e) {
146
174
  console.error('Failed to duplicate stair', e)
147
175
  }
148
- }, [node, setSelection, setMovingNode])
176
+ }, [createNodes, node, setSelection])
149
177
 
150
178
  const handleMove = useCallback(() => {
151
179
  if (node) {
@@ -179,33 +207,158 @@ export function StairPanel() {
179
207
  title={node.name || 'Staircase'}
180
208
  width={300}
181
209
  >
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}
210
+ <PanelSection title="Type">
211
+ <SegmentedControl
212
+ onChange={(value) =>
213
+ handleUpdate(
214
+ value === 'spiral' && node.stairType !== 'spiral'
215
+ ? {
216
+ stairType: value,
217
+ sweepAngle: DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
218
+ position: [node.position[0], 0, node.position[2]],
219
+ }
220
+ : { stairType: value },
221
+ )
222
+ }
223
+ options={STAIR_TYPE_OPTIONS}
224
+ value={node.stairType ?? 'straight'}
225
+ />
226
+ </PanelSection>
227
+
228
+ {node.stairType === 'straight' && (
229
+ <PanelSection title="Segments">
230
+ <div className="flex flex-col gap-1">
231
+ {segments.map((seg, i) => (
232
+ <button
233
+ 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]"
234
+ key={seg.id}
235
+ onClick={() => handleSelectSegment(seg.id)}
236
+ type="button"
237
+ >
238
+ <span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
239
+ <span className="text-muted-foreground text-xs capitalize">{seg.segmentType}</span>
240
+ </button>
241
+ ))}
242
+ </div>
243
+ <div className="flex gap-1.5">
244
+ <ActionButton
245
+ icon={<Plus className="h-3.5 w-3.5" />}
246
+ label="Add flight"
247
+ onClick={handleAddFlight}
248
+ />
249
+ <ActionButton
250
+ icon={<Plus className="h-3.5 w-3.5" />}
251
+ label="Add landing"
252
+ onClick={handleAddLanding}
253
+ />
254
+ </div>
255
+ </PanelSection>
256
+ )}
257
+
258
+ {(node.stairType === 'curved' || node.stairType === 'spiral') && (
259
+ <PanelSection title="Geometry">
260
+ <MetricControl
261
+ label="Width"
262
+ max={10}
263
+ min={0.4}
264
+ onChange={(value) => handleUpdate({ width: value })}
265
+ precision={2}
266
+ step={0.05}
267
+ unit="m"
268
+ value={Math.round((node.width ?? 1) * 100) / 100}
201
269
  />
202
- <ActionButton
203
- icon={<Plus className="h-3.5 w-3.5" />}
204
- label="Add landing"
205
- onClick={handleAddLanding}
270
+ <MetricControl
271
+ label="Rise"
272
+ max={10}
273
+ min={0.2}
274
+ onChange={(value) => handleUpdate({ totalRise: value })}
275
+ precision={2}
276
+ step={0.05}
277
+ unit="m"
278
+ value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
206
279
  />
207
- </div>
208
- </PanelSection>
280
+ <MetricControl
281
+ label="Steps"
282
+ max={32}
283
+ min={2}
284
+ onChange={(value) => handleUpdate({ stepCount: Math.max(2, Math.round(value)) })}
285
+ precision={0}
286
+ step={1}
287
+ unit=""
288
+ value={Math.max(2, Math.round(node.stepCount ?? 10))}
289
+ />
290
+ {node.stairType !== 'spiral' && (
291
+ <ToggleControl
292
+ checked={node.fillToFloor ?? true}
293
+ label="Fit To Floor"
294
+ onChange={(checked) => handleUpdate({ fillToFloor: checked })}
295
+ />
296
+ )}
297
+ {(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
298
+ <MetricControl
299
+ label="Thickness"
300
+ max={1}
301
+ min={0.02}
302
+ onChange={(value) => handleUpdate({ thickness: value })}
303
+ precision={2}
304
+ step={0.01}
305
+ unit="m"
306
+ value={Math.round((node.thickness ?? 0.25) * 100) / 100}
307
+ />
308
+ )}
309
+ <MetricControl
310
+ label="Inner Radius"
311
+ max={10}
312
+ min={node.stairType === 'spiral' ? 0.05 : 0.2}
313
+ onChange={(value) => handleUpdate({ innerRadius: value })}
314
+ precision={2}
315
+ step={0.05}
316
+ unit="m"
317
+ value={Math.round((node.innerRadius ?? 0.9) * 100) / 100}
318
+ />
319
+ <SliderControl
320
+ label="Sweep"
321
+ max={node.stairType === 'spiral' ? 720 : 270}
322
+ min={node.stairType === 'spiral' ? -720 : -270}
323
+ onChange={(degrees) => handleUpdate({ sweepAngle: (degrees * Math.PI) / 180 })}
324
+ precision={0}
325
+ step={1}
326
+ unit="°"
327
+ value={Math.round(((node.sweepAngle ?? Math.PI / 2) * 180) / Math.PI)}
328
+ />
329
+ {node.stairType === 'spiral' && (
330
+ <>
331
+ <SegmentedControl
332
+ onChange={(value) => handleUpdate({ topLandingMode: value })}
333
+ options={TOP_LANDING_MODE_OPTIONS}
334
+ value={node.topLandingMode ?? 'none'}
335
+ />
336
+ {(node.topLandingMode ?? 'none') === 'integrated' && (
337
+ <MetricControl
338
+ label="Top Landing"
339
+ max={5}
340
+ min={0.3}
341
+ onChange={(value) => handleUpdate({ topLandingDepth: value })}
342
+ precision={2}
343
+ step={0.05}
344
+ unit="m"
345
+ value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100}
346
+ />
347
+ )}
348
+ <ToggleControl
349
+ checked={node.showCenterColumn ?? true}
350
+ label="Center Column"
351
+ onChange={(checked) => handleUpdate({ showCenterColumn: checked })}
352
+ />
353
+ <ToggleControl
354
+ checked={node.showStepSupports ?? true}
355
+ label="Step Supports"
356
+ onChange={(checked) => handleUpdate({ showStepSupports: checked })}
357
+ />
358
+ </>
359
+ )}
360
+ </PanelSection>
361
+ )}
209
362
 
210
363
  <PanelSection title="Position">
211
364
  <MetricControl
@@ -280,6 +433,26 @@ export function StairPanel() {
280
433
  </div>
281
434
  </PanelSection>
282
435
 
436
+ <PanelSection title="Railing">
437
+ <SegmentedControl
438
+ onChange={(value) => handleUpdate({ railingMode: value })}
439
+ options={RAILING_MODE_OPTIONS}
440
+ value={node.railingMode ?? 'none'}
441
+ />
442
+ {(node.railingMode ?? 'none') !== 'none' && (
443
+ <SliderControl
444
+ label="Height"
445
+ max={1.4}
446
+ min={0.7}
447
+ onChange={(value) => handleUpdate({ railingHeight: value })}
448
+ precision={2}
449
+ step={0.02}
450
+ unit="m"
451
+ value={Math.round((node.railingHeight ?? 0.92) * 100) / 100}
452
+ />
453
+ )}
454
+ </PanelSection>
455
+
283
456
  <PanelSection title="Actions">
284
457
  <ActionGroup>
285
458
  <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />