@pascal-app/editor 0.6.0 → 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 +9 -5
- 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 +20 -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 +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9855 -3298
- package/src/components/editor/index.tsx +269 -21
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/thumbnail-generator.tsx +38 -7
- 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 +267 -36
- 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/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- 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 +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
- package/src/components/tools/fence/move-fence-tool.tsx +101 -34
- package/src/components/tools/item/move-tool.tsx +10 -1
- package/src/components/tools/item/placement-math.ts +30 -1
- package/src/components/tools/item/placement-strategies.ts +109 -31
- 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 +660 -52
- package/src/components/tools/roof/move-roof-tool.tsx +22 -15
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -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/tool-manager.tsx +18 -3
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +28 -1
- 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 +18 -1
- package/src/components/ui/controls/material-picker.tsx +152 -165
- package/src/components/ui/controls/slider-control.tsx +66 -18
- 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 +1116 -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 +1 -25
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +981 -289
- package/src/components/ui/panels/fence-panel.tsx +3 -45
- 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 +210 -1
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +238 -5
- package/src/components/ui/panels/roof-panel.tsx +4 -105
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +11 -117
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +1 -95
- package/src/components/ui/panels/window-panel.tsx +660 -139
- 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 +2 -2
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +42 -1
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-keyboard.ts +64 -7
- package/src/hooks/use-mobile.ts +12 -12
- 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/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/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +164 -8
|
@@ -2,23 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
type AnyNodeId,
|
|
5
|
+
type BuildingNode,
|
|
5
6
|
type GuideNode,
|
|
6
7
|
type LevelNode,
|
|
7
8
|
type ScanNode,
|
|
8
9
|
useScene,
|
|
9
10
|
} from '@pascal-app/core'
|
|
10
11
|
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
-
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
|
|
12
|
+
import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react'
|
|
12
13
|
import { useCallback, useRef, useState } from 'react'
|
|
13
14
|
import { useShallow } from 'zustand/react/shallow'
|
|
15
|
+
import { createLocalGuideImage } from '../../../lib/local-guide-image'
|
|
14
16
|
import { cn } from '../../../lib/utils'
|
|
17
|
+
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
|
|
15
18
|
import { useUploadStore } from '../../../store/use-upload'
|
|
16
19
|
import { SliderControl } from '../controls/slider-control'
|
|
17
20
|
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
|
21
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
|
|
18
22
|
import { ActionButton } from './action-button'
|
|
19
23
|
|
|
20
24
|
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
|
21
25
|
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
|
|
26
|
+
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
|
|
27
|
+
|
|
28
|
+
function formatGridSnapStep(step: GridSnapStep) {
|
|
29
|
+
return step.toFixed(2)
|
|
30
|
+
}
|
|
22
31
|
|
|
23
32
|
// ── Helper: get guide images for the current level ──────────────────────────
|
|
24
33
|
|
|
@@ -52,37 +61,95 @@ function useLevelScans(): ScanNode[] {
|
|
|
52
61
|
)
|
|
53
62
|
}
|
|
54
63
|
|
|
64
|
+
function useLowerReferenceLevels(): LevelNode[] {
|
|
65
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
66
|
+
return useScene(
|
|
67
|
+
useShallow((state) => {
|
|
68
|
+
if (!levelId) return [] as LevelNode[]
|
|
69
|
+
const activeLevel = state.nodes[levelId]
|
|
70
|
+
if (!activeLevel || activeLevel.type !== 'level') return [] as LevelNode[]
|
|
71
|
+
const buildingId = activeLevel.parentId as BuildingNode['id'] | undefined
|
|
72
|
+
const building = buildingId ? state.nodes[buildingId] : null
|
|
73
|
+
if (!building || building.type !== 'building') return [] as LevelNode[]
|
|
74
|
+
|
|
75
|
+
return (building.children ?? [])
|
|
76
|
+
.map((id) => state.nodes[id])
|
|
77
|
+
.filter(
|
|
78
|
+
(node): node is LevelNode =>
|
|
79
|
+
node?.type === 'level' && node.id !== levelId && node.level < activeLevel.level,
|
|
80
|
+
)
|
|
81
|
+
.sort((a, b) => b.level - a.level)
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getLevelDisplayName(level: LevelNode) {
|
|
87
|
+
return level.name || `Level ${level.level}`
|
|
88
|
+
}
|
|
89
|
+
|
|
55
90
|
// ── Shared upload button for dropdowns ──────────────────────────────────────
|
|
56
91
|
|
|
57
|
-
function UploadButton() {
|
|
92
|
+
function UploadButton({ onError }: { onError: (message: string | null) => void }) {
|
|
58
93
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
59
94
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
95
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
96
|
+
const setShowGuides = useViewer((s) => s.setShowGuides)
|
|
97
|
+
const createNode = useScene((s) => s.createNode)
|
|
98
|
+
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
|
99
|
+
const [isAddingGuide, setIsAddingGuide] = useState(false)
|
|
60
100
|
|
|
61
101
|
const handleFileChange = useCallback(
|
|
62
|
-
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
102
|
+
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
63
103
|
const file = e.target.files?.[0]
|
|
64
104
|
if (!(file && levelId)) return
|
|
65
105
|
e.target.value = ''
|
|
66
106
|
|
|
67
|
-
|
|
68
|
-
if (!uploadHandler) return
|
|
107
|
+
onError(null)
|
|
69
108
|
|
|
70
|
-
if (file.size > MAX_FILE_SIZE)
|
|
109
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
110
|
+
onError('File is too large. Maximum size is 200 MB.')
|
|
111
|
+
return
|
|
112
|
+
}
|
|
71
113
|
|
|
72
114
|
const isScan =
|
|
73
115
|
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
|
|
74
116
|
const isImage = file.type.startsWith('image/')
|
|
75
|
-
if (!(isScan || isImage))
|
|
117
|
+
if (!(isScan || isImage)) {
|
|
118
|
+
onError('Upload a .glb/.gltf scan or an image.')
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isImage) {
|
|
123
|
+
setIsAddingGuide(true)
|
|
124
|
+
try {
|
|
125
|
+
const guide = await createLocalGuideImage({ createNode, file, levelId })
|
|
126
|
+
setShowGuides(true)
|
|
127
|
+
setSelectedReferenceId(guide.id)
|
|
128
|
+
setSelection({ selectedIds: [], zoneId: null })
|
|
129
|
+
} catch {
|
|
130
|
+
onError('Could not add that guide image.')
|
|
131
|
+
} finally {
|
|
132
|
+
setIsAddingGuide(false)
|
|
133
|
+
}
|
|
134
|
+
return
|
|
135
|
+
}
|
|
76
136
|
|
|
77
|
-
const
|
|
137
|
+
const { uploadHandler } = useUploadStore.getState()
|
|
138
|
+
if (!uploadHandler) {
|
|
139
|
+
onError('Scan upload is unavailable.')
|
|
140
|
+
return
|
|
141
|
+
}
|
|
78
142
|
|
|
79
143
|
const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0]
|
|
80
|
-
if (!projectId)
|
|
144
|
+
if (!projectId) {
|
|
145
|
+
onError('Open a project before uploading a scan.')
|
|
146
|
+
return
|
|
147
|
+
}
|
|
81
148
|
|
|
82
149
|
useUploadStore.getState().clearUpload(levelId)
|
|
83
|
-
uploadHandler(projectId, levelId, file,
|
|
150
|
+
uploadHandler(projectId, levelId, file, 'scan')
|
|
84
151
|
},
|
|
85
|
-
[levelId],
|
|
152
|
+
[createNode, levelId, onError, setSelectedReferenceId, setSelection, setShowGuides],
|
|
86
153
|
)
|
|
87
154
|
|
|
88
155
|
return (
|
|
@@ -90,6 +157,7 @@ function UploadButton() {
|
|
|
90
157
|
<button
|
|
91
158
|
aria-label="Upload scan or guide image"
|
|
92
159
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
|
160
|
+
disabled={isAddingGuide}
|
|
93
161
|
onClick={() => fileInputRef.current?.click()}
|
|
94
162
|
type="button"
|
|
95
163
|
>
|
|
@@ -111,9 +179,13 @@ function UploadButton() {
|
|
|
111
179
|
function GuidesControl() {
|
|
112
180
|
const showGuides = useViewer((state) => state.showGuides)
|
|
113
181
|
const setShowGuides = useViewer((state) => state.setShowGuides)
|
|
182
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
114
183
|
const updateNode = useScene((state) => state.updateNode)
|
|
115
184
|
const deleteNode = useScene((state) => state.deleteNode)
|
|
185
|
+
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
|
|
186
|
+
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
|
|
116
187
|
const [isOpen, setIsOpen] = useState(false)
|
|
188
|
+
const [uploadError, setUploadError] = useState<string | null>(null)
|
|
117
189
|
|
|
118
190
|
const guides = useLevelGuides()
|
|
119
191
|
const hasGuides = guides.length > 0
|
|
@@ -125,6 +197,15 @@ function GuidesControl() {
|
|
|
125
197
|
[updateNode],
|
|
126
198
|
)
|
|
127
199
|
|
|
200
|
+
const handleSelectGuide = useCallback(
|
|
201
|
+
(guideId: GuideNode['id']) => {
|
|
202
|
+
setShowGuides(true)
|
|
203
|
+
setSelectedReferenceId(guideId)
|
|
204
|
+
setSelection({ selectedIds: [], zoneId: null })
|
|
205
|
+
},
|
|
206
|
+
[setSelectedReferenceId, setSelection, setShowGuides],
|
|
207
|
+
)
|
|
208
|
+
|
|
128
209
|
return (
|
|
129
210
|
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
|
130
211
|
<div className="flex items-center">
|
|
@@ -194,29 +275,55 @@ function GuidesControl() {
|
|
|
194
275
|
</p>
|
|
195
276
|
)}
|
|
196
277
|
</div>
|
|
197
|
-
<UploadButton />
|
|
278
|
+
<UploadButton onError={setUploadError} />
|
|
198
279
|
</div>
|
|
199
280
|
|
|
281
|
+
{uploadError && (
|
|
282
|
+
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
|
|
283
|
+
{uploadError}
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
|
|
200
287
|
{hasGuides ? (
|
|
201
288
|
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
|
|
202
289
|
{guides.map((guide, index) => (
|
|
203
290
|
<div
|
|
204
|
-
className=
|
|
291
|
+
className={cn(
|
|
292
|
+
'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
|
|
293
|
+
selectedReferenceId === guide.id
|
|
294
|
+
? 'border-foreground/35 bg-white/10'
|
|
295
|
+
: 'border-border/45',
|
|
296
|
+
)}
|
|
205
297
|
key={guide.id}
|
|
206
298
|
>
|
|
207
299
|
<div className="flex min-w-0 items-center gap-2">
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
300
|
+
<button
|
|
301
|
+
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
|
302
|
+
onClick={() => handleSelectGuide(guide.id)}
|
|
303
|
+
type="button"
|
|
304
|
+
>
|
|
305
|
+
<img
|
|
306
|
+
alt=""
|
|
307
|
+
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
|
|
308
|
+
src="/icons/floorplan.png"
|
|
309
|
+
/>
|
|
310
|
+
<p className="truncate font-medium text-foreground text-sm">
|
|
311
|
+
{guide.name || `Guide image ${index + 1}`}
|
|
312
|
+
</p>
|
|
313
|
+
{selectedReferenceId === guide.id && (
|
|
314
|
+
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
|
|
315
|
+
)}
|
|
316
|
+
</button>
|
|
216
317
|
<button
|
|
217
318
|
aria-label="Delete guide image"
|
|
218
|
-
className="
|
|
219
|
-
onClick={() =>
|
|
319
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
|
|
320
|
+
onClick={(event) => {
|
|
321
|
+
event.stopPropagation()
|
|
322
|
+
deleteNode(guide.id)
|
|
323
|
+
if (selectedReferenceId === guide.id) {
|
|
324
|
+
setSelectedReferenceId(null)
|
|
325
|
+
}
|
|
326
|
+
}}
|
|
220
327
|
type="button"
|
|
221
328
|
>
|
|
222
329
|
<Trash2 className="h-3 w-3" />
|
|
@@ -246,14 +353,95 @@ function GuidesControl() {
|
|
|
246
353
|
)
|
|
247
354
|
}
|
|
248
355
|
|
|
356
|
+
// ── Grid snap ──────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
export function GridSnapControl() {
|
|
359
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
360
|
+
const gridSnapStep = useEditor((state) => state.gridSnapStep)
|
|
361
|
+
const setGridSnapStep = useEditor((state) => state.setGridSnapStep)
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
|
365
|
+
<Tooltip>
|
|
366
|
+
<TooltipTrigger asChild>
|
|
367
|
+
<PopoverTrigger asChild>
|
|
368
|
+
<button
|
|
369
|
+
aria-expanded={isOpen}
|
|
370
|
+
aria-label={`Grid snap: ${formatGridSnapStep(gridSnapStep)}`}
|
|
371
|
+
className={cn(
|
|
372
|
+
'flex h-11 w-11 flex-col items-center justify-center rounded-lg text-muted-foreground transition-all hover:bg-white/5 hover:text-foreground',
|
|
373
|
+
isOpen && 'bg-white/10 text-foreground',
|
|
374
|
+
)}
|
|
375
|
+
type="button"
|
|
376
|
+
>
|
|
377
|
+
<svg
|
|
378
|
+
className="h-4 w-4"
|
|
379
|
+
fill="none"
|
|
380
|
+
stroke="currentColor"
|
|
381
|
+
strokeWidth={1.5}
|
|
382
|
+
viewBox="0 0 24 24"
|
|
383
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
384
|
+
>
|
|
385
|
+
<path
|
|
386
|
+
d="M3 3h7v7H3V3zm11 0h7v7h-7V3zm0 11h7v7h-7v-7zm-11 0h7v7H3v-7z"
|
|
387
|
+
strokeLinecap="round"
|
|
388
|
+
strokeLinejoin="round"
|
|
389
|
+
/>
|
|
390
|
+
</svg>
|
|
391
|
+
<span className="mt-1 font-medium text-[9px] leading-none">
|
|
392
|
+
{formatGridSnapStep(gridSnapStep)}
|
|
393
|
+
</span>
|
|
394
|
+
</button>
|
|
395
|
+
</PopoverTrigger>
|
|
396
|
+
</TooltipTrigger>
|
|
397
|
+
<TooltipContent side="top">Grid snap: {formatGridSnapStep(gridSnapStep)}</TooltipContent>
|
|
398
|
+
</Tooltip>
|
|
399
|
+
|
|
400
|
+
<PopoverContent
|
|
401
|
+
align="center"
|
|
402
|
+
className="w-36 rounded-xl border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
|
|
403
|
+
side="top"
|
|
404
|
+
sideOffset={14}
|
|
405
|
+
>
|
|
406
|
+
<div className="space-y-1">
|
|
407
|
+
{GRID_SNAP_STEPS.map((step) => {
|
|
408
|
+
const isActive = step === gridSnapStep
|
|
409
|
+
return (
|
|
410
|
+
<button
|
|
411
|
+
className={cn(
|
|
412
|
+
'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-white/8',
|
|
413
|
+
isActive && 'bg-white/10 text-foreground',
|
|
414
|
+
)}
|
|
415
|
+
key={step}
|
|
416
|
+
onClick={() => {
|
|
417
|
+
setGridSnapStep(step)
|
|
418
|
+
setIsOpen(false)
|
|
419
|
+
}}
|
|
420
|
+
type="button"
|
|
421
|
+
>
|
|
422
|
+
<span>{formatGridSnapStep(step)}</span>
|
|
423
|
+
{isActive ? <Check className="h-3.5 w-3.5" /> : <span className="h-3.5 w-3.5" />}
|
|
424
|
+
</button>
|
|
425
|
+
)
|
|
426
|
+
})}
|
|
427
|
+
</div>
|
|
428
|
+
</PopoverContent>
|
|
429
|
+
</Popover>
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
249
433
|
// ── Scans toggle + dropdown ─────────────────────────────────────────────────
|
|
250
434
|
|
|
251
435
|
function ScansControl() {
|
|
252
436
|
const showScans = useViewer((state) => state.showScans)
|
|
253
437
|
const setShowScans = useViewer((state) => state.setShowScans)
|
|
438
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
254
439
|
const updateNode = useScene((state) => state.updateNode)
|
|
255
440
|
const deleteNode = useScene((state) => state.deleteNode)
|
|
441
|
+
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
|
|
442
|
+
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
|
|
256
443
|
const [isOpen, setIsOpen] = useState(false)
|
|
444
|
+
const [uploadError, setUploadError] = useState<string | null>(null)
|
|
257
445
|
|
|
258
446
|
const scans = useLevelScans()
|
|
259
447
|
const hasScans = scans.length > 0
|
|
@@ -265,6 +453,15 @@ function ScansControl() {
|
|
|
265
453
|
[updateNode],
|
|
266
454
|
)
|
|
267
455
|
|
|
456
|
+
const handleSelectScan = useCallback(
|
|
457
|
+
(scanId: ScanNode['id']) => {
|
|
458
|
+
setShowScans(true)
|
|
459
|
+
setSelectedReferenceId(scanId)
|
|
460
|
+
setSelection({ selectedIds: [], zoneId: null })
|
|
461
|
+
},
|
|
462
|
+
[setSelectedReferenceId, setSelection, setShowScans],
|
|
463
|
+
)
|
|
464
|
+
|
|
268
465
|
return (
|
|
269
466
|
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
|
270
467
|
<div className="flex items-center">
|
|
@@ -330,29 +527,55 @@ function ScansControl() {
|
|
|
330
527
|
</p>
|
|
331
528
|
)}
|
|
332
529
|
</div>
|
|
333
|
-
<UploadButton />
|
|
530
|
+
<UploadButton onError={setUploadError} />
|
|
334
531
|
</div>
|
|
335
532
|
|
|
533
|
+
{uploadError && (
|
|
534
|
+
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
|
|
535
|
+
{uploadError}
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
|
|
336
539
|
{hasScans ? (
|
|
337
540
|
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
|
|
338
541
|
{scans.map((scan, index) => (
|
|
339
542
|
<div
|
|
340
|
-
className=
|
|
543
|
+
className={cn(
|
|
544
|
+
'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
|
|
545
|
+
selectedReferenceId === scan.id
|
|
546
|
+
? 'border-foreground/35 bg-white/10'
|
|
547
|
+
: 'border-border/45',
|
|
548
|
+
)}
|
|
341
549
|
key={scan.id}
|
|
342
550
|
>
|
|
343
551
|
<div className="flex min-w-0 items-center gap-2">
|
|
344
|
-
<
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
552
|
+
<button
|
|
553
|
+
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
|
554
|
+
onClick={() => handleSelectScan(scan.id)}
|
|
555
|
+
type="button"
|
|
556
|
+
>
|
|
557
|
+
<img
|
|
558
|
+
alt=""
|
|
559
|
+
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
|
|
560
|
+
src="/icons/mesh.png"
|
|
561
|
+
/>
|
|
562
|
+
<p className="truncate font-medium text-foreground text-sm">
|
|
563
|
+
{scan.name || `Scan ${index + 1}`}
|
|
564
|
+
</p>
|
|
565
|
+
{selectedReferenceId === scan.id && (
|
|
566
|
+
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
|
|
567
|
+
)}
|
|
568
|
+
</button>
|
|
352
569
|
<button
|
|
353
570
|
aria-label="Delete scan"
|
|
354
|
-
className="
|
|
355
|
-
onClick={() =>
|
|
571
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
|
|
572
|
+
onClick={(event) => {
|
|
573
|
+
event.stopPropagation()
|
|
574
|
+
deleteNode(scan.id)
|
|
575
|
+
if (selectedReferenceId === scan.id) {
|
|
576
|
+
setSelectedReferenceId(null)
|
|
577
|
+
}
|
|
578
|
+
}}
|
|
356
579
|
type="button"
|
|
357
580
|
>
|
|
358
581
|
<Trash2 className="h-3 w-3" />
|
|
@@ -382,6 +605,158 @@ function ScansControl() {
|
|
|
382
605
|
)
|
|
383
606
|
}
|
|
384
607
|
|
|
608
|
+
function ReferenceFloorControl() {
|
|
609
|
+
const showReferenceFloor = useEditor((state) => state.showReferenceFloor)
|
|
610
|
+
const toggleReferenceFloor = useEditor((state) => state.toggleReferenceFloor)
|
|
611
|
+
const referenceFloorOffset = useEditor((state) => state.referenceFloorOffset)
|
|
612
|
+
const setReferenceFloorOffset = useEditor((state) => state.setReferenceFloorOffset)
|
|
613
|
+
const referenceFloorOpacity = useEditor((state) => state.referenceFloorOpacity)
|
|
614
|
+
const setReferenceFloorOpacity = useEditor((state) => state.setReferenceFloorOpacity)
|
|
615
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
616
|
+
const lowerLevels = useLowerReferenceLevels()
|
|
617
|
+
const hasLowerLevels = lowerLevels.length > 0
|
|
618
|
+
const selectedLevel = lowerLevels[referenceFloorOffset - 1] ?? lowerLevels[0] ?? null
|
|
619
|
+
const selectedLevelName = selectedLevel ? getLevelDisplayName(selectedLevel) : null
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
|
623
|
+
<div className="flex items-center">
|
|
624
|
+
<ActionButton
|
|
625
|
+
className={cn(
|
|
626
|
+
'rounded-r-none p-0',
|
|
627
|
+
showReferenceFloor && selectedLevel
|
|
628
|
+
? 'bg-white/15'
|
|
629
|
+
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
|
630
|
+
)}
|
|
631
|
+
disabled={!hasLowerLevels}
|
|
632
|
+
label={
|
|
633
|
+
selectedLevelName && showReferenceFloor
|
|
634
|
+
? `Reference floor: ${selectedLevelName}`
|
|
635
|
+
: 'Reference floor'
|
|
636
|
+
}
|
|
637
|
+
onClick={() => {
|
|
638
|
+
if (hasLowerLevels) toggleReferenceFloor()
|
|
639
|
+
}}
|
|
640
|
+
size="icon"
|
|
641
|
+
variant="ghost"
|
|
642
|
+
>
|
|
643
|
+
<div className="relative">
|
|
644
|
+
<Layers2 className="h-4 w-4" />
|
|
645
|
+
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
|
|
646
|
+
{lowerLevels.length}
|
|
647
|
+
</span>
|
|
648
|
+
</div>
|
|
649
|
+
</ActionButton>
|
|
650
|
+
|
|
651
|
+
<PopoverTrigger asChild>
|
|
652
|
+
<button
|
|
653
|
+
aria-expanded={isOpen}
|
|
654
|
+
aria-label="Reference floor settings"
|
|
655
|
+
className={cn(
|
|
656
|
+
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
|
657
|
+
showReferenceFloor && selectedLevel
|
|
658
|
+
? isOpen
|
|
659
|
+
? 'bg-white/10'
|
|
660
|
+
: 'bg-white/5 hover:bg-white/8'
|
|
661
|
+
: isOpen
|
|
662
|
+
? 'bg-white/8'
|
|
663
|
+
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
|
664
|
+
)}
|
|
665
|
+
disabled={!hasLowerLevels}
|
|
666
|
+
type="button"
|
|
667
|
+
>
|
|
668
|
+
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
|
|
669
|
+
</button>
|
|
670
|
+
</PopoverTrigger>
|
|
671
|
+
</div>
|
|
672
|
+
|
|
673
|
+
<PopoverContent
|
|
674
|
+
align="center"
|
|
675
|
+
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
|
|
676
|
+
side="top"
|
|
677
|
+
sideOffset={14}
|
|
678
|
+
>
|
|
679
|
+
<div className="space-y-3">
|
|
680
|
+
<div className="flex items-center gap-2">
|
|
681
|
+
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
|
682
|
+
<Layers2 className="h-4 w-4" />
|
|
683
|
+
</span>
|
|
684
|
+
<div className="min-w-0 flex-1">
|
|
685
|
+
<p className="font-medium text-foreground text-sm">Reference floor</p>
|
|
686
|
+
{selectedLevelName && (
|
|
687
|
+
<p className="truncate text-muted-foreground text-xs">{selectedLevelName}</p>
|
|
688
|
+
)}
|
|
689
|
+
</div>
|
|
690
|
+
<button
|
|
691
|
+
aria-label={showReferenceFloor ? 'Hide reference floor' : 'Show reference floor'}
|
|
692
|
+
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
|
693
|
+
disabled={!hasLowerLevels}
|
|
694
|
+
onClick={toggleReferenceFloor}
|
|
695
|
+
type="button"
|
|
696
|
+
>
|
|
697
|
+
{showReferenceFloor ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
|
698
|
+
</button>
|
|
699
|
+
</div>
|
|
700
|
+
|
|
701
|
+
{hasLowerLevels ? (
|
|
702
|
+
<>
|
|
703
|
+
<div className="max-h-44 space-y-1 overflow-y-auto rounded-xl border border-border/45 bg-background/60 p-1.5">
|
|
704
|
+
{lowerLevels.map((level, index) => {
|
|
705
|
+
const isSelected = referenceFloorOffset === index + 1
|
|
706
|
+
const levelName = getLevelDisplayName(level)
|
|
707
|
+
return (
|
|
708
|
+
<button
|
|
709
|
+
className={cn(
|
|
710
|
+
'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-white/8',
|
|
711
|
+
isSelected && showReferenceFloor && 'bg-white/10 text-foreground',
|
|
712
|
+
)}
|
|
713
|
+
key={level.id}
|
|
714
|
+
onClick={() => {
|
|
715
|
+
setReferenceFloorOffset(index + 1)
|
|
716
|
+
if (!showReferenceFloor) {
|
|
717
|
+
toggleReferenceFloor()
|
|
718
|
+
}
|
|
719
|
+
}}
|
|
720
|
+
type="button"
|
|
721
|
+
>
|
|
722
|
+
<span
|
|
723
|
+
className={cn(
|
|
724
|
+
'h-3.5 w-3.5 rounded-full border',
|
|
725
|
+
isSelected && showReferenceFloor
|
|
726
|
+
? 'border-foreground bg-foreground'
|
|
727
|
+
: 'border-muted-foreground/35',
|
|
728
|
+
)}
|
|
729
|
+
/>
|
|
730
|
+
<span className="min-w-0 flex-1 truncate">{levelName}</span>
|
|
731
|
+
<span className="text-[10px] text-muted-foreground">
|
|
732
|
+
{index + 1} below
|
|
733
|
+
</span>
|
|
734
|
+
</button>
|
|
735
|
+
)
|
|
736
|
+
})}
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
<SliderControl
|
|
740
|
+
label="Opacity"
|
|
741
|
+
max={0.8}
|
|
742
|
+
min={0.1}
|
|
743
|
+
onChange={setReferenceFloorOpacity}
|
|
744
|
+
precision={2}
|
|
745
|
+
step={0.05}
|
|
746
|
+
value={referenceFloorOpacity}
|
|
747
|
+
/>
|
|
748
|
+
</>
|
|
749
|
+
) : (
|
|
750
|
+
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
|
751
|
+
No lower floor available.
|
|
752
|
+
</div>
|
|
753
|
+
)}
|
|
754
|
+
</div>
|
|
755
|
+
</PopoverContent>
|
|
756
|
+
</Popover>
|
|
757
|
+
)
|
|
758
|
+
}
|
|
759
|
+
|
|
385
760
|
// ── Main ViewToggles ────────────────────────────────────────────────────────
|
|
386
761
|
|
|
387
762
|
export function ViewToggles() {
|
|
@@ -392,6 +767,20 @@ export function ViewToggles() {
|
|
|
392
767
|
|
|
393
768
|
{/* Guides (toggle + dropdown) */}
|
|
394
769
|
<GuidesControl />
|
|
770
|
+
|
|
771
|
+
<ReferenceFloorControl />
|
|
772
|
+
</div>
|
|
773
|
+
)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Secondary toggles for mobile (grid snap + scans + guides)
|
|
777
|
+
export function SecondaryToggles() {
|
|
778
|
+
return (
|
|
779
|
+
<div className="flex items-center gap-1">
|
|
780
|
+
<GridSnapControl />
|
|
781
|
+
<ScansControl />
|
|
782
|
+
<GuidesControl />
|
|
783
|
+
<ReferenceFloorControl />
|
|
395
784
|
</div>
|
|
396
785
|
)
|
|
397
786
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
Moon,
|
|
24
24
|
MousePointer2,
|
|
25
25
|
Package,
|
|
26
|
+
PaintBucket,
|
|
26
27
|
PencilLine,
|
|
27
28
|
Plus,
|
|
28
29
|
Redo2,
|
|
@@ -49,6 +50,7 @@ export function EditorCommands() {
|
|
|
49
50
|
const setMode = useEditor((s) => s.setMode)
|
|
50
51
|
const setTool = useEditor((s) => s.setTool)
|
|
51
52
|
const setStructureLayer = useEditor((s) => s.setStructureLayer)
|
|
53
|
+
const primeMaterialPaintFromSelection = useEditor((s) => s.primeMaterialPaintFromSelection)
|
|
52
54
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
53
55
|
const setPreviewMode = useEditor((s) => s.setPreviewMode)
|
|
54
56
|
|
|
@@ -150,6 +152,21 @@ export function EditorCommands() {
|
|
|
150
152
|
useScene.getState().deleteNodes(selectedIds as any[])
|
|
151
153
|
}),
|
|
152
154
|
},
|
|
155
|
+
{
|
|
156
|
+
id: 'editor.mode.material-paint',
|
|
157
|
+
label: 'Material Paint',
|
|
158
|
+
group: 'Scene',
|
|
159
|
+
icon: <PaintBucket className="h-4 w-4" />,
|
|
160
|
+
keywords: ['paint', 'material', 'texture', 'bucket', 'surface'],
|
|
161
|
+
shortcut: ['P'],
|
|
162
|
+
execute: () =>
|
|
163
|
+
run(() => {
|
|
164
|
+
primeMaterialPaintFromSelection()
|
|
165
|
+
setPhase('structure')
|
|
166
|
+
setStructureLayer('elements')
|
|
167
|
+
setMode('material-paint')
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
153
170
|
|
|
154
171
|
// ── Levels ───────────────────────────────────────────────────────────
|
|
155
172
|
{
|
|
@@ -355,7 +372,7 @@ export function EditorCommands() {
|
|
|
355
372
|
icon: <Box className="h-4 w-4" />,
|
|
356
373
|
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
|
|
357
374
|
execute: () => run(() => exportScene()),
|
|
358
|
-
}
|
|
375
|
+
},
|
|
359
376
|
]
|
|
360
377
|
: []),
|
|
361
378
|
{
|