@pascal-app/editor 0.4.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 +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { Edit, Plus, Trash2 } from 'lucide-react'
|
|
6
|
+
import { useCallback, useEffect } from 'react'
|
|
7
|
+
import useEditor from '../../../store/use-editor'
|
|
8
|
+
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
9
|
+
import { MaterialPicker } from '../controls/material-picker'
|
|
10
|
+
import { PanelSection } from '../controls/panel-section'
|
|
11
|
+
import { SliderControl } from '../controls/slider-control'
|
|
12
|
+
import { PanelWrapper } from './panel-wrapper'
|
|
13
|
+
|
|
14
|
+
export function SlabPanel() {
|
|
15
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
16
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
17
|
+
const nodes = useScene((s) => s.nodes)
|
|
18
|
+
const updateNode = useScene((s) => s.updateNode)
|
|
19
|
+
const editingHole = useEditor((s) => s.editingHole)
|
|
20
|
+
const setEditingHole = useEditor((s) => s.setEditingHole)
|
|
21
|
+
|
|
22
|
+
const selectedId = selectedIds[0]
|
|
23
|
+
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined
|
|
24
|
+
|
|
25
|
+
const handleUpdate = useCallback(
|
|
26
|
+
(updates: Partial<SlabNode>) => {
|
|
27
|
+
if (!selectedId) return
|
|
28
|
+
updateNode(selectedId as AnyNode['id'], updates)
|
|
29
|
+
},
|
|
30
|
+
[selectedId, updateNode],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const handleMaterialChange = useCallback(
|
|
34
|
+
(material: MaterialSchema) => {
|
|
35
|
+
handleUpdate({ material })
|
|
36
|
+
},
|
|
37
|
+
[handleUpdate],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const handleClose = useCallback(() => {
|
|
41
|
+
setSelection({ selectedIds: [] })
|
|
42
|
+
setEditingHole(null)
|
|
43
|
+
}, [setSelection, setEditingHole])
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!node) {
|
|
47
|
+
setEditingHole(null)
|
|
48
|
+
}
|
|
49
|
+
}, [node, setEditingHole])
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
return () => {
|
|
53
|
+
setEditingHole(null)
|
|
54
|
+
}
|
|
55
|
+
}, [setEditingHole])
|
|
56
|
+
|
|
57
|
+
const handleAddHole = useCallback(() => {
|
|
58
|
+
if (!(node && selectedId)) return
|
|
59
|
+
|
|
60
|
+
const polygon = node.polygon
|
|
61
|
+
let cx = 0
|
|
62
|
+
let cz = 0
|
|
63
|
+
for (const [x, z] of polygon) {
|
|
64
|
+
cx += x
|
|
65
|
+
cz += z
|
|
66
|
+
}
|
|
67
|
+
cx /= polygon.length
|
|
68
|
+
cz /= polygon.length
|
|
69
|
+
|
|
70
|
+
const holeSize = 0.5
|
|
71
|
+
const newHole: Array<[number, number]> = [
|
|
72
|
+
[cx - holeSize, cz - holeSize],
|
|
73
|
+
[cx + holeSize, cz - holeSize],
|
|
74
|
+
[cx + holeSize, cz + holeSize],
|
|
75
|
+
[cx - holeSize, cz + holeSize],
|
|
76
|
+
]
|
|
77
|
+
const currentHoles = node?.holes || []
|
|
78
|
+
handleUpdate({ holes: [...currentHoles, newHole] })
|
|
79
|
+
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
|
80
|
+
}, [node, selectedId, handleUpdate, setEditingHole])
|
|
81
|
+
|
|
82
|
+
const handleEditHole = useCallback(
|
|
83
|
+
(index: number) => {
|
|
84
|
+
if (!selectedId) return
|
|
85
|
+
setEditingHole({ nodeId: selectedId, holeIndex: index })
|
|
86
|
+
},
|
|
87
|
+
[selectedId, setEditingHole],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const handleDeleteHole = useCallback(
|
|
91
|
+
(index: number) => {
|
|
92
|
+
if (!selectedId) return
|
|
93
|
+
const currentHoles = node?.holes || []
|
|
94
|
+
const newHoles = currentHoles.filter((_, i) => i !== index)
|
|
95
|
+
handleUpdate({ holes: newHoles })
|
|
96
|
+
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
|
|
97
|
+
setEditingHole(null)
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
|
|
104
|
+
|
|
105
|
+
const calculateArea = (polygon: Array<[number, number]>): number => {
|
|
106
|
+
if (polygon.length < 3) return 0
|
|
107
|
+
let area = 0
|
|
108
|
+
const n = polygon.length
|
|
109
|
+
for (let i = 0; i < n; i++) {
|
|
110
|
+
const j = (i + 1) % n
|
|
111
|
+
area += polygon[i]?.[0] * polygon[j]?.[1]
|
|
112
|
+
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
|
113
|
+
}
|
|
114
|
+
return Math.abs(area) / 2
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const area = calculateArea(node.polygon)
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<PanelWrapper
|
|
121
|
+
icon="/icons/floor.png"
|
|
122
|
+
onClose={handleClose}
|
|
123
|
+
title={node.name || 'Slab'}
|
|
124
|
+
width={320}
|
|
125
|
+
>
|
|
126
|
+
<PanelSection title="Elevation">
|
|
127
|
+
<SliderControl
|
|
128
|
+
label="Height"
|
|
129
|
+
max={1}
|
|
130
|
+
min={-1}
|
|
131
|
+
onChange={(v) => handleUpdate({ elevation: v })}
|
|
132
|
+
precision={3}
|
|
133
|
+
step={0.01}
|
|
134
|
+
unit="m"
|
|
135
|
+
value={Math.round(node.elevation * 1000) / 1000}
|
|
136
|
+
/>
|
|
137
|
+
|
|
138
|
+
<div className="mt-2 grid grid-cols-2 gap-1.5 px-1 pb-1">
|
|
139
|
+
<ActionButton label="Sunken (-15cm)" onClick={() => handleUpdate({ elevation: -0.15 })} />
|
|
140
|
+
<ActionButton label="Ground (0m)" onClick={() => handleUpdate({ elevation: 0 })} />
|
|
141
|
+
<ActionButton label="Raised (+5cm)" onClick={() => handleUpdate({ elevation: 0.05 })} />
|
|
142
|
+
<ActionButton label="Step (+15cm)" onClick={() => handleUpdate({ elevation: 0.15 })} />
|
|
143
|
+
</div>
|
|
144
|
+
</PanelSection>
|
|
145
|
+
|
|
146
|
+
<PanelSection title="Info">
|
|
147
|
+
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
|
|
148
|
+
<span>Area</span>
|
|
149
|
+
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
|
|
150
|
+
</div>
|
|
151
|
+
</PanelSection>
|
|
152
|
+
|
|
153
|
+
<PanelSection title="Holes">
|
|
154
|
+
{node.holes && node.holes.length > 0 ? (
|
|
155
|
+
<div className="flex flex-col gap-1 pb-2">
|
|
156
|
+
{node.holes.map((hole, index) => {
|
|
157
|
+
const holeArea = calculateArea(hole)
|
|
158
|
+
const isEditing =
|
|
159
|
+
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
|
160
|
+
return (
|
|
161
|
+
<div
|
|
162
|
+
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
|
163
|
+
isEditing
|
|
164
|
+
? 'border-primary/50 bg-primary/10'
|
|
165
|
+
: 'border-transparent hover:bg-accent/30'
|
|
166
|
+
}`}
|
|
167
|
+
key={index}
|
|
168
|
+
>
|
|
169
|
+
<div className="min-w-0 flex-1">
|
|
170
|
+
<p
|
|
171
|
+
className={`font-medium text-xs ${isEditing ? 'text-primary' : 'text-white'}`}
|
|
172
|
+
>
|
|
173
|
+
Hole {index + 1} {isEditing && '(Editing)'}
|
|
174
|
+
</p>
|
|
175
|
+
<p className="text-[10px] text-muted-foreground">
|
|
176
|
+
{holeArea.toFixed(2)} m² · {hole.length} pts
|
|
177
|
+
</p>
|
|
178
|
+
</div>
|
|
179
|
+
<div className="flex items-center gap-1">
|
|
180
|
+
{isEditing ? (
|
|
181
|
+
<ActionButton
|
|
182
|
+
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
|
|
183
|
+
label="Done"
|
|
184
|
+
onClick={() => setEditingHole(null)}
|
|
185
|
+
/>
|
|
186
|
+
) : (
|
|
187
|
+
<>
|
|
188
|
+
<button
|
|
189
|
+
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
|
|
190
|
+
onClick={() => handleEditHole(index)}
|
|
191
|
+
type="button"
|
|
192
|
+
>
|
|
193
|
+
<Edit className="h-3.5 w-3.5" />
|
|
194
|
+
</button>
|
|
195
|
+
<button
|
|
196
|
+
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
|
|
197
|
+
onClick={() => handleDeleteHole(index)}
|
|
198
|
+
type="button"
|
|
199
|
+
>
|
|
200
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
201
|
+
</button>
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
)
|
|
207
|
+
})}
|
|
208
|
+
</div>
|
|
209
|
+
) : (
|
|
210
|
+
<div className="px-2 py-3 text-center text-muted-foreground text-xs">No holes</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
<div className="px-1 pt-1 pb-1">
|
|
214
|
+
<ActionButton
|
|
215
|
+
className="w-full"
|
|
216
|
+
disabled={editingHole?.nodeId === selectedId}
|
|
217
|
+
icon={<Plus className="h-3.5 w-3.5" />}
|
|
218
|
+
label="Add Hole"
|
|
219
|
+
onClick={handleAddHole}
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
</PanelSection>
|
|
223
|
+
<PanelSection title="Material">
|
|
224
|
+
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
|
225
|
+
</PanelSection>
|
|
226
|
+
</PanelWrapper>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
type MaterialSchema,
|
|
7
|
+
type StairNode,
|
|
8
|
+
StairNode as StairNodeSchema,
|
|
9
|
+
type StairSegmentNode,
|
|
10
|
+
StairSegmentNode as StairSegmentNodeSchema,
|
|
11
|
+
useScene,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
14
|
+
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
|
15
|
+
import { useCallback } from 'react'
|
|
16
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
17
|
+
import useEditor from '../../../store/use-editor'
|
|
18
|
+
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
19
|
+
import { MaterialPicker } from '../controls/material-picker'
|
|
20
|
+
import { MetricControl } from '../controls/metric-control'
|
|
21
|
+
import { PanelSection } from '../controls/panel-section'
|
|
22
|
+
import { SliderControl } from '../controls/slider-control'
|
|
23
|
+
import { PanelWrapper } from './panel-wrapper'
|
|
24
|
+
|
|
25
|
+
export function StairPanel() {
|
|
26
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
27
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
28
|
+
const nodes = useScene((s) => s.nodes)
|
|
29
|
+
const updateNode = useScene((s) => s.updateNode)
|
|
30
|
+
const createNode = useScene((s) => s.createNode)
|
|
31
|
+
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
32
|
+
|
|
33
|
+
const selectedId = selectedIds[0]
|
|
34
|
+
const node = selectedId
|
|
35
|
+
? (nodes[selectedId as AnyNode['id']] as StairNode | undefined)
|
|
36
|
+
: undefined
|
|
37
|
+
|
|
38
|
+
const handleUpdate = useCallback(
|
|
39
|
+
(updates: Partial<StairNode>) => {
|
|
40
|
+
if (!selectedId) return
|
|
41
|
+
updateNode(selectedId as AnyNode['id'], updates)
|
|
42
|
+
},
|
|
43
|
+
[selectedId, updateNode],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const handleMaterialChange = useCallback(
|
|
47
|
+
(material: MaterialSchema) => {
|
|
48
|
+
handleUpdate({ material })
|
|
49
|
+
},
|
|
50
|
+
[handleUpdate],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const handleClose = useCallback(() => {
|
|
54
|
+
setSelection({ selectedIds: [] })
|
|
55
|
+
}, [setSelection])
|
|
56
|
+
|
|
57
|
+
const getLastSegmentFillDefaults = useCallback(() => {
|
|
58
|
+
if (!node) return { fillToFloor: true }
|
|
59
|
+
const children = node.children ?? []
|
|
60
|
+
const lastChildId = children[children.length - 1]
|
|
61
|
+
if (lastChildId) {
|
|
62
|
+
const lastChild = nodes[lastChildId as AnyNodeId] as StairSegmentNode | undefined
|
|
63
|
+
if (lastChild?.type === 'stair-segment') {
|
|
64
|
+
return { fillToFloor: lastChild.fillToFloor }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { fillToFloor: true }
|
|
68
|
+
}, [node, nodes])
|
|
69
|
+
|
|
70
|
+
const handleAddFlight = useCallback(() => {
|
|
71
|
+
if (!node) return
|
|
72
|
+
const { fillToFloor } = getLastSegmentFillDefaults()
|
|
73
|
+
const segment = StairSegmentNodeSchema.parse({
|
|
74
|
+
segmentType: 'stair',
|
|
75
|
+
width: 1.0,
|
|
76
|
+
length: 3.0,
|
|
77
|
+
height: 2.5,
|
|
78
|
+
stepCount: 10,
|
|
79
|
+
attachmentSide: 'front',
|
|
80
|
+
fillToFloor,
|
|
81
|
+
thickness: 0.25,
|
|
82
|
+
position: [0, 0, 0],
|
|
83
|
+
})
|
|
84
|
+
createNode(segment, node.id as AnyNodeId)
|
|
85
|
+
}, [node, createNode, getLastSegmentFillDefaults])
|
|
86
|
+
|
|
87
|
+
const handleAddLanding = useCallback(() => {
|
|
88
|
+
if (!node) return
|
|
89
|
+
const { fillToFloor } = getLastSegmentFillDefaults()
|
|
90
|
+
const segment = StairSegmentNodeSchema.parse({
|
|
91
|
+
segmentType: 'landing',
|
|
92
|
+
width: 1.0,
|
|
93
|
+
length: 1.0,
|
|
94
|
+
height: 0,
|
|
95
|
+
stepCount: 0,
|
|
96
|
+
attachmentSide: 'front',
|
|
97
|
+
fillToFloor,
|
|
98
|
+
thickness: 0.32,
|
|
99
|
+
position: [0, 0, 0],
|
|
100
|
+
})
|
|
101
|
+
createNode(segment, node.id as AnyNodeId)
|
|
102
|
+
}, [node, createNode, getLastSegmentFillDefaults])
|
|
103
|
+
|
|
104
|
+
const handleSelectSegment = useCallback(
|
|
105
|
+
(segmentId: string) => {
|
|
106
|
+
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
|
|
107
|
+
},
|
|
108
|
+
[setSelection],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const handleDuplicate = useCallback(() => {
|
|
112
|
+
if (!node?.parentId) return
|
|
113
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
114
|
+
|
|
115
|
+
let duplicateInfo = structuredClone(node) as any
|
|
116
|
+
delete duplicateInfo.id
|
|
117
|
+
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
|
118
|
+
duplicateInfo.position = [
|
|
119
|
+
duplicateInfo.position[0] + 1,
|
|
120
|
+
duplicateInfo.position[1],
|
|
121
|
+
duplicateInfo.position[2] + 1,
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const duplicate = StairNodeSchema.parse(duplicateInfo)
|
|
126
|
+
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
127
|
+
|
|
128
|
+
// Also duplicate all child segments
|
|
129
|
+
const nodesState = useScene.getState().nodes
|
|
130
|
+
const children = node.children || []
|
|
131
|
+
|
|
132
|
+
for (const childId of children) {
|
|
133
|
+
const childNode = nodesState[childId]
|
|
134
|
+
if (childNode && childNode.type === 'stair-segment') {
|
|
135
|
+
let childDuplicateInfo = structuredClone(childNode) as any
|
|
136
|
+
delete childDuplicateInfo.id
|
|
137
|
+
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
|
|
138
|
+
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
|
|
139
|
+
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setSelection({ selectedIds: [] })
|
|
144
|
+
setMovingNode(duplicate)
|
|
145
|
+
} catch (e) {
|
|
146
|
+
console.error('Failed to duplicate stair', e)
|
|
147
|
+
}
|
|
148
|
+
}, [node, setSelection, setMovingNode])
|
|
149
|
+
|
|
150
|
+
const handleMove = useCallback(() => {
|
|
151
|
+
if (node) {
|
|
152
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
153
|
+
setMovingNode(node)
|
|
154
|
+
setSelection({ selectedIds: [] })
|
|
155
|
+
}
|
|
156
|
+
}, [node, setMovingNode, setSelection])
|
|
157
|
+
|
|
158
|
+
const handleDelete = useCallback(() => {
|
|
159
|
+
if (!(selectedId && node)) return
|
|
160
|
+
sfxEmitter.emit('sfx:item-delete')
|
|
161
|
+
const parentId = node.parentId
|
|
162
|
+
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
|
163
|
+
if (parentId) {
|
|
164
|
+
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
|
|
165
|
+
}
|
|
166
|
+
setSelection({ selectedIds: [] })
|
|
167
|
+
}, [selectedId, node, setSelection])
|
|
168
|
+
|
|
169
|
+
if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
|
|
170
|
+
|
|
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')
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<PanelWrapper
|
|
177
|
+
icon="/icons/stairs.png"
|
|
178
|
+
onClose={handleClose}
|
|
179
|
+
title={node.name || 'Staircase'}
|
|
180
|
+
width={300}
|
|
181
|
+
>
|
|
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}
|
|
201
|
+
/>
|
|
202
|
+
<ActionButton
|
|
203
|
+
icon={<Plus className="h-3.5 w-3.5" />}
|
|
204
|
+
label="Add landing"
|
|
205
|
+
onClick={handleAddLanding}
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
</PanelSection>
|
|
209
|
+
|
|
210
|
+
<PanelSection title="Position">
|
|
211
|
+
<MetricControl
|
|
212
|
+
label="X"
|
|
213
|
+
max={50}
|
|
214
|
+
min={-50}
|
|
215
|
+
onChange={(v) => {
|
|
216
|
+
const pos = [...node.position] as [number, number, number]
|
|
217
|
+
pos[0] = v
|
|
218
|
+
handleUpdate({ position: pos })
|
|
219
|
+
}}
|
|
220
|
+
precision={2}
|
|
221
|
+
step={0.05}
|
|
222
|
+
unit="m"
|
|
223
|
+
value={Math.round(node.position[0] * 100) / 100}
|
|
224
|
+
/>
|
|
225
|
+
<MetricControl
|
|
226
|
+
label="Y"
|
|
227
|
+
max={50}
|
|
228
|
+
min={-50}
|
|
229
|
+
onChange={(v) => {
|
|
230
|
+
const pos = [...node.position] as [number, number, number]
|
|
231
|
+
pos[1] = v
|
|
232
|
+
handleUpdate({ position: pos })
|
|
233
|
+
}}
|
|
234
|
+
precision={2}
|
|
235
|
+
step={0.05}
|
|
236
|
+
unit="m"
|
|
237
|
+
value={Math.round(node.position[1] * 100) / 100}
|
|
238
|
+
/>
|
|
239
|
+
<MetricControl
|
|
240
|
+
label="Z"
|
|
241
|
+
max={50}
|
|
242
|
+
min={-50}
|
|
243
|
+
onChange={(v) => {
|
|
244
|
+
const pos = [...node.position] as [number, number, number]
|
|
245
|
+
pos[2] = v
|
|
246
|
+
handleUpdate({ position: pos })
|
|
247
|
+
}}
|
|
248
|
+
precision={2}
|
|
249
|
+
step={0.05}
|
|
250
|
+
unit="m"
|
|
251
|
+
value={Math.round(node.position[2] * 100) / 100}
|
|
252
|
+
/>
|
|
253
|
+
<SliderControl
|
|
254
|
+
label="Rotation"
|
|
255
|
+
max={180}
|
|
256
|
+
min={-180}
|
|
257
|
+
onChange={(degrees) => {
|
|
258
|
+
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
|
|
259
|
+
}}
|
|
260
|
+
precision={0}
|
|
261
|
+
step={1}
|
|
262
|
+
unit="°"
|
|
263
|
+
value={Math.round((node.rotation * 180) / Math.PI)}
|
|
264
|
+
/>
|
|
265
|
+
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
|
266
|
+
<ActionButton
|
|
267
|
+
label="-45°"
|
|
268
|
+
onClick={() => {
|
|
269
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
270
|
+
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
|
|
271
|
+
}}
|
|
272
|
+
/>
|
|
273
|
+
<ActionButton
|
|
274
|
+
label="+45°"
|
|
275
|
+
onClick={() => {
|
|
276
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
277
|
+
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
|
|
278
|
+
}}
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
</PanelSection>
|
|
282
|
+
|
|
283
|
+
<PanelSection title="Actions">
|
|
284
|
+
<ActionGroup>
|
|
285
|
+
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
|
286
|
+
<ActionButton
|
|
287
|
+
icon={<Copy className="h-3.5 w-3.5" />}
|
|
288
|
+
label="Duplicate"
|
|
289
|
+
onClick={handleDuplicate}
|
|
290
|
+
/>
|
|
291
|
+
<ActionButton
|
|
292
|
+
className="hover:bg-red-500/20"
|
|
293
|
+
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
|
294
|
+
label="Delete"
|
|
295
|
+
onClick={handleDelete}
|
|
296
|
+
/>
|
|
297
|
+
</ActionGroup>
|
|
298
|
+
</PanelSection>
|
|
299
|
+
<PanelSection title="Material">
|
|
300
|
+
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
|
301
|
+
</PanelSection>
|
|
302
|
+
</PanelWrapper>
|
|
303
|
+
)
|
|
304
|
+
}
|