'use client' import { Icon } from '@iconify/react' import { type AnyNodeId, type BuildingNode, type GuideNode, type LevelNode, type ScanNode, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react' import { useCallback, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { getLevelDisplayName } from '../../../lib/level-name' import { createLocalGuideImage } from '../../../lib/local-guide-image' import { cn } from '../../../lib/utils' import useEditor, { type GridSnapStep } from '../../../store/use-editor' import { useUploadStore } from '../../../store/use-upload' import { SliderControl } from '../controls/slider-control' import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip' import { ActionButton } from './action-button' const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif' const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] function formatGridSnapStep(step: GridSnapStep) { return step.toFixed(2) } // ── Helper: get guide images for the current level ────────────────────────── function useLevelGuides(): GuideNode[] { const levelId = useViewer((s) => s.selection.levelId) return useScene( useShallow((state) => { if (!levelId) return [] as GuideNode[] const level = state.nodes[levelId] if (!level || level.type !== 'level') return [] as GuideNode[] return (level as LevelNode).children .map((id) => state.nodes[id]) .filter((node): node is GuideNode => node?.type === 'guide') }), ) } // ── Helper: get scans for the current level ───────────────────────────────── function useLevelScans(): ScanNode[] { const levelId = useViewer((s) => s.selection.levelId) return useScene( useShallow((state) => { if (!levelId) return [] as ScanNode[] const level = state.nodes[levelId] if (!level || level.type !== 'level') return [] as ScanNode[] return (level as LevelNode).children .map((id) => state.nodes[id]) .filter((node): node is ScanNode => node?.type === 'scan') }), ) } function useLowerReferenceLevels(): LevelNode[] { const levelId = useViewer((s) => s.selection.levelId) return useScene( useShallow((state) => { if (!levelId) return [] as LevelNode[] const activeLevel = state.nodes[levelId] if (!activeLevel || activeLevel.type !== 'level') return [] as LevelNode[] const buildingId = activeLevel.parentId as BuildingNode['id'] | undefined const building = buildingId ? state.nodes[buildingId] : null if (!building || building.type !== 'building') return [] as LevelNode[] return (building.children ?? []) .map((id) => state.nodes[id]) .filter( (node): node is LevelNode => node?.type === 'level' && node.id !== levelId && node.level < activeLevel.level, ) .sort((a, b) => b.level - a.level) }), ) } // ── Shared upload button for dropdowns ────────────────────────────────────── function UploadButton({ onError }: { onError: (message: string | null) => void }) { const fileInputRef = useRef(null) const levelId = useViewer((s) => s.selection.levelId) const setSelection = useViewer((s) => s.setSelection) const setShowGuides = useViewer((s) => s.setShowGuides) const createNode = useScene((s) => s.createNode) const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) const [isAddingGuide, setIsAddingGuide] = useState(false) const handleFileChange = useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!(file && levelId)) return e.target.value = '' onError(null) if (file.size > MAX_FILE_SIZE) { onError('File is too large. Maximum size is 200 MB.') return } const isScan = file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf') const isImage = file.type.startsWith('image/') if (!(isScan || isImage)) { onError('Upload a .glb/.gltf scan or an image.') return } if (isImage) { setIsAddingGuide(true) try { const guide = await createLocalGuideImage({ createNode, file, levelId }) setShowGuides(true) setSelectedReferenceId(guide.id) setSelection({ selectedIds: [], zoneId: null }) } catch { onError('Could not add that guide image.') } finally { setIsAddingGuide(false) } return } const { uploadHandler } = useUploadStore.getState() if (!uploadHandler) { onError('Scan upload is unavailable.') return } const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0] if (!projectId) { onError('Open a project before uploading a scan.') return } useUploadStore.getState().clearUpload(levelId) uploadHandler(projectId, levelId, file, 'scan') }, [createNode, levelId, onError, setSelectedReferenceId, setSelection, setShowGuides], ) return ( <> ) } // ── Guides toggle + dropdown ──────────────────────────────────────────────── function GuidesControl() { const showGuides = useViewer((state) => state.showGuides) const setShowGuides = useViewer((state) => state.setShowGuides) const setSelection = useViewer((state) => state.setSelection) const updateNode = useScene((state) => state.updateNode) const deleteNode = useScene((state) => state.deleteNode) const selectedReferenceId = useEditor((state) => state.selectedReferenceId) const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId) const [isOpen, setIsOpen] = useState(false) const [uploadError, setUploadError] = useState(null) const guides = useLevelGuides() const hasGuides = guides.length > 0 const handleOpacityChange = useCallback( (guideId: GuideNode['id'], opacity: number) => { updateNode(guideId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) }) }, [updateNode], ) const handleSelectGuide = useCallback( (guideId: GuideNode['id']) => { setShowGuides(true) setSelectedReferenceId(guideId) setSelection({ selectedIds: [], zoneId: null }) }, [setSelectedReferenceId, setSelection, setShowGuides], ) return (
{/* Toggle button */} setShowGuides(!showGuides)} size="icon" variant="ghost" >
Guides {guides.length}
{/* Dropdown chevron */}

Guide images

