'use client' import { convertIfcToPascal, type PascalSceneGraph } from '@pascal-app/ifc-converter' import dynamic from 'next/dynamic' import { useCallback, useEffect, useMemo, useState } from 'react' import { availableTestFiles, exampleFileUrl, testFiles } from '@/lib/test-files' // The viewer uses three's WebGPU renderer + the registry-driven scene // store, neither of which run during SSR — dynamic-import with ssr:false // so the bundle doesn't hit the server. const PascalViewer = dynamic(() => import('./PascalSceneViewer'), { ssr: false }) type Status = 'idle' | 'loading' | 'converting' | 'ready' | 'error' // The converter writes a fixed shape into BaseNode.metadata, but the // underlying type is z.json() — a loose JSON value. This helper gives // the UI dot-access on the fields the converter actually writes. type ConverterMetadata = { ifcType?: string expressID?: number globalId?: string levelId?: string elevation?: number material?: string typeName?: string properties?: Record> [key: string]: unknown } function meta(node: { metadata?: unknown } | null | undefined): ConverterMetadata { return (node?.metadata ?? {}) as ConverterMetadata } export default function IfcConverter() { const [pascalData, setPascalData] = useState(null) const [status, setStatus] = useState('idle') const [error, setError] = useState(null) const [isDragging, setIsDragging] = useState(false) const [fileName, setFileName] = useState('') const [selectedFile, setSelectedFile] = useState('01-duplex.ifc') const [ifcData, setIfcData] = useState(null) const [showJson, setShowJson] = useState(false) const [visibleLevels, setVisibleLevels] = useState>(new Set()) const [visibleTypes, setVisibleTypes] = useState>(new Set()) const [selectedNodeId, setSelectedNodeId] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [searchOpen, setSearchOpen] = useState(false) const [conversionProgress, setConversionProgress] = useState(0) const [conversionMessage, setConversionMessage] = useState('') const levels = useMemo(() => { if (!pascalData) return [] return Object.values(pascalData.nodes) .filter((n) => n.type === 'level') .sort((a, b) => (meta(a).elevation ?? 0) - (meta(b).elevation ?? 0)) .map((n) => ({ id: n.id, name: n.name ?? n.id, elevation: meta(n).elevation ?? 0 })) }, [pascalData]) const typeCounts = useMemo(() => { if (!pascalData) return {} const counts: Record = {} for (const n of Object.values(pascalData.nodes)) { counts[n.type] = (counts[n.type] || 0) + 1 } return counts }, [pascalData]) const elementTypes = useMemo(() => { const order = ['wall', 'slab', 'door', 'window', 'stair', 'roof', 'column', 'item'] return order.filter((t) => typeCounts[t]) }, [typeCounts]) useEffect(() => { if (levels.length > 0) { setVisibleLevels(new Set(levels.map((l) => l.id))) } }, [levels]) useEffect(() => { if (elementTypes.length > 0) { setVisibleTypes(new Set(elementTypes)) } }, [elementTypes]) const searchResults = useMemo(() => { if (!pascalData || !searchQuery.trim()) return [] const q = searchQuery.toLowerCase() const results: { id: string; name: string; type: string; match: string }[] = [] for (const node of Object.values(pascalData.nodes)) { if (['site', 'building', 'level'].includes(node.type)) continue const m = meta(node) let match: string | null = null if (node.name?.toLowerCase().includes(q)) match = `Name: ${node.name}` else if (node.type.includes(q)) match = `Type: ${node.type}` else if (m.ifcType?.toLowerCase().includes(q)) match = `IFC: ${m.ifcType}` else if (m.typeName?.toLowerCase().includes(q)) match = `Type: ${m.typeName}` else if (m.material?.toLowerCase().includes(q)) match = `Material: ${m.material}` else if (m.globalId?.toLowerCase().includes(q)) match = `ID: ${m.globalId}` else if (m.properties) { for (const [psetName, props] of Object.entries(m.properties) as [string, any][]) { for (const [k, v] of Object.entries(props)) { if (k.toLowerCase().includes(q) || String(v).toLowerCase().includes(q)) { match = `${psetName}: ${k} = ${v}` break } } if (match) break } } if (match) { results.push({ id: node.id, name: node.name ?? node.id, type: node.type, match }) if (results.length >= 50) break } } return results }, [pascalData, searchQuery]) useEffect(() => { const params = new URLSearchParams(window.location.search) const requested = params.get('file') const matched = testFiles.some((f) => f.name === requested) const initial = matched ? requested! : '01-duplex.ifc' loadExampleFile(initial) if (matched) { document.getElementById('try')?.scrollIntoView({ block: 'start' }) } }, []) const loadAndConvert = async (data: Uint8Array, name: string) => { setFileName(name) setStatus('converting') setSearchQuery('') setSelectedNodeId(null) setConversionProgress(0) setConversionMessage('Starting conversion...') try { const result = await convertIfcToPascal(data, (message, percent) => { setConversionMessage(message) setConversionProgress(percent) }) setPascalData(result) setStatus('ready') setConversionProgress(100) setConversionMessage('Conversion complete!') } catch (err) { setError(err instanceof Error ? err.message : 'Conversion failed') setStatus('error') setConversionProgress(0) } } const loadExampleFile = async (filename: string) => { setStatus('loading') setSelectedFile(filename) setError(null) const params = new URLSearchParams(window.location.search) if (params.get('file') !== filename) { params.set('file', filename) const newUrl = `${window.location.pathname}?${params.toString()}${window.location.hash}` window.history.replaceState(null, '', newUrl) } try { const file = testFiles.find((f) => f.name === filename) const url = file ? exampleFileUrl(file) : `/test-ifc-files/${filename}` const response = await fetch(url) if (!response.ok) throw new Error(`Could not load ${filename} (${response.status})`) const arrayBuffer = await response.arrayBuffer() const uint8Array = new Uint8Array(arrayBuffer) setIfcData(uint8Array) await loadAndConvert(uint8Array, filename) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load file') setStatus('error') } } const handleFile = async (file: File) => { setStatus('loading') setError(null) setSelectedFile('') const params = new URLSearchParams(window.location.search) if (params.has('file')) { params.delete('file') const qs = params.toString() const newUrl = `${window.location.pathname}${qs ? `?${qs}` : ''}${window.location.hash}` window.history.replaceState(null, '', newUrl) } try { const arrayBuffer = await file.arrayBuffer() const uint8Array = new Uint8Array(arrayBuffer) setIfcData(uint8Array) await loadAndConvert(uint8Array, file.name) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load file') setStatus('error') } } const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault() setIsDragging(false) const file = e.dataTransfer.files[0] if (file?.name.toLowerCase().endsWith('.ifc')) { handleFile(file) } else { setError('Please drop a valid IFC file') } }, []) const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() setIsDragging(true) }, []) const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault() setIsDragging(false) }, []) const handleFileInput = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) handleFile(file) } const downloadPascalJson = () => { if (!pascalData) return const json = JSON.stringify(pascalData, null, 2) const blob = new Blob([json], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = fileName.replace('.ifc', '') + '_pascal.json' a.click() URL.revokeObjectURL(url) } const downloadIfc = () => { if (!ifcData) return const blob = new Blob([ifcData as any], { type: 'application/octet-stream' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = fileName a.click() URL.revokeObjectURL(url) } const copyJsonToClipboard = () => { if (!pascalData) return const json = JSON.stringify(pascalData, null, 2) navigator.clipboard.writeText(json) } const isWorking = status === 'loading' || status === 'converting' return (

