@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,53 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type GuideNode,
|
|
6
|
+
loadAssetUrl,
|
|
7
|
+
saveAsset,
|
|
8
|
+
type ScanNode,
|
|
9
|
+
useScene,
|
|
10
|
+
} from '@pascal-app/core'
|
|
11
|
+
import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react'
|
|
12
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
13
|
+
import { guideEmitter } from '../../../lib/guide-events'
|
|
14
|
+
import { getGuideImageName } from '../../../lib/local-guide-image'
|
|
6
15
|
import useEditor from '../../../store/use-editor'
|
|
7
16
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
8
|
-
import { MetricControl } from '../controls/metric-control'
|
|
9
17
|
import { PanelSection } from '../controls/panel-section'
|
|
10
18
|
import { SliderControl } from '../controls/slider-control'
|
|
11
19
|
import { PanelWrapper } from './panel-wrapper'
|
|
12
20
|
|
|
13
21
|
type ReferenceNode = ScanNode | GuideNode
|
|
14
22
|
|
|
23
|
+
function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) {
|
|
24
|
+
const reference = guide.scaleReference
|
|
25
|
+
if (!reference) {
|
|
26
|
+
return 'Uncalibrated'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return `${scaleReferenceVisible ? 'Scaled' : 'Scaled (hidden)'} · ${reference.label}`
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
export function ReferencePanel() {
|
|
16
33
|
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
|
17
34
|
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
|
18
|
-
const
|
|
35
|
+
const guideUi = useEditor((s) => (selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined))
|
|
36
|
+
const setGuideLocked = useEditor((s) => s.setGuideLocked)
|
|
37
|
+
const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
|
|
38
|
+
const clearGuideUi = useEditor((s) => s.clearGuideUi)
|
|
19
39
|
const updateNode = useScene((s) => s.updateNode)
|
|
40
|
+
const deleteNode = useScene((s) => s.deleteNode)
|
|
41
|
+
const replaceInputRef = useRef<HTMLInputElement>(null)
|
|
42
|
+
const [isReplacing, setIsReplacing] = useState(false)
|
|
43
|
+
const [replaceError, setReplaceError] = useState<string | null>(null)
|
|
44
|
+
const [isAssetMissing, setIsAssetMissing] = useState(false)
|
|
20
45
|
|
|
21
|
-
const node =
|
|
22
|
-
|
|
23
|
-
|
|
46
|
+
const node = useScene((s) =>
|
|
47
|
+
selectedReferenceId
|
|
48
|
+
? (s.nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
|
|
49
|
+
: undefined,
|
|
50
|
+
)
|
|
24
51
|
|
|
25
52
|
const handleUpdate = useCallback(
|
|
26
53
|
(updates: Partial<ReferenceNode>) => {
|
|
@@ -34,17 +61,224 @@ export function ReferencePanel() {
|
|
|
34
61
|
setSelectedReferenceId(null)
|
|
35
62
|
}, [setSelectedReferenceId])
|
|
36
63
|
|
|
64
|
+
const handleReplaceFile = useCallback(
|
|
65
|
+
async (file: File) => {
|
|
66
|
+
if (!(selectedReferenceId && node?.type === 'guide')) {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!file.type.startsWith('image/')) {
|
|
71
|
+
setReplaceError('Choose a PNG, JPEG, or WebP image.')
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setIsReplacing(true)
|
|
76
|
+
setReplaceError(null)
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const assetUrl = await saveAsset(file)
|
|
80
|
+
updateNode(selectedReferenceId as AnyNode['id'], {
|
|
81
|
+
name: getGuideImageName(file.name),
|
|
82
|
+
url: assetUrl,
|
|
83
|
+
scaleReference: null,
|
|
84
|
+
} as Partial<GuideNode>)
|
|
85
|
+
setGuideScaleReferenceVisible(selectedReferenceId, true)
|
|
86
|
+
} catch {
|
|
87
|
+
setReplaceError('Could not replace that image.')
|
|
88
|
+
} finally {
|
|
89
|
+
setIsReplacing(false)
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
[node?.type, selectedReferenceId, setGuideScaleReferenceVisible, updateNode],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const handleDeleteGuide = useCallback(() => {
|
|
96
|
+
if (!(selectedReferenceId && node?.type === 'guide')) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
deleteNode(selectedReferenceId as AnyNode['id'])
|
|
101
|
+
guideEmitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] })
|
|
102
|
+
clearGuideUi(selectedReferenceId)
|
|
103
|
+
setSelectedReferenceId(null)
|
|
104
|
+
}, [clearGuideUi, deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId])
|
|
105
|
+
|
|
106
|
+
const handleStartScale = useCallback(() => {
|
|
107
|
+
if (node?.type !== 'guide') {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
guideEmitter.emit('guide:set-reference-scale', { guideId: node.id })
|
|
112
|
+
}, [node])
|
|
113
|
+
|
|
114
|
+
const handleCancelScale = useCallback(() => {
|
|
115
|
+
guideEmitter.emit('guide:cancel-reference-scale')
|
|
116
|
+
}, [])
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (node?.type !== 'guide' || !node.url.startsWith('asset://')) {
|
|
120
|
+
setIsAssetMissing(false)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let cancelled = false
|
|
125
|
+
loadAssetUrl(node.url).then((resolvedUrl) => {
|
|
126
|
+
if (!cancelled) {
|
|
127
|
+
setIsAssetMissing(!resolvedUrl)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return () => {
|
|
132
|
+
cancelled = true
|
|
133
|
+
}
|
|
134
|
+
}, [node])
|
|
135
|
+
|
|
37
136
|
if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null
|
|
38
137
|
|
|
39
138
|
const isScan = node.type === 'scan'
|
|
139
|
+
const guideLocked = !isScan && guideUi?.locked === true
|
|
140
|
+
const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false
|
|
141
|
+
const scaleStatus = !isScan ? getScaleStatus(node, scaleReferenceVisible) : null
|
|
40
142
|
|
|
41
143
|
return (
|
|
42
144
|
<PanelWrapper
|
|
43
|
-
icon={isScan ? undefined : undefined}
|
|
44
145
|
onClose={handleClose}
|
|
45
146
|
title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
|
|
46
147
|
width={300}
|
|
47
148
|
>
|
|
149
|
+
{!isScan && (
|
|
150
|
+
<>
|
|
151
|
+
<PanelSection title="Image">
|
|
152
|
+
<input
|
|
153
|
+
accept="image/*"
|
|
154
|
+
className="hidden"
|
|
155
|
+
onChange={(event) => {
|
|
156
|
+
const file = event.currentTarget.files?.[0]
|
|
157
|
+
event.currentTarget.value = ''
|
|
158
|
+
if (file) {
|
|
159
|
+
void handleReplaceFile(file)
|
|
160
|
+
}
|
|
161
|
+
}}
|
|
162
|
+
ref={replaceInputRef}
|
|
163
|
+
type="file"
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
<ActionGroup>
|
|
167
|
+
<ActionButton
|
|
168
|
+
icon={<Upload className="h-3.5 w-3.5" />}
|
|
169
|
+
label={isReplacing ? 'Replacing...' : 'Replace'}
|
|
170
|
+
onClick={() => replaceInputRef.current?.click()}
|
|
171
|
+
disabled={isReplacing}
|
|
172
|
+
/>
|
|
173
|
+
<ActionButton
|
|
174
|
+
icon={<Trash2 className="h-3.5 w-3.5" />}
|
|
175
|
+
label="Delete"
|
|
176
|
+
onClick={handleDeleteGuide}
|
|
177
|
+
className="text-destructive hover:bg-destructive/10"
|
|
178
|
+
/>
|
|
179
|
+
</ActionGroup>
|
|
180
|
+
|
|
181
|
+
<ActionGroup>
|
|
182
|
+
<ActionButton
|
|
183
|
+
icon={
|
|
184
|
+
node.visible === false ? (
|
|
185
|
+
<EyeOff className="h-3.5 w-3.5" />
|
|
186
|
+
) : (
|
|
187
|
+
<Eye className="h-3.5 w-3.5" />
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
label={node.visible === false ? 'Show' : 'Hide'}
|
|
191
|
+
onClick={() => handleUpdate({ visible: node.visible === false })}
|
|
192
|
+
/>
|
|
193
|
+
<ActionButton
|
|
194
|
+
icon={
|
|
195
|
+
guideLocked ? (
|
|
196
|
+
<Lock className="h-3.5 w-3.5" />
|
|
197
|
+
) : (
|
|
198
|
+
<Unlock className="h-3.5 w-3.5" />
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
label={guideLocked ? 'Unlock' : 'Lock'}
|
|
202
|
+
onClick={() => setGuideLocked(node.id, !guideLocked)}
|
|
203
|
+
/>
|
|
204
|
+
</ActionGroup>
|
|
205
|
+
|
|
206
|
+
{replaceError && (
|
|
207
|
+
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-destructive text-xs">
|
|
208
|
+
{replaceError}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{isAssetMissing && (
|
|
213
|
+
<div className="rounded-md border border-amber-500/35 bg-amber-500/10 px-2 py-1.5 text-amber-700 text-xs dark:text-amber-300">
|
|
214
|
+
Overlay image unavailable. Replace the image to restore it.
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</PanelSection>
|
|
218
|
+
|
|
219
|
+
<PanelSection title="Reference Scale">
|
|
220
|
+
<div className="flex items-center gap-2 rounded-md border border-border/50 bg-background/40 px-2.5 py-2 text-sm">
|
|
221
|
+
<Ruler className="h-4 w-4 shrink-0 text-primary" />
|
|
222
|
+
<span className="truncate text-muted-foreground">{scaleStatus}</span>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<ActionGroup>
|
|
226
|
+
<ActionButton
|
|
227
|
+
label={node.scaleReference ? 'Edit Scale' : 'Set Scale'}
|
|
228
|
+
onClick={handleStartScale}
|
|
229
|
+
/>
|
|
230
|
+
<ActionButton label="Cancel" onClick={handleCancelScale} />
|
|
231
|
+
</ActionGroup>
|
|
232
|
+
|
|
233
|
+
<ActionGroup>
|
|
234
|
+
<ActionButton
|
|
235
|
+
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
|
|
236
|
+
disabled={!node.scaleReference}
|
|
237
|
+
onClick={() => {
|
|
238
|
+
if (!node.scaleReference) return
|
|
239
|
+
setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
|
|
240
|
+
}}
|
|
241
|
+
/>
|
|
242
|
+
<ActionButton
|
|
243
|
+
label="Clear Scale"
|
|
244
|
+
disabled={!node.scaleReference}
|
|
245
|
+
onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
|
|
246
|
+
/>
|
|
247
|
+
</ActionGroup>
|
|
248
|
+
</PanelSection>
|
|
249
|
+
|
|
250
|
+
<PanelSection title="Quick Actions">
|
|
251
|
+
<ActionGroup>
|
|
252
|
+
<ActionButton
|
|
253
|
+
icon={<LocateFixed className="h-3.5 w-3.5" />}
|
|
254
|
+
label="Center"
|
|
255
|
+
onClick={() =>
|
|
256
|
+
handleUpdate({
|
|
257
|
+
position: [0, node.position[1], 0],
|
|
258
|
+
} as Partial<GuideNode>)
|
|
259
|
+
}
|
|
260
|
+
/>
|
|
261
|
+
<ActionButton
|
|
262
|
+
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
|
263
|
+
label="Reset Rotation"
|
|
264
|
+
onClick={() =>
|
|
265
|
+
handleUpdate({
|
|
266
|
+
rotation: [node.rotation[0], 0, node.rotation[2]],
|
|
267
|
+
} as Partial<GuideNode>)
|
|
268
|
+
}
|
|
269
|
+
/>
|
|
270
|
+
</ActionGroup>
|
|
271
|
+
<ActionGroup>
|
|
272
|
+
<ActionButton
|
|
273
|
+
icon={<Ruler className="h-3.5 w-3.5" />}
|
|
274
|
+
label="Reset Image Scale"
|
|
275
|
+
onClick={() => handleUpdate({ scale: 1 } as Partial<GuideNode>)}
|
|
276
|
+
/>
|
|
277
|
+
</ActionGroup>
|
|
278
|
+
</PanelSection>
|
|
279
|
+
</>
|
|
280
|
+
)}
|
|
281
|
+
|
|
48
282
|
<PanelSection title="Position">
|
|
49
283
|
<SliderControl
|
|
50
284
|
label={
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import {
|
|
4
4
|
type AnyNode,
|
|
5
5
|
type AnyNodeId,
|
|
6
|
-
type MaterialSchema,
|
|
7
6
|
type RoofNode,
|
|
7
|
+
type RoofSurfaceMaterialRole,
|
|
8
8
|
RoofNode as RoofNodeSchema,
|
|
9
9
|
type RoofSegmentNode,
|
|
10
10
|
RoofSegmentNode as RoofSegmentNodeSchema,
|
|
@@ -13,25 +13,34 @@ import {
|
|
|
13
13
|
import { useViewer } from '@pascal-app/viewer'
|
|
14
14
|
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
|
15
15
|
import { useCallback } from 'react'
|
|
16
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
16
17
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
18
|
+
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
|
|
17
19
|
import useEditor from '../../../store/use-editor'
|
|
18
20
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
19
|
-
import { MaterialPicker } from '../controls/material-picker'
|
|
20
|
-
import { MetricControl } from '../controls/metric-control'
|
|
21
21
|
import { PanelSection } from '../controls/panel-section'
|
|
22
22
|
import { SliderControl } from '../controls/slider-control'
|
|
23
23
|
import { PanelWrapper } from './panel-wrapper'
|
|
24
24
|
|
|
25
25
|
export function RoofPanel() {
|
|
26
|
-
const
|
|
26
|
+
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
27
27
|
const setSelection = useViewer((s) => s.setSelection)
|
|
28
|
-
const nodes = useScene((s) => s.nodes)
|
|
29
28
|
const updateNode = useScene((s) => s.updateNode)
|
|
30
29
|
const createNode = useScene((s) => s.createNode)
|
|
31
30
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
32
31
|
|
|
33
|
-
const
|
|
34
|
-
|
|
32
|
+
const node = useScene((s) =>
|
|
33
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
|
|
34
|
+
)
|
|
35
|
+
// Shallow selector — only re-renders when the segment list content changes.
|
|
36
|
+
const segments = useScene(
|
|
37
|
+
useShallow((s) => {
|
|
38
|
+
if (!node) return []
|
|
39
|
+
return (node.children ?? [])
|
|
40
|
+
.map((childId) => s.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
|
|
41
|
+
.filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
35
44
|
|
|
36
45
|
const handleUpdate = useCallback(
|
|
37
46
|
(updates: Partial<RoofNode>) => {
|
|
@@ -41,13 +50,6 @@ export function RoofPanel() {
|
|
|
41
50
|
[selectedId, updateNode],
|
|
42
51
|
)
|
|
43
52
|
|
|
44
|
-
const handleMaterialChange = useCallback(
|
|
45
|
-
(material: MaterialSchema) => {
|
|
46
|
-
handleUpdate({ material })
|
|
47
|
-
},
|
|
48
|
-
[handleUpdate],
|
|
49
|
-
)
|
|
50
|
-
|
|
51
53
|
const handleClose = useCallback(() => {
|
|
52
54
|
setSelection({ selectedIds: [] })
|
|
53
55
|
}, [setSelection])
|
|
@@ -73,44 +75,15 @@ export function RoofPanel() {
|
|
|
73
75
|
)
|
|
74
76
|
|
|
75
77
|
const handleDuplicate = useCallback(() => {
|
|
76
|
-
if (!node
|
|
78
|
+
if (!node) return
|
|
77
79
|
sfxEmitter.emit('sfx:item-pick')
|
|
78
80
|
|
|
79
|
-
let duplicateInfo = structuredClone(node) as any
|
|
80
|
-
delete duplicateInfo.id
|
|
81
|
-
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
|
82
|
-
// Offset slightly so it's visible
|
|
83
|
-
duplicateInfo.position = [
|
|
84
|
-
duplicateInfo.position[0] + 1,
|
|
85
|
-
duplicateInfo.position[1],
|
|
86
|
-
duplicateInfo.position[2] + 1,
|
|
87
|
-
]
|
|
88
|
-
|
|
89
81
|
try {
|
|
90
|
-
|
|
91
|
-
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
92
|
-
|
|
93
|
-
// Also duplicate all child segments
|
|
94
|
-
const nodesState = useScene.getState().nodes
|
|
95
|
-
const children = node.children || []
|
|
96
|
-
|
|
97
|
-
for (const childId of children) {
|
|
98
|
-
const childNode = nodesState[childId]
|
|
99
|
-
if (childNode && childNode.type === 'roof-segment') {
|
|
100
|
-
let childDuplicateInfo = structuredClone(childNode) as any
|
|
101
|
-
delete childDuplicateInfo.id
|
|
102
|
-
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
|
|
103
|
-
const childDuplicate = RoofSegmentNodeSchema.parse(childDuplicateInfo)
|
|
104
|
-
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
setSelection({ selectedIds: [] })
|
|
109
|
-
setMovingNode(duplicate)
|
|
82
|
+
duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
|
|
110
83
|
} catch (e) {
|
|
111
84
|
console.error('Failed to duplicate roof', e)
|
|
112
85
|
}
|
|
113
|
-
}, [node
|
|
86
|
+
}, [node])
|
|
114
87
|
|
|
115
88
|
const handleMove = useCallback(() => {
|
|
116
89
|
if (node) {
|
|
@@ -131,11 +104,7 @@ export function RoofPanel() {
|
|
|
131
104
|
setSelection({ selectedIds: [] })
|
|
132
105
|
}, [selectedId, node, setSelection])
|
|
133
106
|
|
|
134
|
-
if (!node
|
|
135
|
-
|
|
136
|
-
const segments = (node.children ?? [])
|
|
137
|
-
.map((childId) => nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
|
|
138
|
-
.filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
|
|
107
|
+
if (!(node && node.type === 'roof' && selectedId)) return null
|
|
139
108
|
|
|
140
109
|
return (
|
|
141
110
|
<PanelWrapper
|
|
@@ -158,15 +127,17 @@ export function RoofPanel() {
|
|
|
158
127
|
</button>
|
|
159
128
|
))}
|
|
160
129
|
</div>
|
|
161
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
130
|
+
<ActionGroup>
|
|
131
|
+
<ActionButton
|
|
132
|
+
icon={<Plus className="h-3.5 w-3.5" />}
|
|
133
|
+
label="Add Segment"
|
|
134
|
+
onClick={handleAddSegment}
|
|
135
|
+
/>
|
|
136
|
+
</ActionGroup>
|
|
166
137
|
</PanelSection>
|
|
167
138
|
|
|
168
139
|
<PanelSection title="Position">
|
|
169
|
-
<
|
|
140
|
+
<SliderControl
|
|
170
141
|
label="X"
|
|
171
142
|
max={50}
|
|
172
143
|
min={-50}
|
|
@@ -180,7 +151,7 @@ export function RoofPanel() {
|
|
|
180
151
|
unit="m"
|
|
181
152
|
value={Math.round(node.position[0] * 100) / 100}
|
|
182
153
|
/>
|
|
183
|
-
<
|
|
154
|
+
<SliderControl
|
|
184
155
|
label="Y"
|
|
185
156
|
max={50}
|
|
186
157
|
min={-50}
|
|
@@ -194,7 +165,7 @@ export function RoofPanel() {
|
|
|
194
165
|
unit="m"
|
|
195
166
|
value={Math.round(node.position[1] * 100) / 100}
|
|
196
167
|
/>
|
|
197
|
-
<
|
|
168
|
+
<SliderControl
|
|
198
169
|
label="Z"
|
|
199
170
|
max={50}
|
|
200
171
|
min={-50}
|
|
@@ -254,9 +225,6 @@ export function RoofPanel() {
|
|
|
254
225
|
/>
|
|
255
226
|
</ActionGroup>
|
|
256
227
|
</PanelSection>
|
|
257
|
-
<PanelSection title="Material">
|
|
258
|
-
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
|
259
|
-
</PanelSection>
|
|
260
228
|
</PanelWrapper>
|
|
261
229
|
)
|
|
262
230
|
}
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import {
|
|
4
4
|
type AnyNode,
|
|
5
5
|
type AnyNodeId,
|
|
6
|
-
type MaterialSchema,
|
|
7
6
|
type RoofSegmentNode,
|
|
8
7
|
RoofSegmentNode as RoofSegmentNodeSchema,
|
|
9
8
|
type RoofType,
|
|
@@ -15,8 +14,6 @@ import { useCallback } from 'react'
|
|
|
15
14
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
16
15
|
import useEditor from '../../../store/use-editor'
|
|
17
16
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
18
|
-
import { MaterialPicker } from '../controls/material-picker'
|
|
19
|
-
import { MetricControl } from '../controls/metric-control'
|
|
20
17
|
import { PanelSection } from '../controls/panel-section'
|
|
21
18
|
import { SegmentedControl } from '../controls/segmented-control'
|
|
22
19
|
import { SliderControl } from '../controls/slider-control'
|
|
@@ -36,16 +33,14 @@ const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [
|
|
|
36
33
|
]
|
|
37
34
|
|
|
38
35
|
export function RoofSegmentPanel() {
|
|
39
|
-
const
|
|
36
|
+
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
40
37
|
const setSelection = useViewer((s) => s.setSelection)
|
|
41
|
-
const nodes = useScene((s) => s.nodes)
|
|
42
38
|
const updateNode = useScene((s) => s.updateNode)
|
|
43
39
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
44
40
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
: undefined
|
|
41
|
+
const node = useScene((s) =>
|
|
42
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) : undefined,
|
|
43
|
+
)
|
|
49
44
|
|
|
50
45
|
const handleUpdate = useCallback(
|
|
51
46
|
(updates: Partial<RoofSegmentNode>) => {
|
|
@@ -55,13 +50,6 @@ export function RoofSegmentPanel() {
|
|
|
55
50
|
[selectedId, updateNode],
|
|
56
51
|
)
|
|
57
52
|
|
|
58
|
-
const handleMaterialChange = useCallback(
|
|
59
|
-
(material: MaterialSchema) => {
|
|
60
|
-
handleUpdate({ material })
|
|
61
|
-
},
|
|
62
|
-
[handleUpdate],
|
|
63
|
-
)
|
|
64
|
-
|
|
65
53
|
const handleClose = useCallback(() => {
|
|
66
54
|
setSelection({ selectedIds: [] })
|
|
67
55
|
}, [setSelection])
|
|
@@ -117,7 +105,7 @@ export function RoofSegmentPanel() {
|
|
|
117
105
|
}
|
|
118
106
|
}, [selectedId, node, setSelection])
|
|
119
107
|
|
|
120
|
-
if (!node
|
|
108
|
+
if (!(node && node.type === 'roof-segment' && selectedId)) return null
|
|
121
109
|
|
|
122
110
|
return (
|
|
123
111
|
<PanelWrapper
|
|
@@ -230,7 +218,7 @@ export function RoofSegmentPanel() {
|
|
|
230
218
|
</PanelSection>
|
|
231
219
|
|
|
232
220
|
<PanelSection title="Position">
|
|
233
|
-
<
|
|
221
|
+
<SliderControl
|
|
234
222
|
label="X"
|
|
235
223
|
max={50}
|
|
236
224
|
min={-50}
|
|
@@ -244,7 +232,7 @@ export function RoofSegmentPanel() {
|
|
|
244
232
|
unit="m"
|
|
245
233
|
value={Math.round(node.position[0] * 100) / 100}
|
|
246
234
|
/>
|
|
247
|
-
<
|
|
235
|
+
<SliderControl
|
|
248
236
|
label="Y"
|
|
249
237
|
max={50}
|
|
250
238
|
min={-50}
|
|
@@ -258,7 +246,7 @@ export function RoofSegmentPanel() {
|
|
|
258
246
|
unit="m"
|
|
259
247
|
value={Math.round(node.position[1] * 100) / 100}
|
|
260
248
|
/>
|
|
261
|
-
<
|
|
249
|
+
<SliderControl
|
|
262
250
|
label="Z"
|
|
263
251
|
max={50}
|
|
264
252
|
min={-50}
|
|
@@ -318,9 +306,6 @@ export function RoofSegmentPanel() {
|
|
|
318
306
|
/>
|
|
319
307
|
</ActionGroup>
|
|
320
308
|
</PanelSection>
|
|
321
|
-
<PanelSection title="Material">
|
|
322
|
-
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
|
323
|
-
</PanelSection>
|
|
324
309
|
</PanelWrapper>
|
|
325
310
|
)
|
|
326
311
|
}
|