feat: IFC → Pascal converter (package + app)

Brings the IFC-to-Pascal converter into the monorepo as a pure-logic
package plus a Next.js app, replacing the standalone repo that consumed
published @pascal-app/* packages (and drifted from their schemas).

packages/ifc-converter — pure conversion. Parses IFC via web-ifc and
maps elements onto @pascal-app/core node schemas (workspace-linked, so
no more version drift). Builds doors/windows, walls, slabs, columns,
roofs, stairs, sites/buildings/levels. Validates each node via the real
Zod schemas at build time (tryParse) and strips undefined metadata.

apps/ifc-converter — the UI: drop zone, example picker, element search,
JSON download, and a 3D preview rendered through the real
@pascal-app/viewer (registry bootstrap + read-only scene) with a custom
toolbar (camera/level/wall/grid/theme), level selector with
camera-focus, auto-fit, and selection bridged to an inspector.

Conversion specifics worth noting:
- Door/window vertical centering (height/2; windows + sill).
- Nearest-wall hosting fallback for files lacking IFCRELFILLSELEMENT,
  preferring walls long enough to contain the opening and clamping the
  along-wall position so cutouts can't overflow and break wall CSG.
- Plain IFCWALL (Brep/mapped geometry) falls back to default
  height/thickness instead of collapsing to zero-height slivers.
- Columns convert as plain structural shafts (no decorative
  base/capital) sized from the IFC profile.
- Beams + items are skipped for now (no Pascal beam type; items need a
  catalog asset) — counted in the conversion summary.

Large example IFCs are fetched from a public bucket at runtime; the four
small ones are committed. web-ifc.wasm is copied into public/ on
install/dev/build. README flags early-alpha + invites contributions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-21 10:45:12 -04:00
co-authored by Claude Opus 4.7
parent 8505d6cdfa
commit 0df51219d0
27 changed files with 150847 additions and 0 deletions
@@ -0,0 +1,742 @@
'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<string, Record<string, string | number | boolean>>
[key: string]: unknown
}
function meta(node: { metadata?: unknown } | null | undefined): ConverterMetadata {
return (node?.metadata ?? {}) as ConverterMetadata
}
export default function IfcConverter() {
const [pascalData, setPascalData] = useState<PascalSceneGraph | null>(null)
const [status, setStatus] = useState<Status>('idle')
const [error, setError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [fileName, setFileName] = useState<string>('')
const [selectedFile, setSelectedFile] = useState<string>('01-duplex.ifc')
const [ifcData, setIfcData] = useState<Uint8Array | null>(null)
const [showJson, setShowJson] = useState(false)
const [visibleLevels, setVisibleLevels] = useState<Set<string>>(new Set())
const [visibleTypes, setVisibleTypes] = useState<Set<string>>(new Set())
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [searchOpen, setSearchOpen] = useState(false)
const [conversionProgress, setConversionProgress] = useState<number>(0)
const [conversionMessage, setConversionMessage] = useState<string>('')
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<string, number> = {}
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<HTMLInputElement>) => {
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 (
<div className="w-full max-w-7xl mx-auto p-6 space-y-6">
<div className="text-center">
<h2 className="text-2xl font-semibold text-gray-900">Try It</h2>
<p className="text-sm text-gray-500 mt-1">Upload an IFC file or pick an example below</p>
</div>
{/* Upload — compact */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={`rounded-lg border-2 border-dashed p-4 text-center transition-all ${
isDragging
? 'border-blue-500 bg-blue-50 scale-[1.01]'
: 'border-gray-300 bg-gray-50 hover:border-gray-400'
}`}
>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input type="file" accept=".ifc" onChange={handleFileInput} className="hidden" />
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<span className="text-sm text-gray-600">
Drop an IFC file here or{' '}
<span className="text-blue-600 font-medium">browse to upload</span>
</span>
</label>
</div>
{/* Example IFC files — 2 rows x 5 cards */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400 mb-3">
Or pick an example
</p>
<div className="grid grid-cols-5 gap-3">
{availableTestFiles().map((file) => (
<button
key={file.name}
onClick={() => loadExampleFile(file.name)}
disabled={isWorking}
className={`rounded-lg border p-3 text-left transition-all ${
selectedFile === file.name
? 'border-blue-500 bg-blue-50 ring-1 ring-blue-500'
: 'border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm'
} ${isWorking ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<p
className={`text-sm font-medium truncate ${
selectedFile === file.name ? 'text-blue-700' : 'text-gray-900'
}`}
>
{file.label}
</p>
<p className="text-xs text-gray-400 mt-0.5">{file.detail}</p>
<p className="text-xs text-gray-500 mt-1">{file.description}</p>
{file.warning && (
<p className="mt-1.5 flex items-start gap-1 text-[11px] leading-snug text-amber-700">
<span aria-hidden></span>
<span>{file.warning}</span>
</p>
)}
</button>
))}
</div>
</div>
{/* Error */}
{status === 'error' && error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-700 text-sm">
<span className="font-medium">Error:</span> {error}
</div>
)}
{/* Results — always rendered once we have data, with loading overlay */}
{(pascalData || isWorking) && (
<div className="space-y-4">
{/* Header with stats and download buttons */}
{pascalData && (
<>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900">{fileName}</h2>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
{Object.keys(pascalData.nodes).length} nodes
</span>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
{new Set(Object.values(pascalData.nodes).map((n) => n.type)).size} types
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={downloadIfc}
className="px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 hover:border-gray-400 transition-colors"
>
Download IFC
</button>
<button
onClick={downloadPascalJson}
className="px-3 py-1.5 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Download Pascal JSON
</button>
</div>
</div>
{/* Type filter */}
{elementTypes.length > 1 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Types
</span>
<button
onClick={() => setVisibleTypes(new Set(elementTypes))}
className="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-500 hover:bg-gray-100"
>
All
</button>
{elementTypes.map((t) => {
const active = visibleTypes.has(t)
return (
<button
key={t}
onClick={() => {
const next = new Set(visibleTypes)
if (active) next.delete(t)
else next.add(t)
setVisibleTypes(next)
}}
className={`text-xs px-2 py-0.5 rounded border transition-colors ${
active
? 'bg-gray-800 text-white border-gray-800'
: 'bg-white text-gray-400 border-gray-300 hover:border-gray-400'
}`}
>
{typeCounts[t]} {t}
{(typeCounts[t] ?? 0) > 1 ? 's' : ''}
</button>
)
})}
</div>
)}
{/* Level filter */}
{levels.length > 1 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Levels
</span>
<button
onClick={() => setVisibleLevels(new Set(levels.map((l) => l.id)))}
className="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-500 hover:bg-gray-100"
>
All
</button>
<button
onClick={() => setVisibleLevels(new Set())}
className="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-500 hover:bg-gray-100"
>
None
</button>
{levels.map((level) => {
const active = visibleLevels.has(level.id)
return (
<button
key={level.id}
onClick={() => {
const next = new Set(visibleLevels)
if (active) next.delete(level.id)
else next.add(level.id)
setVisibleLevels(next)
}}
className={`text-xs px-2 py-0.5 rounded border transition-colors ${
active
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-400 border-gray-300 hover:border-gray-400'
}`}
>
{level.name}
</button>
)
})}
</div>
)}
{/* Search */}
<div className="relative">
<input
type="text"
placeholder="Search elements by name, type, material, property..."
value={searchQuery}
onChange={(e) => {
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 && (
<button
onClick={() => {
setSearchQuery('')
setSearchOpen(false)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
&times;
</button>
)}
{searchOpen && searchQuery.trim() && (
<div className="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-64 overflow-y-auto">
{searchResults.length === 0 ? (
<div className="px-3 py-4 text-sm text-gray-400 text-center">No results</div>
) : (
searchResults.map((r) => (
<button
key={r.id}
className={`w-full px-3 py-2 text-left hover:bg-blue-50 border-b border-gray-50 last:border-0 ${
selectedNodeId === r.id ? 'bg-blue-50' : ''
}`}
onClick={() => {
setSelectedNodeId(r.id)
setSearchOpen(false)
}}
>
<div className="flex items-center gap-2">
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 shrink-0">
{r.type}
</span>
<span className="text-sm text-gray-900 truncate">{r.name}</span>
</div>
<p className="text-xs text-gray-400 mt-0.5 truncate">{r.match}</p>
</button>
))
)}
{searchResults.length >= 50 && (
<div className="px-3 py-2 text-xs text-gray-400 text-center">
Showing first 50 results
</div>
)}
</div>
)}
</div>
</>
)}
{/* Pascal 3D Viewer */}
<div className="flex gap-4">
<div className="flex-1 min-w-0 relative">
{/* Loading overlay */}
{isWorking && (
<div className="absolute inset-0 z-10 bg-white/80 backdrop-blur-sm rounded-lg flex flex-col items-center justify-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-gray-200 border-t-blue-600"></div>
<p className="font-medium text-gray-900 text-sm">
{status === 'loading' ? 'Loading file...' : 'Converting to Pascal'}
</p>
{status === 'converting' && (
<div className="w-48 space-y-1">
<div className="flex justify-between text-xs">
<span className="text-gray-500">{conversionMessage}</span>
<span className="text-blue-600 font-medium">{conversionProgress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className="bg-blue-600 h-full rounded-full transition-all duration-300"
style={{ width: `${conversionProgress}%` }}
></div>
</div>
</div>
)}
</div>
)}
{pascalData && (
<PascalViewer sceneGraph={pascalData} onSelectNode={setSelectedNodeId} />
)}
{!pascalData && <div className="w-full h-[600px] bg-gray-900 rounded-lg" />}
<p className="text-xs text-gray-400 mt-1">
Orbit (left click) / Pan (right click) / Zoom (scroll) / Click element to inspect
</p>
</div>
{selectedNodeId &&
Boolean(
(pascalData?.nodes as Record<string, unknown> | undefined)?.[selectedNodeId],
) &&
(() => {
const node = (pascalData!.nodes as Record<string, any>)[selectedNodeId] as any
const meta = node.metadata ?? {}
const Row = ({ k, v }: { k: string; v: string }) => (
<div className="flex justify-between text-xs gap-2">
<span className="text-gray-500 shrink-0">{k}</span>
<span className="text-gray-900 font-mono text-right truncate" title={v}>
{v}
</span>
</div>
)
return (
<div className="w-80 shrink-0 max-h-[600px] overflow-y-auto">
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-2 sticky top-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">
{node.name ?? node.type}
</h3>
<button
onClick={() => setSelectedNodeId(null)}
className="text-gray-400 hover:text-gray-600 text-lg leading-none"
>
&times;
</button>
</div>
<div className="space-y-1 pb-2 border-b border-gray-100">
<Row k="Type" v={node.type} />
{meta.typeName && <Row k="Type Name" v={meta.typeName} />}
{meta.ifcType && <Row k="IFC Type" v={meta.ifcType} />}
{meta.globalId && <Row k="Global ID" v={meta.globalId} />}
{meta.expressID != null && (
<Row k="Express ID" v={String(meta.expressID)} />
)}
{meta.levelId && (
<Row
k="Level"
v={pascalData!.nodes[meta.levelId]?.name ?? meta.levelId}
/>
)}
</div>
{(node.start ||
node.thickness != null ||
node.height != null ||
node.width != null ||
node.elevation != null ||
node.polygon) && (
<div className="space-y-1 pb-2 border-b border-gray-100">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Geometry
</p>
{node.start && (
<Row
k="Start"
v={`[${node.start.map((v: number) => v.toFixed(2)).join(', ')}]`}
/>
)}
{node.end && (
<Row
k="End"
v={`[${node.end.map((v: number) => v.toFixed(2)).join(', ')}]`}
/>
)}
{node.thickness != null && (
<Row k="Thickness" v={`${node.thickness.toFixed(3)} m`} />
)}
{node.height != null && (
<Row k="Height" v={`${node.height.toFixed(3)} m`} />
)}
{node.width != null && <Row k="Width" v={`${node.width.toFixed(3)} m`} />}
{node.position != null && node.type !== 'wall' && (
<Row
k="Position"
v={`[${node.position.map((v: number) => v.toFixed(2)).join(', ')}]`}
/>
)}
{node.elevation != null && (
<Row k="Elevation" v={`${node.elevation.toFixed(3)} m`} />
)}
{node.sillHeight != null && (
<Row k="Sill Height" v={`${node.sillHeight.toFixed(3)} m`} />
)}
{node.polygon && <Row k="Polygon" v={`${node.polygon.length} points`} />}
</div>
)}
{(meta.material || meta.materialLayers) && (
<div className="space-y-1 pb-2 border-b border-gray-100">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Material
</p>
{meta.material && <Row k="Name" v={meta.material} />}
{meta.materialLayers?.map((l: any, i: number) => (
<Row
key={i}
k={l.name}
v={
l.thickness != null ? `${(l.thickness * 1000).toFixed(0)} mm` : '-'
}
/>
))}
</div>
)}
{meta.properties &&
Object.entries(meta.properties).map(([psetName, props]: [string, any]) => (
<div key={psetName} className="space-y-1 pb-2 border-b border-gray-100">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
{psetName}
</p>
{Object.entries(props).map(([k, v]: [string, any]) => (
<Row key={k} k={k} v={String(v)} />
))}
</div>
))}
</div>
</div>
)
})()}
</div>
</div>
)}
{/* JSON Drawer - fixed position from top (shows when ready and showJson is true) */}
{status === 'ready' && pascalData && showJson && (
<div className="fixed right-0 top-0 h-screen w-96 bg-gray-900 shadow-2xl z-50 flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h3 className="text-sm font-semibold text-gray-300">Pascal JSON</h3>
<div className="flex items-center gap-2">
<button
onClick={copyJsonToClipboard}
className="text-gray-400 hover:text-white transition-colors p-1 hover:bg-gray-800 rounded"
title="Copy to clipboard"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
<button
onClick={() => setShowJson(false)}
className="text-gray-400 hover:text-white transition-colors p-1 hover:bg-gray-800 rounded"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
<pre className="text-green-400 text-xs font-mono">
{JSON.stringify(pascalData, null, 2)}
</pre>
</div>
</div>
)}
{/* JSON toggle button - fixed position (shows when ready and showJson is false) */}
{status === 'ready' && pascalData && !showJson && (
<button
onClick={() => setShowJson(true)}
className="fixed right-6 top-24 bg-gray-900 text-white shadow-xl hover:bg-gray-800 transition-all z-10 group rounded-lg px-4 py-2"
title="Show JSON preview"
>
<div className="flex items-center gap-2">
{/* Curly braces icon */}
<span className="text-green-400 group-hover:text-green-300 transition-colors font-mono text-lg">
&#123; &#125;
</span>
<span className="text-sm font-medium">JSON</span>
</div>
</button>
)}
</div>
)
}
@@ -0,0 +1,183 @@
'use client'
// Renders an IFC-derived scene graph through the real `@pascal-app/viewer`
// (the same one the editor uses). The full `@pascal-app/editor` shell was
// tried but its CSS expects a full-page layout that doesn't sit cleanly
// inside the converter page; we use the bare Viewer + a custom toolbar
// overlay instead.
import { type AnyNode, type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import type { PascalSceneGraph } from '@pascal-app/ifc-converter'
import { useViewer, Viewer } from '@pascal-app/viewer'
import { CameraControls } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Box3, type Object3D, Vector3 } from 'three'
// Structural subset of drei's CameraControls. We can't import the real
// type because `camera-controls` is a transitive dep of `@react-three/drei`,
// not a direct one.
type CameraControlsImpl = {
fitToBox: (
target: Object3D,
enableTransition: boolean,
options?: {
paddingTop?: number
paddingBottom?: number
paddingLeft?: number
paddingRight?: number
},
) => Promise<unknown>
getTarget: (out: Vector3) => Vector3
moveTo: (x: number, y: number, z: number, enableTransition?: boolean) => Promise<unknown>
}
import { FitSceneButton, LevelSelector, PreviewToolbar } from './PreviewToolbar'
interface PascalSceneViewerProps {
sceneGraph: PascalSceneGraph
className?: string
/** Fired when the user clicks a node in the 3D view. */
onSelectNode?: (nodeId: string | null) => void
}
// Inside the Canvas — watches the scene store and frames the camera onto
// the rendered scene whenever a new model lands. Lives as a sibling of
// `<CameraControls makeDefault />`, so `useThree(s => s.controls)` picks
// up the active CameraControls instance.
function AutoFit({ trigger }: { trigger: number }) {
const sceneRoot = useThree((s) => s.scene)
const controls = useThree((s) => s.controls) as CameraControlsImpl | null
const lastFitRef = useRef(-1)
useEffect(() => {
if (!controls || trigger === lastFitRef.current) return
// Defer two RAFs: the first commits the React tree, the second
// gives the per-frame geometry systems (wall mitering, slab build,
// floor-elevation lift) a tick to settle so the bounding box
// measures the final meshes, not their pre-build placeholders.
let cancelled = false
let id1 = 0
const id0 = requestAnimationFrame(() => {
if (cancelled) return
id1 = requestAnimationFrame(() => {
if (cancelled) return
const box = new Box3().setFromObject(sceneRoot)
if (!box.isEmpty()) {
controls.fitToBox(sceneRoot, true, {
paddingTop: 1,
paddingBottom: 1,
paddingLeft: 1,
paddingRight: 1,
})
lastFitRef.current = trigger
}
})
})
return () => {
cancelled = true
cancelAnimationFrame(id0)
cancelAnimationFrame(id1)
}
}, [trigger, sceneRoot, controls])
return null
}
// Inside the Canvas — when the selected level changes, glide the camera
// target up/down to that level's elevation (the level group's world Y in
// the scene registry), keeping the current orbit X/Z. Mirrors the
// editor's CustomCameraControls level behaviour. Skips the initial
// pre-selected level so it doesn't fight `<AutoFit>`'s first framing.
function LevelFocus() {
const levelId = useViewer((s) => s.selection.levelId)
const controls = useThree((s) => s.controls) as CameraControlsImpl | null
const target = useRef(new Vector3())
const seededRef = useRef(false)
useEffect(() => {
if (!controls) return
if (!seededRef.current) {
// First level we see is the auto-pre-selection — don't move.
seededRef.current = true
return
}
if (!levelId) return
const levelMesh = sceneRegistry.nodes.get(levelId)
if (!levelMesh) return
controls.getTarget(target.current)
controls.moveTo(target.current.x, levelMesh.position.y, target.current.z, true)
}, [levelId, controls])
return null
}
export default function PascalSceneViewer({
sceneGraph,
className,
onSelectNode,
}: PascalSceneViewerProps) {
const setScene = useScene((s) => s.setScene)
const setSelection = useViewer((s) => s.setSelection)
const [fitTrigger, setFitTrigger] = useState(0)
// Push the scene into the shared store + skip the SelectionManager's
// building/level drill-down by pre-selecting both so clicks on
// walls/items resolve to wall/item selection immediately. Bumping
// fitTrigger forces the `<AutoFit>` inside the Canvas to re-frame.
useEffect(() => {
setScene(sceneGraph.nodes as Record<AnyNodeId, AnyNode>, sceneGraph.rootNodeIds as AnyNodeId[])
const allNodes = Object.values(sceneGraph.nodes) as AnyNode[]
const firstBuilding = allNodes.find((n) => n.type === 'building')
const firstLevel = allNodes.find((n) => n.type === 'level')
setSelection({
buildingId: (firstBuilding?.id ?? null) as never,
levelId: (firstLevel?.id ?? null) as never,
zoneId: null,
selectedIds: [],
})
setFitTrigger((n) => n + 1)
}, [sceneGraph, setScene, setSelection])
// Bridge `useViewer.selection.selectedIds[0]` (the SelectionManager's
// multi-select bucket for walls/items/doors/etc) back to the parent so
// the converter's inspector panel updates on every 3D click.
const selectedIds = useViewer((s) => s.selection.selectedIds)
const zoneId = useViewer((s) => s.selection.zoneId)
useEffect(() => {
onSelectNode?.((selectedIds[0] as string | undefined) ?? zoneId ?? null)
}, [selectedIds, zoneId, onSelectNode])
const onFit = useCallback(() => {
setFitTrigger((n) => n + 1)
}, [])
return (
<div
className={
className ?? 'relative w-full h-[600px] overflow-hidden rounded-lg border border-gray-200'
}
>
<div className="pointer-events-none absolute top-2 left-1/2 z-10 -translate-x-1/2">
<div className="pointer-events-auto">
<PreviewToolbar />
</div>
</div>
<div className="pointer-events-none absolute top-2 right-2 z-10">
<div className="pointer-events-auto">
<FitSceneButton onFit={onFit} />
</div>
</div>
<div className="pointer-events-none absolute top-1/2 left-2 z-10 -translate-y-1/2">
<div className="pointer-events-auto">
<LevelSelector />
</div>
</div>
<Viewer>
<CameraControls makeDefault />
<AutoFit trigger={fitTrigger} />
<LevelFocus />
</Viewer>
</div>
)
}
@@ -0,0 +1,191 @@
'use client'
// Lightweight viewer-settings toolbar for the converter preview. Drives
// the same `useViewer` store the editor's own toolbar drives — the full
// `@pascal-app/editor` Editor shell didn't fit (its CSS expects a
// full-page layout) and we don't need its editing tools here.
//
// Once the editor extracts its toolbar into a reusable shell component,
// this file can collapse to an import.
import { type AnyNode, type LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Box, Grid2x2, Layers, Layers2, Maximize, Moon, ScanLine, Square, Sun } from 'lucide-react'
import { type ReactNode, useMemo } from 'react'
const levelModes = ['stacked', 'solo', 'exploded', 'manual'] as const
const wallModes = ['up', 'cutaway', 'down'] as const
const levelLabel: Record<(typeof levelModes)[number], string> = {
stacked: 'Stack',
solo: 'Solo',
exploded: 'Exploded',
manual: 'Manual',
}
const wallLabel: Record<(typeof wallModes)[number], string> = {
up: 'Full',
cutaway: 'Cutaway',
down: 'Down',
}
function cycle<T>(list: readonly T[], current: T): T {
const i = list.indexOf(current)
return list[(i + 1) % list.length] ?? list[0]!
}
function ToolButton({
active,
label,
icon,
onClick,
}: {
active?: boolean
label: string
icon: ReactNode
onClick: () => void
}) {
return (
<button
aria-pressed={active}
className={[
'flex h-8 items-center gap-1.5 rounded-md px-2.5 font-medium text-xs transition-colors',
active ? 'bg-white/15 text-white' : 'text-white/65 hover:bg-white/8 hover:text-white/95',
].join(' ')}
onClick={onClick}
title={label}
type="button"
>
{icon}
<span className="hidden sm:inline">{label}</span>
</button>
)
}
export function PreviewToolbar() {
const cameraMode = useViewer((s) => s.cameraMode)
const setCameraMode = useViewer((s) => s.setCameraMode)
const theme = useViewer((s) => s.theme)
const setTheme = useViewer((s) => s.setTheme)
const showGrid = useViewer((s) => s.showGrid)
const setShowGrid = useViewer((s) => s.setShowGrid)
const levelMode = useViewer((s) => s.levelMode)
const setLevelMode = useViewer((s) => s.setLevelMode)
const wallMode = useViewer((s) => s.wallMode)
const setWallMode = useViewer((s) => s.setWallMode)
return (
<div className="flex items-center gap-1 rounded-xl border border-white/10 bg-black/60 p-1 shadow-lg backdrop-blur-md">
<ToolButton
active={cameraMode === 'orthographic'}
icon={
cameraMode === 'perspective' ? (
<Box className="size-3.5" />
) : (
<Square className="size-3.5" />
)
}
label={cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
onClick={() => setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
/>
<span aria-hidden className="mx-0.5 h-5 w-px bg-white/10" />
<ToolButton
active={levelMode !== 'stacked'}
icon={
levelMode === 'solo' ? <Layers2 className="size-3.5" /> : <Layers className="size-3.5" />
}
label={`Levels: ${levelLabel[levelMode]}`}
onClick={() => setLevelMode(cycle(levelModes, levelMode))}
/>
<ToolButton
active={wallMode !== 'up'}
icon={<ScanLine className="size-3.5" />}
label={`Walls: ${wallLabel[wallMode]}`}
onClick={() => setWallMode(cycle(wallModes, wallMode))}
/>
<span aria-hidden className="mx-0.5 h-5 w-px bg-white/10" />
<ToolButton
active={showGrid}
icon={<Grid2x2 className="size-3.5" />}
label="Grid"
onClick={() => setShowGrid(!showGrid)}
/>
<ToolButton
active={theme === 'dark'}
icon={theme === 'dark' ? <Moon className="size-3.5" /> : <Sun className="size-3.5" />}
label={theme === 'dark' ? 'Dark' : 'Light'}
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
/>
</div>
)
}
export function FitSceneButton({ onFit }: { onFit: () => void }) {
return (
<button
className="flex h-8 items-center gap-1.5 rounded-xl border border-white/10 bg-black/60 px-3 font-medium text-white/85 text-xs shadow-lg backdrop-blur-md transition-colors hover:bg-black/70 hover:text-white"
onClick={onFit}
title="Fit scene"
type="button"
>
<Maximize className="size-3.5" />
<span className="hidden sm:inline">Fit</span>
</button>
)
}
/**
* Vertical level picker, top-floor first (matches the way you'd read a
* building section). Highlights `useViewer.selection.levelId` so it
* stays in sync with the editor's own LevelSystem (e.g. when Solo mode
* hides every level except the selected one). Hidden when the scene
* has 0 or 1 levels — no point picking from a list of one.
*/
export function LevelSelector() {
const nodes = useScene((s) => s.nodes)
const selection = useViewer((s) => s.selection)
const setSelection = useViewer((s) => s.setSelection)
const levels = useMemo(() => {
const list = Object.values(nodes as Record<string, AnyNode>).filter(
(n): n is LevelNode => n.type === 'level',
)
// Top floor first.
return list.slice().sort((a, b) => b.level - a.level)
}, [nodes])
if (levels.length <= 1) return null
const selectedId = selection.levelId
return (
<div className="flex flex-col gap-0.5 rounded-xl border border-white/10 bg-black/60 p-1 shadow-lg backdrop-blur-md">
{levels.map((level) => {
const active = level.id === selectedId
return (
<button
aria-pressed={active}
className={[
'flex min-w-[88px] items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left font-medium text-xs transition-colors',
active
? 'bg-white/15 text-white'
: 'text-white/65 hover:bg-white/8 hover:text-white/95',
].join(' ')}
key={level.id}
onClick={() => setSelection({ levelId: level.id })}
type="button"
>
<span className="truncate">{level.name?.trim() || `Level ${level.level}`}</span>
<span className="shrink-0 text-[10px] text-white/40">L{level.level}</span>
</button>
)
})}
</div>
)
}