@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.
- package/package.json +5 -5
- package/src/components/editor/floating-action-menu.tsx +101 -29
- package/src/components/editor/floating-building-action-menu.tsx +69 -0
- package/src/components/editor/floorplan-panel.tsx +31 -13
- package/src/components/editor/index.tsx +219 -167
- package/src/components/editor/node-action-menu.tsx +26 -10
- package/src/components/editor/selection-manager.tsx +38 -2
- package/src/components/editor/thumbnail-generator.tsx +245 -64
- package/src/components/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- package/src/components/tools/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +19 -7
- package/src/components/tools/door/move-door-tool.tsx +17 -8
- package/src/components/tools/fence/fence-drafting.ts +125 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-tool.tsx +223 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +7 -0
- package/src/components/tools/item/placement-strategies.ts +15 -7
- package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
- package/src/components/tools/roof/move-roof-tool.tsx +5 -2
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- package/src/components/tools/shared/polygon-editor.tsx +2 -2
- package/src/components/tools/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +29 -6
- package/src/components/tools/tool-manager.tsx +42 -14
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +17 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +19 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/panels/fence-panel.tsx +184 -0
- package/src/components/ui/panels/panel-manager.tsx +3 -0
- package/src/components/ui/panels/stair-panel.tsx +206 -33
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +59 -52
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
- package/src/components/viewer-overlay.tsx +1 -0
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +10 -2
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +4 -0
- 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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
178
|
-
const gridZ = Math.round(event.
|
|
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.
|
|
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.
|
|
186
|
+
cursorRef.current.position.set(snapped[0], event.localPosition[1], snapped[1])
|
|
187
187
|
} else {
|
|
188
|
-
cursorRef.current.position.set(gridX, event.
|
|
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.
|
|
198
|
-
const gridZ = Math.round(event.
|
|
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
|
|
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
|
|
164
|
+
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
|
|
138
165
|
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
|
|
139
|
-
|
|
166
|
+
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
|
|
140
167
|
}
|
|
141
168
|
}
|
|
142
169
|
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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="
|
|
183
|
-
<
|
|
184
|
-
{
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
<
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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} />
|