@pascal-app/editor 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import NextImage from 'next/image'
|
|
4
|
+
import { useContextualTools } from '../../../hooks/use-contextual-tools'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../../../lib/utils'
|
|
7
|
+
import useEditor, {
|
|
8
|
+
type CatalogCategory,
|
|
9
|
+
type StructureTool,
|
|
10
|
+
type Tool,
|
|
11
|
+
} from '../../../store/use-editor'
|
|
12
|
+
import { ActionButton } from './action-button'
|
|
13
|
+
|
|
14
|
+
export type ToolConfig = {
|
|
15
|
+
id: StructureTool
|
|
16
|
+
iconSrc: string
|
|
17
|
+
label: string
|
|
18
|
+
catalogCategory?: CatalogCategory
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const tools: ToolConfig[] = [
|
|
22
|
+
{ id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
|
|
23
|
+
// { id: 'room', iconSrc: '/icons/room.png', label: 'Room' },
|
|
24
|
+
// { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
|
|
25
|
+
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
|
26
|
+
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
|
27
|
+
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
|
|
28
|
+
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
|
29
|
+
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
|
30
|
+
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
|
|
31
|
+
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export function StructureTools() {
|
|
35
|
+
const activeTool = useEditor((state) => state.tool)
|
|
36
|
+
const catalogCategory = useEditor((state) => state.catalogCategory)
|
|
37
|
+
const structureLayer = useEditor((state) => state.structureLayer)
|
|
38
|
+
const setTool = useEditor((state) => state.setTool)
|
|
39
|
+
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
|
|
40
|
+
|
|
41
|
+
const contextualTools = useContextualTools()
|
|
42
|
+
|
|
43
|
+
// Filter tools based on structureLayer
|
|
44
|
+
const visibleTools =
|
|
45
|
+
structureLayer === 'zones'
|
|
46
|
+
? tools.filter((t) => t.id === 'zone')
|
|
47
|
+
: tools.filter((t) => t.id !== 'zone')
|
|
48
|
+
|
|
49
|
+
const hasActiveTool = visibleTools.some(
|
|
50
|
+
(t) =>
|
|
51
|
+
activeTool === t.id && (t.catalogCategory ? catalogCategory === t.catalogCategory : true),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex items-center gap-1.5 px-1">
|
|
56
|
+
{visibleTools.map((tool, index) => {
|
|
57
|
+
// For item tools with catalog category, check both tool and category match
|
|
58
|
+
const isActive =
|
|
59
|
+
activeTool === tool.id &&
|
|
60
|
+
(tool.catalogCategory ? catalogCategory === tool.catalogCategory : true)
|
|
61
|
+
|
|
62
|
+
const isContextual = contextualTools.includes(tool.id)
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<ActionButton
|
|
66
|
+
className={cn(
|
|
67
|
+
'rounded-lg duration-300',
|
|
68
|
+
isActive
|
|
69
|
+
? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
|
|
70
|
+
: 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
|
|
71
|
+
)}
|
|
72
|
+
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
|
73
|
+
label={tool.label}
|
|
74
|
+
onClick={() => {
|
|
75
|
+
if (!isActive) {
|
|
76
|
+
setTool(tool.id)
|
|
77
|
+
setCatalogCategory(tool.catalogCategory ?? null)
|
|
78
|
+
|
|
79
|
+
// Automatically switch to build mode if we select a tool
|
|
80
|
+
if (useEditor.getState().mode !== 'build') {
|
|
81
|
+
useEditor.getState().setMode('build')
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}}
|
|
85
|
+
size="icon"
|
|
86
|
+
variant="ghost"
|
|
87
|
+
>
|
|
88
|
+
<NextImage
|
|
89
|
+
alt={tool.label}
|
|
90
|
+
className="size-full object-contain"
|
|
91
|
+
height={28}
|
|
92
|
+
src={tool.iconSrc}
|
|
93
|
+
width={28}
|
|
94
|
+
/>
|
|
95
|
+
</ActionButton>
|
|
96
|
+
)
|
|
97
|
+
})}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
type GuideNode,
|
|
6
|
+
type LevelNode,
|
|
7
|
+
type ScanNode,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
|
|
12
|
+
import { useCallback, useRef, useState } from 'react'
|
|
13
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
14
|
+
import { cn } from '../../../lib/utils'
|
|
15
|
+
import { useUploadStore } from '../../../store/use-upload'
|
|
16
|
+
import { SliderControl } from '../controls/slider-control'
|
|
17
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
|
18
|
+
import { ActionButton } from './action-button'
|
|
19
|
+
|
|
20
|
+
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
|
21
|
+
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
|
|
22
|
+
|
|
23
|
+
// ── Helper: get guide images for the current level ──────────────────────────
|
|
24
|
+
|
|
25
|
+
function useLevelGuides(): GuideNode[] {
|
|
26
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
27
|
+
return useScene(
|
|
28
|
+
useShallow((state) => {
|
|
29
|
+
if (!levelId) return [] as GuideNode[]
|
|
30
|
+
const level = state.nodes[levelId]
|
|
31
|
+
if (!level || level.type !== 'level') return [] as GuideNode[]
|
|
32
|
+
return (level as LevelNode).children
|
|
33
|
+
.map((id) => state.nodes[id])
|
|
34
|
+
.filter((node): node is GuideNode => node?.type === 'guide')
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Helper: get scans for the current level ─────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function useLevelScans(): ScanNode[] {
|
|
42
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
43
|
+
return useScene(
|
|
44
|
+
useShallow((state) => {
|
|
45
|
+
if (!levelId) return [] as ScanNode[]
|
|
46
|
+
const level = state.nodes[levelId]
|
|
47
|
+
if (!level || level.type !== 'level') return [] as ScanNode[]
|
|
48
|
+
return (level as LevelNode).children
|
|
49
|
+
.map((id) => state.nodes[id])
|
|
50
|
+
.filter((node): node is ScanNode => node?.type === 'scan')
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Shared upload button for dropdowns ──────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function UploadButton() {
|
|
58
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
59
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
60
|
+
|
|
61
|
+
const handleFileChange = useCallback(
|
|
62
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
63
|
+
const file = e.target.files?.[0]
|
|
64
|
+
if (!(file && levelId)) return
|
|
65
|
+
e.target.value = ''
|
|
66
|
+
|
|
67
|
+
const { uploadHandler } = useUploadStore.getState()
|
|
68
|
+
if (!uploadHandler) return
|
|
69
|
+
|
|
70
|
+
if (file.size > MAX_FILE_SIZE) return
|
|
71
|
+
|
|
72
|
+
const isScan =
|
|
73
|
+
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
|
|
74
|
+
const isImage = file.type.startsWith('image/')
|
|
75
|
+
if (!(isScan || isImage)) return
|
|
76
|
+
|
|
77
|
+
const type = isScan ? 'scan' : 'guide'
|
|
78
|
+
|
|
79
|
+
const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0]
|
|
80
|
+
if (!projectId) return
|
|
81
|
+
|
|
82
|
+
useUploadStore.getState().clearUpload(levelId)
|
|
83
|
+
uploadHandler(projectId, levelId, file, type)
|
|
84
|
+
},
|
|
85
|
+
[levelId],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
<button
|
|
91
|
+
aria-label="Upload scan or guide image"
|
|
92
|
+
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"
|
|
93
|
+
onClick={() => fileInputRef.current?.click()}
|
|
94
|
+
type="button"
|
|
95
|
+
>
|
|
96
|
+
<Plus className="h-3 w-3" />
|
|
97
|
+
</button>
|
|
98
|
+
<input
|
|
99
|
+
accept={ACCEPTED_FILE_TYPES}
|
|
100
|
+
className="hidden"
|
|
101
|
+
onChange={handleFileChange}
|
|
102
|
+
ref={fileInputRef}
|
|
103
|
+
type="file"
|
|
104
|
+
/>
|
|
105
|
+
</>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Guides toggle + dropdown ────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function GuidesControl() {
|
|
112
|
+
const showGuides = useViewer((state) => state.showGuides)
|
|
113
|
+
const setShowGuides = useViewer((state) => state.setShowGuides)
|
|
114
|
+
const updateNode = useScene((state) => state.updateNode)
|
|
115
|
+
const deleteNode = useScene((state) => state.deleteNode)
|
|
116
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
117
|
+
|
|
118
|
+
const guides = useLevelGuides()
|
|
119
|
+
const hasGuides = guides.length > 0
|
|
120
|
+
|
|
121
|
+
const handleOpacityChange = useCallback(
|
|
122
|
+
(guideId: GuideNode['id'], opacity: number) => {
|
|
123
|
+
updateNode(guideId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) })
|
|
124
|
+
},
|
|
125
|
+
[updateNode],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
|
130
|
+
<div className="flex items-center">
|
|
131
|
+
{/* Toggle button */}
|
|
132
|
+
<ActionButton
|
|
133
|
+
className={cn(
|
|
134
|
+
'rounded-r-none p-0',
|
|
135
|
+
showGuides
|
|
136
|
+
? 'bg-white/15'
|
|
137
|
+
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
|
138
|
+
)}
|
|
139
|
+
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
|
140
|
+
onClick={() => setShowGuides(!showGuides)}
|
|
141
|
+
size="icon"
|
|
142
|
+
variant="ghost"
|
|
143
|
+
>
|
|
144
|
+
<div className="relative">
|
|
145
|
+
<img
|
|
146
|
+
alt="Guides"
|
|
147
|
+
className="h-[28px] w-[28px] object-contain"
|
|
148
|
+
src="/icons/floorplan.png"
|
|
149
|
+
/>
|
|
150
|
+
<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]">
|
|
151
|
+
{guides.length}
|
|
152
|
+
</span>
|
|
153
|
+
</div>
|
|
154
|
+
</ActionButton>
|
|
155
|
+
|
|
156
|
+
{/* Dropdown chevron */}
|
|
157
|
+
<PopoverTrigger asChild>
|
|
158
|
+
<button
|
|
159
|
+
aria-expanded={isOpen}
|
|
160
|
+
aria-label="Guide image settings"
|
|
161
|
+
className={cn(
|
|
162
|
+
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
|
163
|
+
showGuides
|
|
164
|
+
? isOpen
|
|
165
|
+
? 'bg-white/10'
|
|
166
|
+
: 'bg-white/5 hover:bg-white/8'
|
|
167
|
+
: isOpen
|
|
168
|
+
? 'bg-white/8'
|
|
169
|
+
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
|
170
|
+
)}
|
|
171
|
+
type="button"
|
|
172
|
+
>
|
|
173
|
+
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
|
|
174
|
+
</button>
|
|
175
|
+
</PopoverTrigger>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<PopoverContent
|
|
179
|
+
align="center"
|
|
180
|
+
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"
|
|
181
|
+
side="top"
|
|
182
|
+
sideOffset={14}
|
|
183
|
+
>
|
|
184
|
+
<div className="space-y-3">
|
|
185
|
+
<div className="flex items-center gap-2">
|
|
186
|
+
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
|
187
|
+
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.png" />
|
|
188
|
+
</span>
|
|
189
|
+
<div className="min-w-0 flex-1">
|
|
190
|
+
<p className="font-medium text-foreground text-sm">Guide images</p>
|
|
191
|
+
{hasGuides && (
|
|
192
|
+
<p className="text-muted-foreground text-xs">
|
|
193
|
+
{guides.length} guide image{guides.length !== 1 ? 's' : ''} on this level
|
|
194
|
+
</p>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
<UploadButton />
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{hasGuides ? (
|
|
201
|
+
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
|
|
202
|
+
{guides.map((guide, index) => (
|
|
203
|
+
<div
|
|
204
|
+
className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
|
|
205
|
+
key={guide.id}
|
|
206
|
+
>
|
|
207
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
208
|
+
<img
|
|
209
|
+
alt=""
|
|
210
|
+
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
|
|
211
|
+
src="/icons/floorplan.png"
|
|
212
|
+
/>
|
|
213
|
+
<p className="truncate font-medium text-foreground text-sm">
|
|
214
|
+
{guide.name || `Guide image ${index + 1}`}
|
|
215
|
+
</p>
|
|
216
|
+
<button
|
|
217
|
+
aria-label="Delete guide image"
|
|
218
|
+
className="ml-auto 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"
|
|
219
|
+
onClick={() => deleteNode(guide.id)}
|
|
220
|
+
type="button"
|
|
221
|
+
>
|
|
222
|
+
<Trash2 className="h-3 w-3" />
|
|
223
|
+
</button>
|
|
224
|
+
</div>
|
|
225
|
+
<SliderControl
|
|
226
|
+
label="Opacity"
|
|
227
|
+
max={100}
|
|
228
|
+
min={0}
|
|
229
|
+
onChange={(value) => handleOpacityChange(guide.id, value)}
|
|
230
|
+
precision={0}
|
|
231
|
+
step={1}
|
|
232
|
+
unit="%"
|
|
233
|
+
value={guide.opacity}
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
) : (
|
|
239
|
+
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
|
240
|
+
No guide images on this level yet.
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
</PopoverContent>
|
|
245
|
+
</Popover>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Scans toggle + dropdown ─────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
function ScansControl() {
|
|
252
|
+
const showScans = useViewer((state) => state.showScans)
|
|
253
|
+
const setShowScans = useViewer((state) => state.setShowScans)
|
|
254
|
+
const updateNode = useScene((state) => state.updateNode)
|
|
255
|
+
const deleteNode = useScene((state) => state.deleteNode)
|
|
256
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
257
|
+
|
|
258
|
+
const scans = useLevelScans()
|
|
259
|
+
const hasScans = scans.length > 0
|
|
260
|
+
|
|
261
|
+
const handleOpacityChange = useCallback(
|
|
262
|
+
(scanId: ScanNode['id'], opacity: number) => {
|
|
263
|
+
updateNode(scanId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) })
|
|
264
|
+
},
|
|
265
|
+
[updateNode],
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
|
270
|
+
<div className="flex items-center">
|
|
271
|
+
{/* Toggle button */}
|
|
272
|
+
<ActionButton
|
|
273
|
+
className={cn(
|
|
274
|
+
'rounded-r-none p-0',
|
|
275
|
+
showScans
|
|
276
|
+
? 'bg-white/15'
|
|
277
|
+
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
|
278
|
+
)}
|
|
279
|
+
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
|
280
|
+
onClick={() => setShowScans(!showScans)}
|
|
281
|
+
size="icon"
|
|
282
|
+
variant="ghost"
|
|
283
|
+
>
|
|
284
|
+
<div className="relative">
|
|
285
|
+
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
|
|
286
|
+
<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]">
|
|
287
|
+
{scans.length}
|
|
288
|
+
</span>
|
|
289
|
+
</div>
|
|
290
|
+
</ActionButton>
|
|
291
|
+
|
|
292
|
+
{/* Dropdown chevron */}
|
|
293
|
+
<PopoverTrigger asChild>
|
|
294
|
+
<button
|
|
295
|
+
aria-expanded={isOpen}
|
|
296
|
+
aria-label="Scan settings"
|
|
297
|
+
className={cn(
|
|
298
|
+
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
|
299
|
+
showScans
|
|
300
|
+
? isOpen
|
|
301
|
+
? 'bg-white/10'
|
|
302
|
+
: 'bg-white/5 hover:bg-white/8'
|
|
303
|
+
: isOpen
|
|
304
|
+
? 'bg-white/8'
|
|
305
|
+
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
|
306
|
+
)}
|
|
307
|
+
type="button"
|
|
308
|
+
>
|
|
309
|
+
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
|
|
310
|
+
</button>
|
|
311
|
+
</PopoverTrigger>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<PopoverContent
|
|
315
|
+
align="center"
|
|
316
|
+
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"
|
|
317
|
+
side="top"
|
|
318
|
+
sideOffset={14}
|
|
319
|
+
>
|
|
320
|
+
<div className="space-y-3">
|
|
321
|
+
<div className="flex items-center gap-2">
|
|
322
|
+
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
|
323
|
+
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.png" />
|
|
324
|
+
</span>
|
|
325
|
+
<div className="min-w-0 flex-1">
|
|
326
|
+
<p className="font-medium text-foreground text-sm">Scans</p>
|
|
327
|
+
{hasScans && (
|
|
328
|
+
<p className="text-muted-foreground text-xs">
|
|
329
|
+
{scans.length} scan{scans.length !== 1 ? 's' : ''} on this level
|
|
330
|
+
</p>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
<UploadButton />
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
{hasScans ? (
|
|
337
|
+
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
|
|
338
|
+
{scans.map((scan, index) => (
|
|
339
|
+
<div
|
|
340
|
+
className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
|
|
341
|
+
key={scan.id}
|
|
342
|
+
>
|
|
343
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
344
|
+
<img
|
|
345
|
+
alt=""
|
|
346
|
+
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
|
|
347
|
+
src="/icons/mesh.png"
|
|
348
|
+
/>
|
|
349
|
+
<p className="truncate font-medium text-foreground text-sm">
|
|
350
|
+
{scan.name || `Scan ${index + 1}`}
|
|
351
|
+
</p>
|
|
352
|
+
<button
|
|
353
|
+
aria-label="Delete scan"
|
|
354
|
+
className="ml-auto 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"
|
|
355
|
+
onClick={() => deleteNode(scan.id)}
|
|
356
|
+
type="button"
|
|
357
|
+
>
|
|
358
|
+
<Trash2 className="h-3 w-3" />
|
|
359
|
+
</button>
|
|
360
|
+
</div>
|
|
361
|
+
<SliderControl
|
|
362
|
+
label="Opacity"
|
|
363
|
+
max={100}
|
|
364
|
+
min={0}
|
|
365
|
+
onChange={(value) => handleOpacityChange(scan.id, value)}
|
|
366
|
+
precision={0}
|
|
367
|
+
step={1}
|
|
368
|
+
unit="%"
|
|
369
|
+
value={scan.opacity}
|
|
370
|
+
/>
|
|
371
|
+
</div>
|
|
372
|
+
))}
|
|
373
|
+
</div>
|
|
374
|
+
) : (
|
|
375
|
+
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
|
376
|
+
No scans on this level yet.
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
</PopoverContent>
|
|
381
|
+
</Popover>
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Main ViewToggles ────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
export function ViewToggles() {
|
|
388
|
+
return (
|
|
389
|
+
<div className="flex items-center gap-1">
|
|
390
|
+
{/* Scans (toggle + dropdown) */}
|
|
391
|
+
<ScansControl />
|
|
392
|
+
|
|
393
|
+
{/* Guides (toggle + dropdown) */}
|
|
394
|
+
<GuidesControl />
|
|
395
|
+
</div>
|
|
396
|
+
)
|
|
397
|
+
}
|