'use client' import { Icon } from '@iconify/react' import { type AnyNode, type AnyNodeId, type BuildingNode, emitter, getLevelDisplayName, type LevelNode, useScene, type ZoneNode, } from '@pascal-app/core' import { CLAY_PALETTE, type EdgeMode, getSceneTheme, SCENE_THEMES, useViewer, } from '@pascal-app/viewer' import { ArrowLeft, Box, Camera, Check, ChevronRight, Diamond, Footprints, Layers, Palette, PenLine, Sparkles, Square, } from 'lucide-react' import Link from 'next/link' import { flushSync } from 'react-dom' import { useShallow } from 'zustand/react/shallow' import { cn } from '../lib/utils' import useEditor from '../store/use-editor' import { ActionButton } from './ui/action-menu/action-button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from './ui/primitives/dropdown-menu' import { TooltipProvider } from './ui/primitives/tooltip' type ProjectOwner = { id: string name: string username: string | null image: string | null } function requestWalkthroughPointerLock() { const canvas = document.querySelector('[data-pascal-viewer-3d] canvas') if (!canvas) return if (!canvas.hasAttribute('tabindex')) { canvas.tabIndex = -1 } canvas.focus({ preventScroll: true }) if (document.pointerLockElement === canvas) return try { canvas.requestPointerLock?.() } catch { return } } const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { stacked: 'Stacked', exploded: 'Exploded', solo: 'Solo', } const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = { manual: 'Stack', stacked: 'Stack', exploded: 'Exploded', solo: 'Solo', } const wallModeConfig = { up: { icon: (props: any) => ( Full Height ), label: 'Full Height', }, cutaway: { icon: (props: any) => ( Cutaway ), label: 'Cutaway', }, down: { icon: (props: any) => ( Low ), label: 'Low', }, translucent: { icon: (props: any) => ( Translucent ), label: 'Translucent', }, } const SHADING_OPTIONS = [ { id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box }, { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles }, ] as const const TEXTURE_OPTIONS = [ { id: true, name: 'Colored', detail: 'Show materials, textures & colors', icon: Palette }, { id: false, name: 'Monochrome', detail: 'Flat clay surfaces by role', icon: Square }, ] as const function RenderModeMenu() { const shading = useViewer((s) => s.shading) const textures = useViewer((s) => s.textures) const active = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0] const ActiveIcon = active.icon return ( {SHADING_OPTIONS.map((option) => { const OptionIcon = option.icon return ( useViewer.getState().setShading(option.id)} >
{option.name} {option.detail}
{shading === option.id ? : null}
) })} {TEXTURE_OPTIONS.map((option) => { const OptionIcon = option.icon return ( useViewer.getState().setTextures(option.id)} >
{option.name} {option.detail}
{textures === option.id ? : null}
) })}
) } function SceneThemeMenu() { const sceneTheme = useViewer((s) => s.sceneTheme) const active = getSceneTheme(sceneTheme) return ( {SCENE_THEMES.map((sceneThemeOption) => { const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map( (role) => sceneThemeOption.clayTints?.[role] ?? CLAY_PALETTE[role], ) return ( useViewer.getState().setSceneTheme(sceneThemeOption.id)} > {swatches.map((color, index) => ( ))} {sceneThemeOption.name} {sceneTheme === sceneThemeOption.id ? ( ) : null} ) })} ) } const EDGE_OPTIONS = [ { id: 'off', name: 'Off', detail: 'No edge lines' }, { id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' }, { id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' }, ] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[] function EdgesMenu() { const edges = useViewer((s) => s.edges) const active = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0] return ( {EDGE_OPTIONS.map((option) => ( useViewer.getState().setEdges(option.id)} >
{option.name} {option.detail}
{edges === option.id ? : null}
))}
) } const getNodeName = (node: AnyNode): string => { if ('name' in node && node.name) return node.name if (node.type === 'wall') return 'Wall' if (node.type === 'fence') return 'Fence' if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item' if (node.type === 'slab') return 'Slab' if (node.type === 'ceiling') return 'Ceiling' if (node.type === 'roof') return 'Roof' if (node.type === 'roof-segment') return 'Roof Segment' return node.type } interface ViewerOverlayProps { projectName?: string | null owner?: ProjectOwner | null canShowScans?: boolean canShowGuides?: boolean onBack?: () => void } export const ViewerOverlay = ({ projectName, owner, canShowScans = true, canShowGuides = true, onBack, }: ViewerOverlayProps) => { const selection = useViewer((s) => s.selection) const showScans = useViewer((s) => s.showScans) const showGuides = useViewer((s) => s.showGuides) const cameraMode = useViewer((s) => s.cameraMode) const levelMode = useViewer((s) => s.levelMode) const wallMode = useViewer((s) => s.wallMode) // Subscribe only to the specific nodes we read so that creating an unrelated // node elsewhere in the scene doesn't re-render this overlay. const firstSelectedId = selection.selectedIds[0] ?? null const building = useScene((s) => selection.buildingId ? (s.nodes[selection.buildingId] as BuildingNode | undefined) : null, ) const level = useScene((s) => selection.levelId ? (s.nodes[selection.levelId] as LevelNode | undefined) : null, ) const zone = useScene((s) => selection.zoneId ? (s.nodes[selection.zoneId] as ZoneNode | undefined) : null, ) const selectedNode = useScene((s) => firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null, ) const levels = useScene( useShallow((s) => { if (!building) return [] return building.children .map((id) => s.nodes[id as AnyNodeId] as LevelNode | undefined) .filter((n): n is LevelNode => n?.type === 'level') .sort((a, b) => a.level - b.level) }), ) const handleLevelClick = (levelId: LevelNode['id']) => { // When switching levels, deselect zone and items useViewer.getState().setSelection({ levelId }) } const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => { switch (depth) { case 'root': useViewer.getState().resetSelection() break case 'building': useViewer.getState().setSelection({ levelId: null }) break case 'level': useViewer.getState().setSelection({ zoneId: null }) break } } return ( <> {/* Unified top-left card */}
{/* Project info + back */}
{onBack ? ( ) : ( )}
{projectName || 'Untitled'}
{owner?.username && ( @{owner.username} )}
{/* Breadcrumb — only shown when navigated into a building */} {building && (
{building && ( <> )} {level && ( <> )} {zone && ( <> {zone.name} )} {selectedNode && zone && ( <> {getNodeName(selectedNode)} )}
)}
{/* Level List (only when building is selected) */} {building && levels.length > 0 && (
Levels
{levels.map((lvl) => { const isSelected = lvl.id === selection.levelId return ( ) })}
)}
{/* Controls Panel - Bottom Center */}
{/* Scans and Guides Visibility */} {canShowScans && ( useViewer.getState().setShowScans(!showScans)} size="icon" tooltipSide="top" variant="ghost" > Scans )} {canShowGuides && ( useViewer.getState().setShowGuides(!showGuides)} size="icon" tooltipSide="top" variant="ghost" > Guides )} {(canShowScans || canShowGuides) &&
} {/* Camera Mode */} useViewer .getState() .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective') } size="icon" tooltipSide="top" variant="ghost" > {/* Level Mode */} { if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked') const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo'] const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked') }} size="icon" tooltipSide="top" variant="ghost" > {levelMode === 'solo' && } {levelMode === 'exploded' && ( )} {(levelMode === 'stacked' || levelMode === 'manual') && ( )} {/* Wall Mode */} { const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [ 'cutaway', 'up', 'down', 'translucent', ] const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') }} size="icon" tooltipSide="top" variant="ghost" > {(() => { const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon return })()}
{/* Camera Actions */} emitter.emit('camera-controls:orbit-ccw')} size="icon" tooltipSide="top" variant="ghost" > Orbit Left emitter.emit('camera-controls:orbit-cw')} size="icon" tooltipSide="top" variant="ghost" > Orbit Right emitter.emit('camera-controls:top-view')} size="icon" tooltipSide="top" variant="ghost" > Top View
{/* First-person walkthrough */} { flushSync(() => useEditor.getState().setFirstPersonMode(true)) requestWalkthroughPointerLock() }} size="icon" tooltipSide="top" variant="ghost" >
) }