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