@pascal-app/editor 0.5.1 → 0.7.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 +12 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- 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/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +279 -15
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { type AnyNode, type
|
|
3
|
+
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
|
|
4
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
-
import { Edit, Plus, Trash2 } from 'lucide-react'
|
|
5
|
+
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
|
6
6
|
import { useCallback, useEffect } from 'react'
|
|
7
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
7
8
|
import useEditor from '../../../store/use-editor'
|
|
8
9
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
9
|
-
import { MaterialPicker } from '../controls/material-picker'
|
|
10
10
|
import { PanelSection } from '../controls/panel-section'
|
|
11
11
|
import { SliderControl } from '../controls/slider-control'
|
|
12
12
|
import { PanelWrapper } from './panel-wrapper'
|
|
13
13
|
|
|
14
14
|
export function SlabPanel() {
|
|
15
|
-
const
|
|
15
|
+
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
16
16
|
const setSelection = useViewer((s) => s.setSelection)
|
|
17
|
-
const nodes = useScene((s) => s.nodes)
|
|
18
17
|
const updateNode = useScene((s) => s.updateNode)
|
|
19
18
|
const editingHole = useEditor((s) => s.editingHole)
|
|
20
19
|
const setEditingHole = useEditor((s) => s.setEditingHole)
|
|
20
|
+
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
|
|
22
|
+
const node = useScene((s) =>
|
|
23
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined,
|
|
24
|
+
)
|
|
24
25
|
|
|
25
26
|
const handleUpdate = useCallback(
|
|
26
27
|
(updates: Partial<SlabNode>) => {
|
|
@@ -30,13 +31,6 @@ export function SlabPanel() {
|
|
|
30
31
|
[selectedId, updateNode],
|
|
31
32
|
)
|
|
32
33
|
|
|
33
|
-
const handleMaterialChange = useCallback(
|
|
34
|
-
(material: MaterialSchema) => {
|
|
35
|
-
handleUpdate({ material })
|
|
36
|
-
},
|
|
37
|
-
[handleUpdate],
|
|
38
|
-
)
|
|
39
|
-
|
|
40
34
|
const handleClose = useCallback(() => {
|
|
41
35
|
setSelection({ selectedIds: [] })
|
|
42
36
|
setEditingHole(null)
|
|
@@ -75,7 +69,13 @@ export function SlabPanel() {
|
|
|
75
69
|
[cx - holeSize, cz + holeSize],
|
|
76
70
|
]
|
|
77
71
|
const currentHoles = node?.holes || []
|
|
78
|
-
|
|
72
|
+
const currentMetadata = currentHoles.map(
|
|
73
|
+
(_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
|
|
74
|
+
)
|
|
75
|
+
handleUpdate({
|
|
76
|
+
holes: [...currentHoles, newHole],
|
|
77
|
+
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
|
78
|
+
})
|
|
79
79
|
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
|
80
80
|
}, [node, selectedId, handleUpdate, setEditingHole])
|
|
81
81
|
|
|
@@ -91,16 +91,28 @@ export function SlabPanel() {
|
|
|
91
91
|
(index: number) => {
|
|
92
92
|
if (!selectedId) return
|
|
93
93
|
const currentHoles = node?.holes || []
|
|
94
|
+
if (node?.holeMetadata?.[index]?.source === 'stair') return
|
|
94
95
|
const newHoles = currentHoles.filter((_, i) => i !== index)
|
|
95
|
-
|
|
96
|
+
const currentMetadata = currentHoles.map(
|
|
97
|
+
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
|
|
98
|
+
)
|
|
99
|
+
const newMetadata = currentMetadata.filter((_, i) => i !== index)
|
|
100
|
+
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
|
|
96
101
|
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
|
|
97
102
|
setEditingHole(null)
|
|
98
103
|
}
|
|
99
104
|
},
|
|
100
|
-
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
|
105
|
+
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
|
|
101
106
|
)
|
|
102
107
|
|
|
103
|
-
|
|
108
|
+
const handleMove = useCallback(() => {
|
|
109
|
+
if (!node) return
|
|
110
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
111
|
+
setMovingNode(node)
|
|
112
|
+
setSelection({ selectedIds: [] })
|
|
113
|
+
}, [node, setMovingNode, setSelection])
|
|
114
|
+
|
|
115
|
+
if (!(node && node.type === 'slab' && selectedId)) return null
|
|
104
116
|
|
|
105
117
|
const calculateArea = (polygon: Array<[number, number]>): number => {
|
|
106
118
|
if (polygon.length < 3) return 0
|
|
@@ -108,8 +120,11 @@ export function SlabPanel() {
|
|
|
108
120
|
const n = polygon.length
|
|
109
121
|
for (let i = 0; i < n; i++) {
|
|
110
122
|
const j = (i + 1) % n
|
|
111
|
-
|
|
112
|
-
|
|
123
|
+
const current = polygon[i]
|
|
124
|
+
const next = polygon[j]
|
|
125
|
+
if (!(current && next)) continue
|
|
126
|
+
area += current[0] * next[1]
|
|
127
|
+
area -= next[0] * current[1]
|
|
113
128
|
}
|
|
114
129
|
return Math.abs(area) / 2
|
|
115
130
|
}
|
|
@@ -157,6 +172,8 @@ export function SlabPanel() {
|
|
|
157
172
|
const holeArea = calculateArea(hole)
|
|
158
173
|
const isEditing =
|
|
159
174
|
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
|
175
|
+
const source = node.holeMetadata?.[index]?.source ?? 'manual'
|
|
176
|
+
const isAutoHole = source === 'stair'
|
|
160
177
|
return (
|
|
161
178
|
<div
|
|
162
179
|
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
|
@@ -173,7 +190,8 @@ export function SlabPanel() {
|
|
|
173
190
|
Hole {index + 1} {isEditing && '(Editing)'}
|
|
174
191
|
</p>
|
|
175
192
|
<p className="text-[10px] text-muted-foreground">
|
|
176
|
-
{holeArea.toFixed(2)} m² · {hole.length} pts
|
|
193
|
+
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
|
|
194
|
+
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
|
|
177
195
|
</p>
|
|
178
196
|
</div>
|
|
179
197
|
<div className="flex items-center gap-1">
|
|
@@ -183,6 +201,10 @@ export function SlabPanel() {
|
|
|
183
201
|
label="Done"
|
|
184
202
|
onClick={() => setEditingHole(null)}
|
|
185
203
|
/>
|
|
204
|
+
) : isAutoHole ? (
|
|
205
|
+
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
|
|
206
|
+
Auto
|
|
207
|
+
</div>
|
|
186
208
|
) : (
|
|
187
209
|
<>
|
|
188
210
|
<button
|
|
@@ -220,9 +242,9 @@ export function SlabPanel() {
|
|
|
220
242
|
/>
|
|
221
243
|
</div>
|
|
222
244
|
</PanelSection>
|
|
223
|
-
<
|
|
224
|
-
<
|
|
225
|
-
</
|
|
245
|
+
<ActionGroup>
|
|
246
|
+
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
|
247
|
+
</ActionGroup>
|
|
226
248
|
</PanelWrapper>
|
|
227
249
|
)
|
|
228
250
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNode, type SpawnNode, useLiveTransforms, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { Move, Trash2 } from 'lucide-react'
|
|
6
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
7
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
8
|
+
import useEditor from '../../../store/use-editor'
|
|
9
|
+
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
10
|
+
import { PanelSection } from '../controls/panel-section'
|
|
11
|
+
import { SliderControl } from '../controls/slider-control'
|
|
12
|
+
import { PanelWrapper } from './panel-wrapper'
|
|
13
|
+
|
|
14
|
+
export function SpawnPanel() {
|
|
15
|
+
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
16
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
17
|
+
const updateNode = useScene((s) => s.updateNode)
|
|
18
|
+
const deleteNode = useScene((s) => s.deleteNode)
|
|
19
|
+
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
20
|
+
|
|
21
|
+
const node = useScene((s) =>
|
|
22
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as SpawnNode | undefined) : undefined,
|
|
23
|
+
)
|
|
24
|
+
const [draftRotation, setDraftRotation] = useState<number | null>(null)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!(node && node.type === 'spawn')) {
|
|
28
|
+
setDraftRotation(null)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setDraftRotation(node.rotation)
|
|
33
|
+
useLiveTransforms.getState().clear(node.id)
|
|
34
|
+
}, [node?.id, node?.rotation, node?.type])
|
|
35
|
+
|
|
36
|
+
const handleUpdate = useCallback(
|
|
37
|
+
(updates: Partial<SpawnNode>) => {
|
|
38
|
+
if (!(selectedId && node)) return
|
|
39
|
+
updateNode(selectedId as AnyNode['id'], updates)
|
|
40
|
+
},
|
|
41
|
+
[node, selectedId, updateNode],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const handleRotationChange = useCallback(
|
|
45
|
+
(degrees: number) => {
|
|
46
|
+
if (!(node && selectedId)) return
|
|
47
|
+
const nextRotation = (degrees * Math.PI) / 180
|
|
48
|
+
setDraftRotation(nextRotation)
|
|
49
|
+
useLiveTransforms.getState().set(selectedId as AnyNode['id'], {
|
|
50
|
+
position: [...node.position],
|
|
51
|
+
rotation: nextRotation,
|
|
52
|
+
})
|
|
53
|
+
},
|
|
54
|
+
[node, selectedId],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const commitRotation = useCallback(
|
|
58
|
+
(degrees: number) => {
|
|
59
|
+
if (!(node && selectedId)) return
|
|
60
|
+
const nextRotation = (degrees * Math.PI) / 180
|
|
61
|
+
useLiveTransforms.getState().clear(selectedId as AnyNode['id'])
|
|
62
|
+
setDraftRotation(nextRotation)
|
|
63
|
+
if (Math.abs(nextRotation - node.rotation) > 1e-6) {
|
|
64
|
+
updateNode(selectedId as AnyNode['id'], { rotation: nextRotation })
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
[node, selectedId, updateNode],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const handleClose = useCallback(() => {
|
|
71
|
+
setSelection({ selectedIds: [] })
|
|
72
|
+
}, [setSelection])
|
|
73
|
+
|
|
74
|
+
const handleMove = useCallback(() => {
|
|
75
|
+
if (!node) return
|
|
76
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
77
|
+
setMovingNode(node)
|
|
78
|
+
setSelection({ selectedIds: [] })
|
|
79
|
+
}, [node, setMovingNode, setSelection])
|
|
80
|
+
|
|
81
|
+
const handleDelete = useCallback(() => {
|
|
82
|
+
if (!selectedId) return
|
|
83
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
84
|
+
deleteNode(selectedId as AnyNode['id'])
|
|
85
|
+
setSelection({ selectedIds: [] })
|
|
86
|
+
}, [deleteNode, selectedId, setSelection])
|
|
87
|
+
|
|
88
|
+
if (!(node && node.type === 'spawn' && selectedId)) return null
|
|
89
|
+
|
|
90
|
+
const rotationDegrees = Math.round((((draftRotation ?? node.rotation) * 180) / Math.PI))
|
|
91
|
+
const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<PanelWrapper icon="/icons/site.png" onClose={handleClose} title="Spawn Point" width={300}>
|
|
95
|
+
<PanelSection title="Position">
|
|
96
|
+
<SliderControl
|
|
97
|
+
label="X"
|
|
98
|
+
max={node.position[0] + 2}
|
|
99
|
+
min={node.position[0] - 2}
|
|
100
|
+
onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
|
|
101
|
+
precision={2}
|
|
102
|
+
step={0.01}
|
|
103
|
+
unit="m"
|
|
104
|
+
value={Math.round(node.position[0] * 100) / 100}
|
|
105
|
+
/>
|
|
106
|
+
<SliderControl
|
|
107
|
+
label="Y"
|
|
108
|
+
max={node.position[1] + 2}
|
|
109
|
+
min={node.position[1] - 2}
|
|
110
|
+
onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
|
|
111
|
+
precision={2}
|
|
112
|
+
step={0.01}
|
|
113
|
+
unit="m"
|
|
114
|
+
value={Math.round(node.position[1] * 100) / 100}
|
|
115
|
+
/>
|
|
116
|
+
<SliderControl
|
|
117
|
+
label="Z"
|
|
118
|
+
max={node.position[2] + 2}
|
|
119
|
+
min={node.position[2] - 2}
|
|
120
|
+
onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
|
|
121
|
+
precision={2}
|
|
122
|
+
step={0.01}
|
|
123
|
+
unit="m"
|
|
124
|
+
value={Math.round(node.position[2] * 100) / 100}
|
|
125
|
+
/>
|
|
126
|
+
</PanelSection>
|
|
127
|
+
|
|
128
|
+
<PanelSection title="Facing">
|
|
129
|
+
<SliderControl
|
|
130
|
+
label="Yaw"
|
|
131
|
+
max={storedRotationDegrees + 90}
|
|
132
|
+
min={storedRotationDegrees - 90}
|
|
133
|
+
onChange={handleRotationChange}
|
|
134
|
+
onCommit={commitRotation}
|
|
135
|
+
precision={0}
|
|
136
|
+
step={1}
|
|
137
|
+
unit="°"
|
|
138
|
+
value={rotationDegrees}
|
|
139
|
+
/>
|
|
140
|
+
</PanelSection>
|
|
141
|
+
|
|
142
|
+
<PanelSection title="Actions">
|
|
143
|
+
<ActionGroup>
|
|
144
|
+
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
|
|
145
|
+
<ActionButton
|
|
146
|
+
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
|
147
|
+
icon={<Trash2 className="h-4 w-4" />}
|
|
148
|
+
label="Delete"
|
|
149
|
+
onClick={handleDelete}
|
|
150
|
+
/>
|
|
151
|
+
</ActionGroup>
|
|
152
|
+
</PanelSection>
|
|
153
|
+
</PanelWrapper>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
import {
|
|
4
4
|
type AnyNode,
|
|
5
5
|
type AnyNodeId,
|
|
6
|
-
type
|
|
6
|
+
type LevelNode,
|
|
7
7
|
type StairNode,
|
|
8
8
|
type StairRailingMode,
|
|
9
|
+
type StairSlabOpeningMode,
|
|
9
10
|
type StairTopLandingMode,
|
|
10
11
|
type StairType,
|
|
11
|
-
StairNode as StairNodeSchema,
|
|
12
12
|
type StairSegmentNode,
|
|
13
13
|
StairSegmentNode as StairSegmentNodeSchema,
|
|
14
14
|
useScene,
|
|
@@ -16,11 +16,12 @@ import {
|
|
|
16
16
|
import { useViewer } from '@pascal-app/viewer'
|
|
17
17
|
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
|
18
18
|
import { useCallback } from 'react'
|
|
19
|
+
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
|
|
20
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
19
21
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
20
22
|
import useEditor from '../../../store/use-editor'
|
|
21
23
|
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
|
|
22
24
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
23
|
-
import { MaterialPicker } from '../controls/material-picker'
|
|
24
25
|
import { MetricControl } from '../controls/metric-control'
|
|
25
26
|
import { PanelSection } from '../controls/panel-section'
|
|
26
27
|
import { SegmentedControl } from '../controls/segmented-control'
|
|
@@ -46,19 +47,39 @@ const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[]
|
|
|
46
47
|
{ label: 'Integrated', value: 'integrated' },
|
|
47
48
|
]
|
|
48
49
|
|
|
50
|
+
const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }[] = [
|
|
51
|
+
{ label: 'None', value: 'none' },
|
|
52
|
+
{ label: 'Destination', value: 'destination' },
|
|
53
|
+
]
|
|
54
|
+
|
|
49
55
|
export function StairPanel() {
|
|
50
|
-
const
|
|
56
|
+
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
57
|
+
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
|
51
58
|
const setSelection = useViewer((s) => s.setSelection)
|
|
52
|
-
const nodes = useScene((s) => s.nodes)
|
|
53
59
|
const updateNode = useScene((s) => s.updateNode)
|
|
54
60
|
const createNode = useScene((s) => s.createNode)
|
|
55
|
-
const createNodes = useScene((s) => s.createNodes)
|
|
56
61
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
57
62
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
const node = useScene((s) =>
|
|
64
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
|
|
65
|
+
)
|
|
66
|
+
const levels = useScene(
|
|
67
|
+
useShallow((s) =>
|
|
68
|
+
Object.values(s.nodes)
|
|
69
|
+
.filter((entry): entry is LevelNode => entry.type === 'level')
|
|
70
|
+
.sort((left, right) => left.level - right.level),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
const segments = useScene(
|
|
74
|
+
useShallow((s) => {
|
|
75
|
+
if (!selectedId) return []
|
|
76
|
+
const stairNode = s.nodes[selectedId as AnyNode['id']] as StairNode | undefined
|
|
77
|
+
if (stairNode?.type !== 'stair') return []
|
|
78
|
+
return (stairNode.children ?? [])
|
|
79
|
+
.map((childId) => s.nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
|
80
|
+
.filter((entry): entry is StairSegmentNode => entry?.type === 'stair-segment')
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
62
83
|
|
|
63
84
|
const handleUpdate = useCallback(
|
|
64
85
|
(updates: Partial<StairNode>) => {
|
|
@@ -68,13 +89,6 @@ export function StairPanel() {
|
|
|
68
89
|
[selectedId, updateNode],
|
|
69
90
|
)
|
|
70
91
|
|
|
71
|
-
const handleMaterialChange = useCallback(
|
|
72
|
-
(material: MaterialSchema) => {
|
|
73
|
-
handleUpdate({ material })
|
|
74
|
-
},
|
|
75
|
-
[handleUpdate],
|
|
76
|
-
)
|
|
77
|
-
|
|
78
92
|
const handleClose = useCallback(() => {
|
|
79
93
|
setSelection({ selectedIds: [] })
|
|
80
94
|
}, [setSelection])
|
|
@@ -84,13 +98,15 @@ export function StairPanel() {
|
|
|
84
98
|
const children = node.children ?? []
|
|
85
99
|
const lastChildId = children[children.length - 1]
|
|
86
100
|
if (lastChildId) {
|
|
87
|
-
const lastChild = nodes[lastChildId as AnyNodeId] as
|
|
101
|
+
const lastChild = useScene.getState().nodes[lastChildId as AnyNodeId] as
|
|
102
|
+
| StairSegmentNode
|
|
103
|
+
| undefined
|
|
88
104
|
if (lastChild?.type === 'stair-segment') {
|
|
89
105
|
return { fillToFloor: lastChild.fillToFloor }
|
|
90
106
|
}
|
|
91
107
|
}
|
|
92
108
|
return { fillToFloor: true }
|
|
93
|
-
}, [node
|
|
109
|
+
}, [node])
|
|
94
110
|
|
|
95
111
|
const handleAddFlight = useCallback(() => {
|
|
96
112
|
if (!node) return
|
|
@@ -134,46 +150,15 @@ export function StairPanel() {
|
|
|
134
150
|
)
|
|
135
151
|
|
|
136
152
|
const handleDuplicate = useCallback(() => {
|
|
137
|
-
if (!node
|
|
153
|
+
if (!node) return
|
|
138
154
|
sfxEmitter.emit('sfx:item-pick')
|
|
139
155
|
|
|
140
|
-
let duplicateInfo = structuredClone(node) as any
|
|
141
|
-
delete duplicateInfo.id
|
|
142
|
-
duplicateInfo.metadata = { ...duplicateInfo.metadata }
|
|
143
|
-
duplicateInfo.children = []
|
|
144
|
-
duplicateInfo.position = [
|
|
145
|
-
duplicateInfo.position[0] + 1,
|
|
146
|
-
duplicateInfo.position[1],
|
|
147
|
-
duplicateInfo.position[2] + 1,
|
|
148
|
-
]
|
|
149
|
-
|
|
150
156
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const nodesState = useScene.getState().nodes
|
|
154
|
-
const children = node.children || []
|
|
155
|
-
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
|
|
156
|
-
{ node: duplicate, parentId: duplicate.parentId as AnyNodeId },
|
|
157
|
-
]
|
|
158
|
-
|
|
159
|
-
for (const childId of children) {
|
|
160
|
-
const childNode = nodesState[childId]
|
|
161
|
-
if (childNode && childNode.type === 'stair-segment') {
|
|
162
|
-
let childDuplicateInfo = structuredClone(childNode) as any
|
|
163
|
-
delete childDuplicateInfo.id
|
|
164
|
-
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
|
|
165
|
-
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
|
|
166
|
-
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
createNodes(createOps)
|
|
171
|
-
|
|
172
|
-
setSelection({ selectedIds: [duplicate.id as AnyNode['id']] })
|
|
157
|
+
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
|
|
173
158
|
} catch (e) {
|
|
174
159
|
console.error('Failed to duplicate stair', e)
|
|
175
160
|
}
|
|
176
|
-
}, [
|
|
161
|
+
}, [node])
|
|
177
162
|
|
|
178
163
|
const handleMove = useCallback(() => {
|
|
179
164
|
if (node) {
|
|
@@ -194,11 +179,10 @@ export function StairPanel() {
|
|
|
194
179
|
setSelection({ selectedIds: [] })
|
|
195
180
|
}, [selectedId, node, setSelection])
|
|
196
181
|
|
|
197
|
-
if (!node
|
|
182
|
+
if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
|
|
198
183
|
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
|
184
|
+
const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null
|
|
185
|
+
const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId
|
|
202
186
|
|
|
203
187
|
return (
|
|
204
188
|
<PanelWrapper
|
|
@@ -225,6 +209,73 @@ export function StairPanel() {
|
|
|
225
209
|
/>
|
|
226
210
|
</PanelSection>
|
|
227
211
|
|
|
212
|
+
<PanelSection title="Opening">
|
|
213
|
+
<div className="space-y-3">
|
|
214
|
+
<ToggleControl
|
|
215
|
+
checked={(node.slabOpeningMode ?? 'none') === 'destination'}
|
|
216
|
+
label="Auto Cutout"
|
|
217
|
+
onChange={(checked) =>
|
|
218
|
+
handleUpdate({
|
|
219
|
+
slabOpeningMode: checked ? 'destination' : 'none',
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
/>
|
|
223
|
+
|
|
224
|
+
<div className="space-y-1.5">
|
|
225
|
+
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
|
226
|
+
From Level
|
|
227
|
+
</div>
|
|
228
|
+
<select
|
|
229
|
+
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
|
230
|
+
onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
|
|
231
|
+
value={resolvedFromLevelId ?? ''}
|
|
232
|
+
>
|
|
233
|
+
{levels.map((level) => (
|
|
234
|
+
<option key={level.id} value={level.id}>
|
|
235
|
+
{level.name || `Level ${level.level + 1}`}
|
|
236
|
+
</option>
|
|
237
|
+
))}
|
|
238
|
+
</select>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div className="space-y-1.5">
|
|
242
|
+
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
|
243
|
+
To Level
|
|
244
|
+
</div>
|
|
245
|
+
<select
|
|
246
|
+
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
|
247
|
+
onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
|
|
248
|
+
value={resolvedToLevelId ?? ''}
|
|
249
|
+
>
|
|
250
|
+
{levels.map((level) => (
|
|
251
|
+
<option key={level.id} value={level.id}>
|
|
252
|
+
{level.name || `Level ${level.level + 1}`}
|
|
253
|
+
</option>
|
|
254
|
+
))}
|
|
255
|
+
</select>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<SegmentedControl
|
|
259
|
+
onChange={(value) => handleUpdate({ slabOpeningMode: value as StairSlabOpeningMode })}
|
|
260
|
+
options={STAIR_SLAB_OPENING_OPTIONS}
|
|
261
|
+
value={node.slabOpeningMode ?? 'none'}
|
|
262
|
+
/>
|
|
263
|
+
|
|
264
|
+
{(node.slabOpeningMode ?? 'none') === 'destination' ? (
|
|
265
|
+
<SliderControl
|
|
266
|
+
label="Opening Offset"
|
|
267
|
+
max={0.5}
|
|
268
|
+
min={0}
|
|
269
|
+
onChange={(value) => handleUpdate({ openingOffset: value })}
|
|
270
|
+
precision={2}
|
|
271
|
+
step={0.01}
|
|
272
|
+
unit="m"
|
|
273
|
+
value={Math.round((node.openingOffset ?? 0) * 100) / 100}
|
|
274
|
+
/>
|
|
275
|
+
) : null}
|
|
276
|
+
</div>
|
|
277
|
+
</PanelSection>
|
|
278
|
+
|
|
228
279
|
{node.stairType === 'straight' && (
|
|
229
280
|
<PanelSection title="Segments">
|
|
230
281
|
<div className="flex flex-col gap-1">
|
|
@@ -257,7 +308,7 @@ export function StairPanel() {
|
|
|
257
308
|
|
|
258
309
|
{(node.stairType === 'curved' || node.stairType === 'spiral') && (
|
|
259
310
|
<PanelSection title="Geometry">
|
|
260
|
-
<
|
|
311
|
+
<SliderControl
|
|
261
312
|
label="Width"
|
|
262
313
|
max={10}
|
|
263
314
|
min={0.4}
|
|
@@ -267,7 +318,7 @@ export function StairPanel() {
|
|
|
267
318
|
unit="m"
|
|
268
319
|
value={Math.round((node.width ?? 1) * 100) / 100}
|
|
269
320
|
/>
|
|
270
|
-
<
|
|
321
|
+
<SliderControl
|
|
271
322
|
label="Rise"
|
|
272
323
|
max={10}
|
|
273
324
|
min={0.2}
|
|
@@ -277,7 +328,7 @@ export function StairPanel() {
|
|
|
277
328
|
unit="m"
|
|
278
329
|
value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
|
|
279
330
|
/>
|
|
280
|
-
<
|
|
331
|
+
<SliderControl
|
|
281
332
|
label="Steps"
|
|
282
333
|
max={32}
|
|
283
334
|
min={2}
|
|
@@ -295,7 +346,7 @@ export function StairPanel() {
|
|
|
295
346
|
/>
|
|
296
347
|
)}
|
|
297
348
|
{(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
|
|
298
|
-
<
|
|
349
|
+
<SliderControl
|
|
299
350
|
label="Thickness"
|
|
300
351
|
max={1}
|
|
301
352
|
min={0.02}
|
|
@@ -306,7 +357,7 @@ export function StairPanel() {
|
|
|
306
357
|
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
|
|
307
358
|
/>
|
|
308
359
|
)}
|
|
309
|
-
<
|
|
360
|
+
<SliderControl
|
|
310
361
|
label="Inner Radius"
|
|
311
362
|
max={10}
|
|
312
363
|
min={node.stairType === 'spiral' ? 0.05 : 0.2}
|
|
@@ -334,7 +385,7 @@ export function StairPanel() {
|
|
|
334
385
|
value={node.topLandingMode ?? 'none'}
|
|
335
386
|
/>
|
|
336
387
|
{(node.topLandingMode ?? 'none') === 'integrated' && (
|
|
337
|
-
<
|
|
388
|
+
<SliderControl
|
|
338
389
|
label="Top Landing"
|
|
339
390
|
max={5}
|
|
340
391
|
min={0.3}
|
|
@@ -361,7 +412,7 @@ export function StairPanel() {
|
|
|
361
412
|
)}
|
|
362
413
|
|
|
363
414
|
<PanelSection title="Position">
|
|
364
|
-
<
|
|
415
|
+
<SliderControl
|
|
365
416
|
label="X"
|
|
366
417
|
max={50}
|
|
367
418
|
min={-50}
|
|
@@ -375,7 +426,7 @@ export function StairPanel() {
|
|
|
375
426
|
unit="m"
|
|
376
427
|
value={Math.round(node.position[0] * 100) / 100}
|
|
377
428
|
/>
|
|
378
|
-
<
|
|
429
|
+
<SliderControl
|
|
379
430
|
label="Y"
|
|
380
431
|
max={50}
|
|
381
432
|
min={-50}
|
|
@@ -389,7 +440,7 @@ export function StairPanel() {
|
|
|
389
440
|
unit="m"
|
|
390
441
|
value={Math.round(node.position[1] * 100) / 100}
|
|
391
442
|
/>
|
|
392
|
-
<
|
|
443
|
+
<SliderControl
|
|
393
444
|
label="Z"
|
|
394
445
|
max={50}
|
|
395
446
|
min={-50}
|
|
@@ -469,9 +520,6 @@ export function StairPanel() {
|
|
|
469
520
|
/>
|
|
470
521
|
</ActionGroup>
|
|
471
522
|
</PanelSection>
|
|
472
|
-
<PanelSection title="Material">
|
|
473
|
-
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
|
474
|
-
</PanelSection>
|
|
475
523
|
</PanelWrapper>
|
|
476
524
|
)
|
|
477
525
|
}
|