Try It

Upload an IFC file or pick an example below

{/* Upload — compact */}
{/* Example IFC files — 2 rows x 5 cards */}

Or pick an example

{availableTestFiles().map((file) => ( ))}
{/* Error */} {status === 'error' && error && (
Error: {error}
)} {/* Results — always rendered once we have data, with loading overlay */} {(pascalData || isWorking) && (
{/* Header with stats and download buttons */} {pascalData && ( <>

{fileName}

{Object.keys(pascalData.nodes).length} nodes {new Set(Object.values(pascalData.nodes).map((n) => n.type)).size} types
{/* Type filter */} {elementTypes.length > 1 && (
Types {elementTypes.map((t) => { const active = visibleTypes.has(t) return ( ) })}
)} {/* Level filter */} {levels.length > 1 && (
Levels {levels.map((level) => { const active = visibleLevels.has(level.id) return ( ) })}
)} {/* Search */}
{ setSearchQuery(e.target.value) setSearchOpen(true) }} onFocus={() => setSearchOpen(true)} onBlur={() => setTimeout(() => setSearchOpen(false), 200)} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> {searchQuery && ( )} {searchOpen && searchQuery.trim() && (
{searchResults.length === 0 ? (
No results
) : ( searchResults.map((r) => ( )) )} {searchResults.length >= 50 && (
Showing first 50 results
)}
)}
)} {/* Pascal 3D Viewer */}
{/* Loading overlay */} {isWorking && (

{status === 'loading' ? 'Loading file...' : 'Converting to Pascal'}

{status === 'converting' && (
{conversionMessage} {conversionProgress}%
)}
)} {pascalData && ( )} {!pascalData &&
}

Orbit (left click) / Pan (right click) / Zoom (scroll) / Click element to inspect

{selectedNodeId && Boolean( (pascalData?.nodes as Record | undefined)?.[selectedNodeId], ) && (() => { const node = (pascalData!.nodes as Record)[selectedNodeId] as any const meta = node.metadata ?? {} const Row = ({ k, v }: { k: string; v: string }) => (
{k} {v}
) return (

{node.name ?? node.type}

{meta.typeName && } {meta.ifcType && } {meta.globalId && } {meta.expressID != null && ( )} {meta.levelId && ( )}
{(node.start || node.thickness != null || node.height != null || node.width != null || node.elevation != null || node.polygon) && (

Geometry

{node.start && ( v.toFixed(2)).join(', ')}]`} /> )} {node.end && ( v.toFixed(2)).join(', ')}]`} /> )} {node.thickness != null && ( )} {node.height != null && ( )} {node.width != null && } {node.position != null && node.type !== 'wall' && ( v.toFixed(2)).join(', ')}]`} /> )} {node.elevation != null && ( )} {node.sillHeight != null && ( )} {node.polygon && }
)} {(meta.material || meta.materialLayers) && (

Material

{meta.material && } {meta.materialLayers?.map((l: any, i: number) => ( ))}
)} {meta.properties && Object.entries(meta.properties).map(([psetName, props]: [string, any]) => (

{psetName}

{Object.entries(props).map(([k, v]: [string, any]) => ( ))}
))}
) })()}
)} {/* JSON Drawer - fixed position from top (shows when ready and showJson is true) */} {status === 'ready' && pascalData && showJson && (

Pascal JSON

              {JSON.stringify(pascalData, null, 2)}
            
)} {/* JSON toggle button - fixed position (shows when ready and showJson is false) */} {status === 'ready' && pascalData && !showJson && ( )}
) }