@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.
- package/package.json +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +341 -48
- package/src/components/editor/floating-building-action-menu.tsx +70 -0
- package/src/components/editor/floorplan-panel.tsx +1350 -722
- package/src/components/editor/index.tsx +221 -167
- package/src/components/editor/node-action-menu.tsx +40 -11
- package/src/components/editor/selection-manager.tsx +238 -10
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +422 -79
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- 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/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +31 -7
- package/src/components/tools/door/move-door-tool.tsx +27 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +137 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +231 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +16 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +17 -9
- package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
- package/src/components/tools/roof/move-roof-tool.tsx +90 -26
- 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 +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- 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 +39 -8
- package/src/components/tools/tool-manager.tsx +54 -14
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +27 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +31 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +269 -0
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +32 -27
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +377 -50
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
- 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 +64 -53
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +26 -19
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +25 -16
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +7 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- 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
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
239
|
+
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
|
|
138
240
|
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
|
|
139
|
-
|
|
241
|
+
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
|
|
140
242
|
}
|
|
141
243
|
}
|
|
142
244
|
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
|
272
|
+
if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
|
|
170
273
|
|
|
171
|
-
const
|
|
172
|
-
|
|
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="
|
|
183
|
-
<
|
|
184
|
-
{
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
: undefined
|
|
42
|
+
const node = useScene((s) =>
|
|
43
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined) : undefined,
|
|
44
|
+
)
|
|
48
45
|
|
|
49
|
-
//
|
|
50
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
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
|
)
|