splitting editor and community

This commit is contained in:
wass08
2026-03-11 12:16:26 +01:00
parent 5108636d49
commit 7359c1fcbf
586 changed files with 6477 additions and 1546 deletions
@@ -0,0 +1,300 @@
'use client'
import {
type AnyNodeId,
type CollectionId,
type Control,
type ControlValue,
type ItemNode,
useInteractive,
useScene,
} from '@pascal-app/core'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { cn } from '@/lib/utils'
// ─── Shared control derivation ───────────────────────────────────────────────
type ItemControlRef = { itemId: AnyNodeId; controlIndex: number }
type SharedControlDef = {
kind: 'toggle' | 'slider' | 'temperature'
label?: string
min?: number
max?: number
step?: number
unit?: string
refs: ItemControlRef[]
}
function deriveSharedControls(items: ItemNode[]): SharedControlDef[] {
if (items.length === 0) return []
const result: SharedControlDef[] = []
for (const kind of ['toggle', 'slider', 'temperature'] as const) {
const refs: ItemControlRef[] = []
let ref: Control | null = null
let allHave = true
for (const item of items) {
const idx = item.asset.interactive!.controls.findIndex((c) => c.kind === kind)
if (idx === -1) { allHave = false; break }
refs.push({ itemId: item.id, controlIndex: idx })
if (!ref) ref = item.asset.interactive!.controls[idx]!
}
if (!allHave || !ref) continue
const def: SharedControlDef = { kind, label: ref.label, refs }
if ('min' in ref) { def.min = ref.min; def.max = ref.max }
if ('step' in ref) def.step = (ref as { step?: number }).step
if ('unit' in ref) def.unit = (ref as { unit?: string }).unit
result.push(def)
}
return result
}
// ─── Shared control widget ───────────────────────────────────────────────────
function SharedWidget({ def, value, onChange }: { def: SharedControlDef; value: ControlValue; onChange: (v: ControlValue) => void }) {
if (def.kind === 'toggle') {
return (
<button
type="button"
onClick={() => onChange(!value)}
className={cn(
'flex h-7 w-full items-center justify-center rounded-md px-3 text-xs font-medium transition-colors',
value ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-muted-foreground hover:text-foreground',
)}
>
{def.label ?? (value ? 'On' : 'Off')}
</button>
)
}
return (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{def.label ?? def.kind}</span>
<span>{value}{def.kind === 'temperature' ? '°' : ''}{def.unit ? ` ${def.unit}` : ''}</span>
</div>
<input
type="range"
min={def.min}
max={def.max}
step={def.step ?? 1}
value={value as number}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
className="w-full accent-white"
/>
</div>
)
}
// ─── Individual item control widget ──────────────────────────────────────────
function ItemWidget({ control, value, onChange }: { control: Control; value: ControlValue; onChange: (v: ControlValue) => void }) {
if (control.kind === 'toggle') {
return (
<button
type="button"
onClick={() => onChange(!value)}
className={cn(
'flex h-6 w-full items-center justify-center rounded px-2 text-[10px] font-medium transition-colors',
value ? 'bg-green-500/20 text-green-400' : 'bg-white/5 text-muted-foreground hover:text-foreground',
)}
>
{control.label ?? (value ? 'On' : 'Off')}
</button>
)
}
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{control.label ?? control.kind}</span>
<span>{value}{control.kind === 'temperature' ? '°' : ''}{'unit' in control && control.unit ? ` ${control.unit}` : ''}</span>
</div>
<input
type="range"
min={'min' in control ? control.min : 0}
max={'max' in control ? control.max : 100}
step={'step' in control ? (control as { step?: number }).step ?? 1 : 1}
value={value as number}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
className="w-full accent-white"
/>
</div>
)
}
// ─── Collection row ───────────────────────────────────────────────────────────
function CollectionRow({ collectionId }: { collectionId: CollectionId }) {
const collection = useScene((s) => s.collections[collectionId])
const interactiveItems = useScene(
useShallow((s) =>
(collection?.nodeIds ?? [])
.map((id) => s.nodes[id])
.filter((n): n is ItemNode => n?.type === 'item' && !!n.asset.interactive)
),
)
const allItems = useInteractive((s) => s.items)
const controlValuesByItem = useMemo(
() => Object.fromEntries(interactiveItems.map((n) => [n.id, allItems[n.id]?.controlValues ?? []])),
[allItems, interactiveItems],
)
const setControlValue = useInteractive((s) => s.setControlValue)
const [expanded, setExpanded] = useState(false)
const [expandedItemIds, setExpandedItemIds] = useState<Set<AnyNodeId>>(new Set())
if (!collection) return null
const sharedControls = deriveSharedControls(interactiveItems)
const getSharedValue = (def: SharedControlDef): ControlValue => {
if (def.kind === 'toggle') {
return def.refs.every(({ itemId, controlIndex }) => Boolean(controlValuesByItem[itemId]?.[controlIndex]))
}
const first = def.refs[0]!
return controlValuesByItem[first.itemId]?.[first.controlIndex] ?? 0
}
const setSharedValue = (def: SharedControlDef, value: ControlValue) => {
for (const { itemId, controlIndex } of def.refs) {
setControlValue(itemId, controlIndex, value)
}
}
const toggleItemExpand = (id: AnyNodeId) => {
setExpandedItemIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div>
{/* Header */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2.5 hover:bg-white/5 transition-colors"
>
<span
className="h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20"
style={{ backgroundColor: collection.color ?? '#6366f1' }}
/>
<span className="flex-1 min-w-0 text-xs font-medium text-foreground truncate text-left">
{collection.name}
</span>
{interactiveItems.length > 0 && (
<span className="text-[10px] text-muted-foreground shrink-0">
{interactiveItems.length}
</span>
)}
{expanded
? <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />}
</button>
{/* Expanded */}
{expanded && (
<div className="pb-1">
{interactiveItems.length === 0 ? (
<p className="px-3 pb-2 text-[11px] text-muted-foreground">No interactive items.</p>
) : (
<>
{/* Shared controls */}
{sharedControls.length > 0 && (
<div className="px-3 pt-0.5 pb-2.5 border-b border-border/30">
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mb-2">All</p>
<div className="flex flex-col gap-2">
{sharedControls.map((def, i) => (
<SharedWidget
key={i}
def={def}
value={getSharedValue(def)}
onChange={(v) => setSharedValue(def, v)}
/>
))}
</div>
</div>
)}
{/* Individual items */}
{interactiveItems.map((item) => {
const isItemExpanded = expandedItemIds.has(item.id)
const controls = item.asset.interactive!.controls
const values = controlValuesByItem[item.id] ?? []
return (
<div key={item.id}>
<button
type="button"
onClick={() => toggleItemExpand(item.id)}
className="flex w-full items-center gap-1.5 px-3 py-1.5 hover:bg-white/5 transition-colors"
>
{isItemExpanded
? <ChevronDown className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
: <ChevronRight className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />}
<span className="flex-1 min-w-0 text-[11px] text-muted-foreground truncate text-left">
{item.name || item.asset.name}
</span>
</button>
{isItemExpanded && (
<div className="px-3 pb-2 flex flex-col gap-1.5">
{controls.map((control, i) => (
<ItemWidget
key={i}
control={control}
value={values[i] ?? false}
onChange={(v) => setControlValue(item.id, i, v)}
/>
))}
</div>
)}
</div>
)
})}
</>
)}
</div>
)}
</div>
)
}
// ─── Main panel ───────────────────────────────────────────────────────────────
export function CollectionsPanel() {
const collectionIds = useScene(
useShallow((s) => Object.keys(s.collections) as CollectionId[]),
)
if (collectionIds.length === 0) return null
return (
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl overflow-hidden w-56">
<div className="px-3 py-2 border-b border-border/40 shrink-0">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Collections</span>
</div>
<div className="overflow-y-auto max-h-[70vh] no-scrollbar divide-y divide-border/30">
{collectionIds.map((id) => (
<CollectionRow key={id} collectionId={id} />
))}
</div>
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
'use client'
import Link from 'next/link'
import { useEffect } from 'react'
export default function ViewerRouteError({
error,
reset,
}: Readonly<{
error: Error & { digest?: string }
reset: () => void
}>) {
useEffect(() => {
console.error('[viewer-route] Unhandled viewer error:', error)
}, [error])
return (
<div className="flex min-h-screen w-full items-center justify-center bg-background p-4 text-foreground">
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
<h1 className="text-lg font-semibold">Viewer error</h1>
<p className="mt-2 text-sm text-muted-foreground">
We couldn&apos;t load this project view. You can retry without leaving the app.
</p>
<div className="mt-4 flex items-center gap-2">
<button
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
onClick={reset}
type="button"
>
Try again
</button>
<Link
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
href="/"
>
Back to home
</Link>
</div>
</div>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Project Viewer',
description: 'View and share 3D projects built with Pascal Editor.',
}
export default function ViewerLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return children
}
+190
View File
@@ -0,0 +1,190 @@
'use client'
import { initSpatialGridSync, useScene } from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import { ErrorBoundary } from '@/components/ui/primitives/error-boundary'
import { SceneLoader } from '@pascal-app/editor'
import {
getProjectModelPublic,
incrementProjectViews,
} from '@/features/community/lib/projects/actions'
import type { ProjectOwner } from '@/features/community/lib/projects/types'
import { ViewerCameraControls } from './viewer-camera-controls'
import { ViewerGuestCTA } from './viewer-guest-cta'
import { ViewerOverlay } from './viewer-overlay'
import { ViewerZoneSystem } from './viewer-zone-system'
function ViewerSceneCrashFallback({ projectName }: { projectName?: string | null }) {
return (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-background/95 p-4 text-foreground">
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
<h2 className="text-lg font-semibold">The 3D scene failed to render</h2>
<p className="mt-2 text-sm text-muted-foreground">
{projectName ? `"${projectName}" ` : ''}
hit a rendering error. The rest of the app is still available.
</p>
<div className="mt-4 flex items-center gap-2">
<button
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
onClick={() => window.location.reload()}
type="button"
>
Reload scene
</button>
<Link
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
href="/"
>
Back to home
</Link>
</div>
</div>
</div>
)
}
export default function ViewerPage() {
const params = useParams()
const id = params.id as string
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [projectId, setProjectId] = useState<string | null>(null)
const [projectName, setProjectName] = useState<string | null>(null)
const [owner, setOwner] = useState<ProjectOwner | null>(null)
const [canShowScans, setCanShowScans] = useState(true)
const [canShowGuides, setCanShowGuides] = useState(true)
const setScene = useScene((state) => state.setScene)
useEffect(() => {
useViewer.getState().setProjectId(projectId)
}, [projectId])
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
setProjectId(null)
setProjectName(null)
setOwner(null)
setCanShowScans(true)
setCanShowGuides(true)
useViewer.getState().setShowScans(true)
useViewer.getState().setShowGuides(true)
const loadContent = async () => {
try {
// Check if it's a demo file (starts with 'demo_')
if (id.startsWith('demo_')) {
const response = await fetch(`/demos/${id}.json`)
if (cancelled) return
if (!response.ok) {
throw new Error(`Demo "${id}" not found`)
}
const data = await response.json()
if (cancelled) return
if (data.nodes && data.rootNodeIds) {
setScene(data.nodes, data.rootNodeIds)
initSpatialGridSync()
}
setProjectName('Demo')
} else {
// Load from database (public project)
const result = await getProjectModelPublic(id)
if (cancelled) return
if (result.success && result.data) {
const { project, model, isOwner } = result.data
const projectData = project as any
setProjectId(project.id)
setProjectName(project.name)
setOwner(projectData.owner ?? null)
// Apply public visibility settings for scans/guides (only for non-owners)
if (!isOwner) {
const scansAllowed = projectData.show_scans_public !== false
const guidesAllowed = projectData.show_guides_public !== false
setCanShowScans(scansAllowed)
setCanShowGuides(guidesAllowed)
if (!scansAllowed) {
useViewer.getState().setShowScans(false)
}
if (!guidesAllowed) {
useViewer.getState().setShowGuides(false)
}
}
if (model?.scene_graph) {
const { nodes, rootNodeIds } = model.scene_graph
setScene(nodes, rootNodeIds)
initSpatialGridSync()
}
// Increment view count
await incrementProjectViews(id)
if (cancelled) return
} else {
throw new Error(result.error || 'Project not found')
}
}
if (!cancelled) {
setLoading(false)
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load content')
setLoading(false)
}
}
}
loadContent()
return () => {
cancelled = true
}
}, [id, setScene])
if (error) {
return (
<div className="flex h-screen w-full items-center justify-center bg-neutral-100">
<p className="text-destructive">{error}</p>
</div>
)
}
return (
<div className="relative h-screen w-full">
{loading && <SceneLoader fullScreen />}
{!loading && (
<>
<ViewerOverlay
projectName={projectName}
owner={owner}
canShowScans={canShowScans}
canShowGuides={canShowGuides}
/>
<ViewerGuestCTA />
<ErrorBoundary key={id} fallback={<ViewerSceneCrashFallback projectName={projectName} />}>
<Viewer>
<ViewerCameraControls />
<ViewerZoneSystem />
<InteractiveSystem />
</Viewer>
</ErrorBoundary>
</>
)}
</div>
)
}
@@ -0,0 +1,178 @@
'use client'
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Box3, Vector3 } from 'three'
const tempBox = new Box3()
const tempCenter = new Vector3()
const tempSize = new Vector3()
export const ViewerCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!)
const selection = useViewer((s) => s.selection)
const nodes = useScene((s) => s.nodes)
const cameraMode = useViewer((s) => s.cameraMode)
const firstLoad = useRef(true)
// Get the deepest selected node ID (excluding selectedIds)
const targetNodeId = selection.zoneId ?? selection.levelId ?? selection.buildingId
// Configure mouse buttons - same as editor
const mouseButtons = useMemo(() => {
const wheelAction =
cameraMode === 'orthographic'
? CameraControlsImpl.ACTION.ZOOM
: CameraControlsImpl.ACTION.DOLLY
return {
left: CameraControlsImpl.ACTION.SCREEN_PAN,
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
right: CameraControlsImpl.ACTION.ROTATE,
wheel: wheelAction,
}
}, [cameraMode])
useEffect(() => {
if (!controls.current) return
// On first load, set a default camera position
if (firstLoad.current) {
firstLoad.current = false
controls.current.setLookAt(30, 30, 30, 0, 0, 0, false)
}
let node = targetNodeId ? nodes[targetNodeId] : null;
if (!targetNodeId) {
const site = Object.values(nodes).find((n) => n.type === 'site')
node = site || null
}
if (!node) return
// Check if node has a saved camera
if (node.camera) {
const { position, target } = node.camera
requestAnimationFrame(() => {
controls.current.setLookAt(
position[0],
position[1],
position[2],
target[0],
target[1],
target[2],
true,
)
})
return
}
if (!targetNodeId) {
// No selection and no site - do nothing
return
}
// Calculate camera position based on the node's 3D object
const object3D = sceneRegistry.nodes.get(targetNodeId)
if (!object3D) return
// Compute bounding box
tempBox.setFromObject(object3D)
tempBox.getCenter(tempCenter)
tempBox.getSize(tempSize)
// Calculate a good viewing distance based on the object size
const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
const distance = Math.max(maxDim * 2, 15)
// Position camera at an angle looking at the center
const cameraPos = new Vector3(
tempCenter.x + distance * 0.7,
tempCenter.y + distance * 0.5,
tempCenter.z + distance * 0.7,
)
controls.current.setLookAt(
cameraPos.x,
cameraPos.y,
cameraPos.z,
tempCenter.x,
tempCenter.y,
tempCenter.z,
true,
)
}, [targetNodeId, nodes])
useEffect(() => {
const handleTopView = () => {
if (!controls.current) return
const currentPolarAngle = controls.current.polarAngle
// Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45°
// Otherwise, go to top view (0°)
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
controls.current.rotatePolarTo(targetAngle, true)
}
const handleOrbitCW = () => {
if (!controls.current) return
const currentAzimuth = controls.current.azimuthAngle
const currentPolar = controls.current.polarAngle
// Round to nearest 90° increment, then rotate 90° clockwise
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const target = rounded - Math.PI / 2
controls.current.rotateTo(target, currentPolar, true)
}
const handleOrbitCCW = () => {
if (!controls.current) return
const currentAzimuth = controls.current.azimuthAngle
const currentPolar = controls.current.polarAngle
// Round to nearest 90° increment, then rotate 90° counter-clockwise
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const target = rounded + Math.PI / 2
controls.current.rotateTo(target, currentPolar, true)
}
emitter.on('camera-controls:top-view', handleTopView)
emitter.on('camera-controls:orbit-cw', handleOrbitCW)
emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
return () => {
emitter.off('camera-controls:top-view', handleTopView)
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
}
}, [])
const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true)
}, [])
const onRest = useCallback(() => {
useViewer.getState().setCameraDragging(false)
}, [])
return (
<CameraControls
ref={controls}
maxDistance={100}
minDistance={5}
maxPolarAngle={Math.PI / 2 - 0.1}
minPolarAngle={0}
mouseButtons={mouseButtons}
onTransitionStart={onTransitionStart}
onRest={onRest}
restThreshold={0.01}
/>
)
}
@@ -0,0 +1,29 @@
'use client'
import { useState } from 'react'
import { useAuth } from '@/features/community/lib/auth/hooks'
import { SignInDialog } from '@/features/community/components/sign-in-dialog'
export function ViewerGuestCTA() {
const { isAuthenticated, isLoading } = useAuth()
const [showSignIn, setShowSignIn] = useState(false)
if (isLoading || isAuthenticated) return null
return (
<>
<div className="absolute top-4 right-4 z-20 dark text-foreground">
<div className="pointer-events-auto bg-background/95 backdrop-blur-xl border border-border/40 rounded-2xl px-6 py-3 shadow-lg transition-colors duration-200 ease-out flex flex-col sm:flex-row items-center gap-4">
<p className="text-sm font-medium text-foreground text-center">Want to create your own 3D project?</p>
<button
onClick={() => setShowSignIn(true)}
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors whitespace-nowrap w-full sm:w-auto"
>
Get Started
</button>
</div>
</div>
<SignInDialog open={showSignIn} onOpenChange={setShowSignIn} />
</>
)
}
@@ -0,0 +1,444 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type BuildingNode,
type LevelNode,
useScene,
type ZoneNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import {
ArrowLeft,
Camera,
ChevronRight,
Diamond,
Layers,
Layers2,
Moon,
Sun,
} from 'lucide-react'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'
import type { ProjectOwner } from '@/features/community/lib/projects/types'
import { ActionButton } from '@/components/ui/action-menu/action-button'
import { TooltipProvider } from '@/components/ui/primitives/tooltip'
import { emitter } from '@pascal-app/core'
import { CollectionsPanel } from './collections-panel'
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
stacked: 'Stacked',
exploded: 'Exploded',
solo: 'Solo',
}
const wallModeConfig = {
up: {
icon: (props: any) => (
<img alt="Full Height" height={28} src="/icons/room.png" width={28} {...props} />
),
label: 'Full Height',
},
cutaway: {
icon: (props: any) => (
<img alt="Cutaway" height={28} src="/icons/wallcut.png" width={28} {...props} />
),
label: 'Cutaway',
},
down: {
icon: (props: any) => <img alt="Low" height={28} src="/icons/walllow.png" width={28} {...props} />,
label: 'Low',
},
}
const getNodeName = (node: AnyNode): string => {
if ('name' in node && node.name) return node.name
if (node.type === 'wall') return 'Wall'
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'
return node.type
}
interface ViewerOverlayProps {
projectName?: string | null
owner?: ProjectOwner | null
canShowScans?: boolean
canShowGuides?: boolean
onBack?: () => void
hideCollections?: boolean
}
export const ViewerOverlay = ({
projectName,
owner,
canShowScans = true,
canShowGuides = true,
onBack,
hideCollections,
}: ViewerOverlayProps) => {
const selection = useViewer((s) => s.selection)
const nodes = useScene((s) => s.nodes)
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)
const theme = useViewer((s) => s.theme)
const building = selection.buildingId
? (nodes[selection.buildingId] as BuildingNode | undefined)
: null
const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null
// Get the first selected item (if any)
const selectedNode =
selection.selectedIds.length > 0
? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined)
: null
// Get all levels for the selected building
const levels =
building?.children
.map((id) => 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 */}
<div className="absolute top-4 left-4 z-20 flex flex-col gap-3 dark text-foreground">
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out overflow-hidden min-w-[200px]">
{/* Project info + back */}
<div className="flex items-center gap-3 px-3 py-2.5">
{onBack ? (
<button
onClick={onBack}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/10 transition-colors"
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
</button>
) : (
<Link
href="/"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/10 transition-colors"
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
</Link>
)}
<div className="min-w-0">
<div className="text-sm font-medium text-foreground truncate">
{projectName || 'Untitled'}
</div>
{owner?.username && (
<Link
href={`/u/${owner.username}`}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
@{owner.username}
</Link>
)}
</div>
</div>
{/* Breadcrumb — only shown when navigated into a building */}
{building && (
<div className="border-t border-border/40 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs">
<button
onClick={() => handleBreadcrumbClick('root')}
className="text-muted-foreground hover:text-foreground transition-colors"
>
Site
</button>
{building && (
<>
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<button
onClick={() => handleBreadcrumbClick('building')}
className={`transition-colors truncate ${level ? 'text-muted-foreground hover:text-foreground' : 'text-foreground font-medium'}`}
>
{building.name || 'Building'}
</button>
</>
)}
{level && (
<>
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<button
onClick={() => handleBreadcrumbClick('level')}
className={`transition-colors truncate ${zone ? 'text-muted-foreground hover:text-foreground' : 'text-foreground font-medium'}`}
>
{level.name || `Level ${level.level}`}
</button>
</>
)}
{zone && (
<>
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<span
className={`transition-colors truncate ${selectedNode ? 'text-muted-foreground' : 'text-foreground font-medium'}`}
>
{zone.name}
</span>
</>
)}
{selectedNode && zone && (
<>
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<span className="text-foreground font-medium truncate">
{getNodeName(selectedNode)}
</span>
</>
)}
</div>
</div>
)}
</div>
{/* Level List (only when building is selected) */}
{building && levels.length > 0 && (
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out overflow-hidden w-48 py-1">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider px-3 py-2">Levels</span>
<div className="flex flex-col">
{levels.map((lvl) => {
const isSelected = lvl.id === selection.levelId;
return (
<button
key={lvl.id}
onClick={() => handleLevelClick(lvl.id)}
className={cn(
"relative flex items-center h-8 w-full cursor-pointer group/row text-sm select-none border-b border-r border-border/50 border-r-transparent transition-all duration-200 px-3",
isSelected
? "bg-accent/50 text-foreground border-r-white border-r-3"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
)}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className={cn(
"w-4 h-4 flex items-center justify-center shrink-0 transition-all duration-200",
!isSelected && "opacity-60 grayscale"
)}>
<Layers className="w-3.5 h-3.5" />
</span>
<div className="flex-1 min-w-0 truncate text-left">
{lvl.name || `Level ${lvl.level}`}
</div>
</div>
</button>
);
})}
</div>
</div>
)}
</div>
{/* Collections Panel - Top Right */}
{!hideCollections && (
<div className="absolute top-4 right-4 z-20 flex flex-col gap-3 dark text-foreground">
<CollectionsPanel />
</div>
)}
{/* Controls Panel - Bottom Center */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 dark text-foreground">
<TooltipProvider delayDuration={0}>
<div className="pointer-events-auto flex flex-row items-center justify-center gap-1.5 rounded-2xl border border-border/40 bg-background/95 p-1.5 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out h-14">
{/* Theme Toggle */}
<button
className="shrink-0 flex items-center bg-accent/50 rounded-full p-1 border border-border/50 cursor-pointer h-[36px]"
onClick={() => useViewer.getState().setTheme(theme === 'dark' ? 'light' : 'dark')}
type="button"
aria-label="Toggle theme"
>
<div className="relative flex">
{/* Sliding Background */}
<motion.div
className="absolute inset-0 bg-white shadow-sm rounded-full dark:bg-white/20"
initial={false}
animate={{
x: theme === "light" ? "100%" : "0%",
}}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
style={{ width: "50%" }}
/>
{/* Dark Mode Icon */}
<div
className={cn(
"relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
theme === "dark"
? "text-foreground"
: "text-muted-foreground"
)}
>
<Moon className="h-4 w-4" />
</div>
{/* Light Mode Icon */}
<div
className={cn(
"relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
theme === "light"
? "text-foreground"
: "text-muted-foreground"
)}
>
<Sun className="h-4 w-4" />
</div>
</div>
</button>
<div className="mx-1 h-5 w-px bg-border/40" />
{/* Scans and Guides Visibility */}
{canShowScans && (
<ActionButton
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
tooltipSide="top"
className={showScans ? 'bg-white/10' : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5'}
onClick={() => useViewer.getState().setShowScans(!showScans)}
size="icon"
variant="ghost"
>
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
</ActionButton>
)}
{canShowGuides && (
<ActionButton
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
tooltipSide="top"
className={showGuides ? 'bg-white/10' : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5'}
onClick={() => useViewer.getState().setShowGuides(!showGuides)}
size="icon"
variant="ghost"
>
<img alt="Guides" className="h-[28px] w-[28px] object-contain" src="/icons/floorplan.png" />
</ActionButton>
)}
{(canShowScans || canShowGuides) && <div className="mx-1 h-5 w-px bg-border/40" />}
{/* Camera Mode */}
<ActionButton
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
tooltipSide="top"
className={cameraMode === 'orthographic' ? 'bg-violet-500/20 text-violet-400' : 'hover:text-violet-400 hover:bg-white/5'}
onClick={() => useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
size="icon"
variant="ghost"
>
<Camera className="h-6 w-6" />
</ActionButton>
{/* Level Mode */}
<ActionButton
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
tooltipSide="top"
className={levelMode !== 'stacked' ? 'bg-amber-500/20 text-amber-400' : 'hover:text-amber-400 hover:bg-white/5'}
onClick={() => {
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"
variant="ghost"
>
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
{levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-6 w-6" />}
</ActionButton>
{/* Wall Mode */}
<ActionButton
label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
tooltipSide="top"
className={wallMode !== 'cutaway' ? 'bg-white/10' : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5'}
onClick={() => {
const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down']
const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
}}
size="icon"
variant="ghost"
>
{(() => {
const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon
return <Icon className="h-[28px] w-[28px]" />
})()}
</ActionButton>
<div className="mx-1 h-5 w-px bg-border/40" />
{/* Camera Actions */}
<ActionButton
label="Orbit Left"
tooltipSide="top"
className="group hover:bg-white/5 hidden sm:inline-flex"
onClick={() => emitter.emit('camera-controls:orbit-ccw')}
size="icon"
variant="ghost"
>
<img alt="Orbit Left" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100" src="/icons/rotate.png" />
</ActionButton>
<ActionButton
label="Orbit Right"
tooltipSide="top"
className="group hover:bg-white/5 hidden sm:inline-flex"
onClick={() => emitter.emit('camera-controls:orbit-cw')}
size="icon"
variant="ghost"
>
<img alt="Orbit Right" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100" src="/icons/rotate.png" />
</ActionButton>
<ActionButton
label="Top View"
tooltipSide="top"
className="group hover:bg-white/5"
onClick={() => emitter.emit('camera-controls:top-view')}
size="icon"
variant="ghost"
>
<img alt="Top View" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100" src="/icons/topview.png" />
</ActionButton>
</div>
</TooltipProvider>
</div>
</>
)
}
@@ -0,0 +1,37 @@
'use client'
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
export const ViewerZoneSystem = () => {
useFrame(() => {
const { levelId, zoneId } = useViewer.getState().selection
const nodes = useScene.getState().nodes
sceneRegistry.byType.zone.forEach((id) => {
const obj = sceneRegistry.nodes.get(id)
if (!obj) return
const zone = nodes[id as ZoneNode['id']] as ZoneNode | undefined
if (!zone) return
// Hide zones if:
// 1. No level is selected
// 2. Zone is not on the selected level
// 3. A zone is already selected (hide all zones to show zone contents)
const isOnSelectedLevel = zone.parentId === levelId
const shouldShow = !!levelId && isOnSelectedLevel && !zoneId
obj.visible = shouldShow
const targetOpacity = shouldShow ? '1' : '0'
const labelEl = document.getElementById(`${id}-label`)
if (labelEl && labelEl.style.opacity !== targetOpacity) {
labelEl.style.opacity = targetOpacity
}
})
})
return null
}