{hasGuides && (

{guides.length} guide image{guides.length !== 1 ? 's' : ''} on this level

)}
{uploadError && (
{uploadError}
)} {hasGuides ? (
{guides.map((guide, index) => (
handleOpacityChange(guide.id, value)} precision={0} step={1} unit="%" value={guide.opacity} />
))}
) : (
No guide images on this level yet.
)}
) } // ── Grid snap toggle ──────────────────────────────────────────────────────── function GridSnapControl() { const [isOpen, setIsOpen] = useState(false) const gridSnapStep = useEditor((state) => state.gridSnapStep) const setGridSnapStep = useEditor((state) => state.setGridSnapStep) return ( Grid snap: {formatGridSnapStep(gridSnapStep)}
{GRID_SNAP_STEPS.map((step) => { const isActive = step === gridSnapStep return ( ) })}
) } // ── Scans toggle + dropdown ───────────────────────────────────────────────── function ScansControl() { const showScans = useViewer((state) => state.showScans) const setShowScans = useViewer((state) => state.setShowScans) const setSelection = useViewer((state) => state.setSelection) const updateNode = useScene((state) => state.updateNode) const deleteNode = useScene((state) => state.deleteNode) const selectedReferenceId = useEditor((state) => state.selectedReferenceId) const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId) const [isOpen, setIsOpen] = useState(false) const [uploadError, setUploadError] = useState(null) const scans = useLevelScans() const hasScans = scans.length > 0 const handleOpacityChange = useCallback( (scanId: ScanNode['id'], opacity: number) => { updateNode(scanId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) }) }, [updateNode], ) const handleSelectScan = useCallback( (scanId: ScanNode['id']) => { setShowScans(true) setSelectedReferenceId(scanId) setSelection({ selectedIds: [], zoneId: null }) }, [setSelectedReferenceId, setSelection, setShowScans], ) return (
{/* Toggle button */} setShowScans(!showScans)} size="icon" variant="ghost" >
Scans {scans.length}
{/* Dropdown chevron */}

Scans

{hasScans && (

{scans.length} scan{scans.length !== 1 ? 's' : ''} on this level

)}
{uploadError && (
{uploadError}
)} {hasScans ? (
{scans.map((scan, index) => (
handleOpacityChange(scan.id, value)} precision={0} step={1} unit="%" value={scan.opacity} />
))}
) : (
No scans on this level yet.
)}
) } // ── References (merged scans + guides) ────────────────────────────────────── // Bottom-bar control that folds the separate Scans and Guides toggles into one // "References" split button + a popover holding both, each with its own // visibility toggle, upload, and per-item opacity/delete. function ReferenceListSection({ title, iconSrc, noun, emptyText, nodes, show, setShow, onError, }: { title: string iconSrc: string noun: string emptyText: string nodes: (GuideNode | ScanNode)[] show: boolean setShow: (show: boolean) => void onError: (message: string | null) => void }) { const setSelection = useViewer((state) => state.setSelection) const updateNode = useScene((state) => state.updateNode) const deleteNode = useScene((state) => state.deleteNode) const selectedReferenceId = useEditor((state) => state.selectedReferenceId) const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId) const hasItems = nodes.length > 0 const handleSelect = useCallback( (id: AnyNodeId) => { setShow(true) setSelectedReferenceId(id) setSelection({ selectedIds: [], zoneId: null }) }, [setShow, setSelectedReferenceId, setSelection], ) return (

{title}

{hasItems && (

{nodes.length} {noun} {nodes.length !== 1 ? 's' : ''} on this level

)}
{hasItems ? (
{nodes.map((node, index) => (
updateNode(node.id, { opacity: Math.round(Math.min(100, Math.max(0, value))) }) } precision={0} step={1} unit="%" value={node.opacity} />
))}
) : (
{emptyText}
)}
) } function ReferencesControl() { const showScans = useViewer((state) => state.showScans) const setShowScans = useViewer((state) => state.setShowScans) const showGuides = useViewer((state) => state.showGuides) const setShowGuides = useViewer((state) => state.setShowGuides) const [isOpen, setIsOpen] = useState(false) const [uploadError, setUploadError] = useState(null) const scans = useLevelScans() const guides = useLevelGuides() const total = scans.length + guides.length const anyVisible = showScans || showGuides const toggleAll = useCallback(() => { const next = !anyVisible setShowScans(next) setShowGuides(next) }, [anyVisible, setShowScans, setShowGuides]) return (
References {total}
{uploadError && (
{uploadError}
)}
) } // ── Reference floor control ──────────────────────────────────────────────────────────────────── function ReferenceFloorControl() { const showReferenceFloor = useEditor((state) => state.showReferenceFloor) const toggleReferenceFloor = useEditor((state) => state.toggleReferenceFloor) const referenceFloorOffset = useEditor((state) => state.referenceFloorOffset) const setReferenceFloorOffset = useEditor((state) => state.setReferenceFloorOffset) const referenceFloorOpacity = useEditor((state) => state.referenceFloorOpacity) const setReferenceFloorOpacity = useEditor((state) => state.setReferenceFloorOpacity) const [isOpen, setIsOpen] = useState(false) const lowerLevels = useLowerReferenceLevels() const hasLowerLevels = lowerLevels.length > 0 const selectedLevel = lowerLevels[referenceFloorOffset - 1] ?? lowerLevels[0] ?? null const selectedLevelName = selectedLevel ? getLevelDisplayName(selectedLevel) : null return (
{ if (hasLowerLevels) toggleReferenceFloor() }} size="icon" variant="ghost" >
{lowerLevels.length}

Reference floor

{selectedLevelName && (

{selectedLevelName}

)}
{hasLowerLevels ? ( <>
{lowerLevels.map((level, index) => { const isSelected = referenceFloorOffset === index + 1 const levelName = getLevelDisplayName(level) return ( ) })}
) : (
No lower floor available.
)}
) } // ── Exports ───────────────────────────────────────────────────────────────── export { GridSnapControl } export function SecondaryToggles() { return (
) } export function ViewToggles() { return (
) }