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
+51
View File
@@ -0,0 +1,51 @@
# IFC → Pascal Converter
A web app that converts IFC building models into Pascal scene-graph JSON and
previews the result in the real `@pascal-app/viewer`. Drop in an `.ifc` file
(or pick a bundled example), inspect what was extracted, and download the
JSON to load into the Pascal editor.
> ## ⚠️ Early alpha
>
> This converter is in **early alpha**. IFC is a sprawling, loosely-followed
> standard and real-world exports vary wildly — so expect rough edges:
> misplaced or missing elements, walls that default to a fixed height when
> their geometry can't be read, items skipped entirely, and element types
> that aren't mapped yet. The output is meant for previewing and iterating,
> not production.
>
> **Contributions very welcome** — if you hit a file that converts badly,
> a sample IFC + a note on what's wrong is hugely helpful, and PRs improving
> the conversion (better geometry extraction, more element types, edge-case
> handling) are exactly what this needs. Jump in. 🙏
## How it works
- **`@pascal-app/ifc-converter`** (`packages/ifc-converter`) — the pure
conversion logic. Parses IFC via [web-ifc](https://github.com/ThatOpen/engine_web-ifc),
maps elements onto Pascal node schemas from `@pascal-app/core`. No DOM, no
React.
- **This app** — the UI: drop zone, example picker, element search/filters,
the 3D preview, and JSON download.
## Develop
```bash
bun dev # from this directory, or `turbo run dev` at the repo root
```
The `web-ifc.wasm` binary is copied into `public/` automatically on
install/dev/build (`scripts/copy-web-ifc-wasm.mjs`). Large example IFCs are
fetched from a public bucket at runtime; the small ones are committed under
`public/test-ifc-files/`. Override the bucket with
`NEXT_PUBLIC_IFC_EXAMPLES_BASE_URL`.
## Known limitations (help wanted)
- Plain `IFCWALL` (Brep/mapped geometry) falls back to a default height — exact
per-wall heights need geometry-AABB extraction.
- Items (furniture, etc.) are skipped — Pascal items require a catalog asset.
- Beams have no Pascal node type yet and are skipped.
- Doors/windows are matched to walls by proximity when the IFC omits fill
relationships; matching isn't perfect.
- Stairs/roofs are placeholders (bounding box / flat polygon in metadata).
@@ -0,0 +1,11 @@
'use client'
// Side-effect import: loads every built-in node kind into the registry
// on the client so the first `<Viewer>` has renderers to dispatch to.
// Mounted from `app/layout.tsx` so every route is covered.
import '../lib/bootstrap'
import type { ReactNode } from 'react'
export function ClientBootstrap({ children }: { children: ReactNode }) {
return children
}
+6
View File
@@ -0,0 +1,6 @@
@import 'tailwindcss';
html,
body {
height: 100%;
}
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react'
import { ClientBootstrap } from './client-bootstrap'
import './globals.css'
export const metadata = {
title: 'IFC → Pascal Converter',
description: 'Convert IFC building models into Pascal scene-graph JSON.',
}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<ClientBootstrap>{children}</ClientBootstrap>
</body>
</html>
)
}
+31
View File
@@ -0,0 +1,31 @@
import IfcConverter from '@/components/IfcConverter'
export default function HomePage() {
return (
<main className="min-h-screen bg-gradient-to-br from-blue-50 to-gray-100 py-12">
<div className="max-w-3xl mx-auto px-6 pb-12 space-y-4">
<h1 className="text-3xl font-bold text-gray-900">IFC Pascal Converter</h1>
<p className="text-gray-600 leading-relaxed">
Upload an IFC building model or pick one of the bundled examples. The converter reads the
IFC geometry, maps it onto Pascal's parametric node types, and returns a scene-graph JSON
you can load into the editor's <em>Load Build</em> dialog.
</p>
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<span className="font-semibold">Early alpha.</span> IFC is a sprawling, loosely-followed
standard and real-world exports vary a lot, so expect rough edges misplaced or missing
elements, default-height walls, skipped items.{' '}
<a
className="font-medium underline decoration-amber-400 underline-offset-2 hover:text-amber-700"
href="https://github.com/pascalorg/editor/apps/ifc-converter"
rel="noopener noreferrer"
target="_blank"
>
Contributions welcome
</a>{' '}
a sample IFC that converts badly, or a PR improving the conversion, both help a lot.
</div>
</div>
<IfcConverter />
</main>
)
}
@@ -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>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
import { builtinPlugin } from '@pascal-app/nodes'
// Mirrors apps/editor/lib/bootstrap.ts — registers every built-in node
// kind synchronously so the registry is populated before the first
// `<Viewer>` mounts. Without this every NodeRenderer resolves to null
// and the preview is empty. HMR-safe via the closure-scoped flag.
let builtinsLoaded = false
export function loadBuiltins(): void {
if (builtinsLoaded) return
builtinsLoaded = true
for (const def of builtinPlugin.nodes ?? []) {
registerNode(def as AnyNodeDefinition)
}
if (typeof console !== 'undefined') {
const kinds = Array.from(nodeRegistry.entries(), ([k]) => k)
console.info(
`[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds)`,
)
}
}
loadBuiltins()
+114
View File
@@ -0,0 +1,114 @@
export interface TestFile {
name: string
label: string
detail: string
description: string
/**
* Served from `examplesBaseUrl` instead of the repo's `public/` folder.
* The large IFC samples (tens of MB each) aren't committed to keep the
* open-source repo lean; they're hosted externally and fetched at
* runtime. Marked entries only appear once a base URL is configured.
*/
remote?: boolean
/** Shown as a caution on the example card (e.g. heavy models that can
* tax the browser when rendered). */
warning?: string
}
// Host serving the large (remote) example IFCs by filename. The big
// samples (tens of MB) aren't committed to keep the repo lean — they
// live in a public, read-only Supabase Storage bucket. Overridable via
// env (NEXT_PUBLIC_ is inlined at build time by Next.js); set it to ''
// to hide the remote examples entirely.
const DEFAULT_EXAMPLES_BASE_URL =
'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/ifc_examples'
export const examplesBaseUrl = (
process.env.NEXT_PUBLIC_IFC_EXAMPLES_BASE_URL ?? DEFAULT_EXAMPLES_BASE_URL
).replace(/\/$/, '')
export const testFiles: TestFile[] = [
{
name: '01-duplex.ifc',
label: 'Duplex Apartment',
detail: '1.2 MB',
description: 'Multi-level apartment from IFC Tools Project',
},
{
name: '02-schependomlaan.ifc',
label: 'Schependomlaan',
detail: '47 MB',
description: 'Dutch apartment complex (buildingSMART)',
remote: true,
warning: 'Very large — may slow down or crash the browser when rendered.',
},
{
name: '03-rac-sample-project.ifc',
label: 'RAC Sample Project',
detail: '43 MB',
description: 'Revit commercial office building',
remote: true,
},
{
name: '04-ifc-open-house.ifc',
label: 'IFC Open House',
detail: '111 KB',
description: 'Small residential house (IFC4)',
},
{
name: '05-paris-ground-floor.ifc',
label: 'Paris Building',
detail: '3.9 MB',
description: '19 rue Marc Antoine Petit, Paris',
},
{
name: '06-sample-castle.ifc',
label: 'Sample Castle',
detail: '47 MB',
description: 'Historic architecture demo model',
remote: true,
warning: 'Very large — may slow down or crash the browser when rendered.',
},
{
name: '07-revit-architectural.ifc',
label: 'Revit Architectural',
detail: '13 MB',
description: 'Autodesk Revit Architecture model',
remote: true,
},
{
name: '08-revit-mep.ifc',
label: 'Revit MEP',
detail: '28 MB',
description: 'Building systems from Revit MEP',
remote: true,
},
{
name: '09-revit-structural.ifc',
label: 'Revit Structural',
detail: '11 MB',
description: 'Structural engineering from Revit',
remote: true,
},
{
name: '10-sample-house.ifc',
label: 'Sample House',
detail: '2.2 MB',
description: 'Complete residential house model',
},
]
/** Resolve where to fetch a given example from. */
export function exampleFileUrl(file: TestFile): string {
return file.remote ? `${examplesBaseUrl}/${file.name}` : `/test-ifc-files/${file.name}`
}
/**
* Examples to show in the picker: the committed local ones always, plus
* the remote ones once a base URL is configured (so a fresh clone with
* no env doesn't surface examples that would 404).
*/
export function availableTestFiles(): TestFile[] {
if (examplesBaseUrl) return testFiles
return testFiles.filter((f) => !f.remote)
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+33
View File
@@ -0,0 +1,33 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
logging: {
browserToTerminal: true,
},
transpilePackages: [
'three',
'@pascal-app/core',
'@pascal-app/ifc-converter',
'@pascal-app/nodes',
'@pascal-app/viewer',
],
turbopack: {
resolveAlias: {
react: './node_modules/react',
three: './node_modules/three',
'@react-three/fiber': './node_modules/@react-three/fiber',
'@react-three/drei': './node_modules/@react-three/drei',
},
},
// web-ifc ships a WASM module. Serving it from the same origin as the
// app keeps `WebAssembly.instantiateStreaming` happy with strict CSP /
// module-MIME-type checks. The standalone repo copied the file into
// public/; we do the same on first dev/build via a postinstall step
// (see scripts/copy-web-ifc-wasm.mjs).
webpack: (config) => {
config.experiments = { ...config.experiments, asyncWebAssembly: true }
return config
},
}
export default nextConfig
+44
View File
@@ -0,0 +1,44 @@
{
"name": "ifc-converter-app",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"predev": "node scripts/copy-web-ifc-wasm.mjs",
"prebuild": "node scripts/copy-web-ifc-wasm.mjs",
"postinstall": "node scripts/copy-web-ifc-wasm.mjs",
"dev": "next dev --port 3003",
"build": "next build",
"start": "next start",
"lint": "biome lint",
"check-types": "next typegen && tsc --noEmit"
},
"dependencies": {
"@pascal-app/core": "*",
"@pascal-app/ifc-converter": "*",
"@pascal-app/nodes": "*",
"@pascal-app/viewer": "*",
"lucide-react": "^1.7.0",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/postcss": "^4.2.1",
"clsx": "^2.1.1",
"next": "16.2.1",
"postcss": "^8.5.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"three": "^0.184.0",
"web-ifc": "^0.0.77",
"zod": "^4.3.5"
},
"devDependencies": {
"@pascal/typescript-config": "*",
"@types/node": "^22.19.12",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.184.0",
"typescript": "6.0.2"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
+5
View File
@@ -0,0 +1,5 @@
# Copied from node_modules/web-ifc/ by scripts/copy-web-ifc-wasm.mjs
# (runs on postinstall / predev / prebuild).
web-ifc.wasm
web-ifc-mt.wasm
web-ifc-node.wasm
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,89 @@
# IFC Test Files
This directory contains 10 real-world IFC (Industry Foundation Classes) files for testing the IFC to Pascal converter. All files are from open-source repositories and represent actual BIM models exported from professional software like Revit, ArchiCAD, and other authoring tools.
## Files
### 01-duplex.ifc (1.2 MB)
- **Source**: [xeokit-sdk](https://github.com/xeokit/xeokit-sdk/tree/master/assets/models/ifc)
- **Description**: Duplex apartment model (IFC 2x3)
- **Created**: 2015-11-12
- **Software**: IFC Tools Project - IFC2x3 Java Toolbox
- **Use Case**: Residential building, multi-level apartment
### 02-schependomlaan.ifc (47 MB)
- **Source**: [xeokit-sdk](https://github.com/xeokit/xeokit-sdk/tree/master/assets/models/ifc) / buildingSMART Sample Files
- **Description**: "10 Appartementen Schependomlaan" - Large Dutch apartment complex
- **Use Case**: Large-scale residential building, complex spatial hierarchy
- **Note**: One of the most widely used IFC test files in the community
### 03-rac-sample-project.ifc (43 MB)
- **Source**: [xeokit-sdk](https://github.com/xeokit/xeokit-sdk/tree/master/assets/models/ifc)
- **Description**: RAC (Revit Architecture) Advanced Sample Project
- **Software**: Autodesk Revit
- **Use Case**: Large commercial/office building with detailed architectural elements
### 04-ifc-open-house.ifc (111 KB)
- **Source**: [xeokit-sdk](https://github.com/xeokit/xeokit-sdk/tree/master/assets/models/ifc)
- **Description**: IFC Open House (IFC4 schema)
- **Use Case**: Small residential building, IFC4 format example
### 05-paris-ground-floor.ifc (3.9 MB)
- **Source**: [xeokit-sdk](https://github.com/xeokit/xeokit-sdk/tree/master/assets/models/ifc)
- **Description**: 19 rue Marc Antoine Petit - Ground floor, Paris building
- **Use Case**: European architectural model, single floor representation
### 06-sample-castle.ifc (47 MB)
- **Source**: [youshengCode/IfcSampleFiles](https://github.com/youshengCode/IfcSampleFiles)
- **Description**: Sample Castle (IFC 2x3)
- **Use Case**: Historic/complex architectural geometry, demonstration model
### 07-revit-architectural.ifc (13 MB)
- **Source**: [youshengCode/IfcSampleFiles](https://github.com/youshengCode/IfcSampleFiles)
- **Description**: Revit Architectural model (IFC4)
- **Software**: Autodesk Revit
- **Use Case**: Architectural discipline model from Revit
### 08-revit-mep.ifc (28 MB)
- **Source**: [youshengCode/IfcSampleFiles](https://github.com/youshengCode/IfcSampleFiles)
- **Description**: Revit MEP (Mechanical, Electrical, Plumbing) model (IFC4)
- **Software**: Autodesk Revit MEP
- **Use Case**: Building systems - HVAC, electrical, plumbing elements
### 09-revit-structural.ifc (11 MB)
- **Source**: [youshengCode/IfcSampleFiles](https://github.com/youshengCode/IfcSampleFiles)
- **Description**: Revit Structural model (IFC4)
- **Software**: Autodesk Revit Structure
- **Use Case**: Structural engineering discipline - beams, columns, foundations
### 10-sample-house.ifc (2.2 MB)
- **Source**: [youshengCode/IfcSampleFiles](https://github.com/youshengCode/IfcSampleFiles)
- **Description**: Sample House (IFC4)
- **Use Case**: Residential building, complete house model with multiple building elements
## Schema Versions
- **IFC 2x3**: Files 01, 02, 06 (widely used, mature standard)
- **IFC4**: Files 03, 04, 07, 08, 09, 10 (newer standard with enhanced capabilities)
## Testing Coverage
These files cover:
- **Building Types**: Residential (apartments, houses), Commercial (office buildings), Historic (castle)
- **Disciplines**: Architecture, MEP (mechanical/electrical/plumbing), Structural
- **Software Sources**: Autodesk Revit, IFC Tools, various BIM authoring tools
- **Complexity**: From simple houses (111 KB) to large complexes (47 MB)
- **Geographic Origins**: European (Netherlands, France) and International models
## License
All files are from open-source repositories and are used for testing purposes. Original licenses apply:
- xeokit-sdk: [GPL-3.0 License](https://github.com/xeokit/xeokit-sdk/blob/master/LICENSE)
- youshengCode/IfcSampleFiles: Public repository for testing use
- buildingSMART samples: Community-provided test files
## References
- [buildingSMART International](https://www.buildingsmart.org/) - IFC standard organization
- [xeokit](https://xeokit.io/) - Open-source WebGL-based 3D BIM viewer
- [IFC.js](https://ifcjs.github.io/info/) - JavaScript library for IFC file processing
@@ -0,0 +1,56 @@
#!/usr/bin/env node
// web-ifc ships its WASM binaries inside node_modules. Next.js needs to
// serve them at the app root URL (the library hardcodes `/web-ifc.wasm`
// when no `wasmPath` override is set), so copy the three blobs into
// `public/` so they're served from /web-ifc*.wasm.
//
// Run on `postinstall` and again on `predev` / `prebuild` so a forgotten
// install step doesn't leave the dev server with a stale or missing
// copy. Idempotent: skips files that already match by size.
import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs'
import { join, resolve } from 'node:path'
// web-ifc's package.json doesn't expose subpath exports, so we can't use
// require.resolve('web-ifc/package.json'). Walk up the script directory
// looking for the package folder inside any node_modules along the way.
function findWebIfcDir(startDir) {
let dir = startDir
while (dir && dir !== '/') {
const candidate = join(dir, 'node_modules', 'web-ifc')
if (existsSync(join(candidate, 'web-ifc.wasm'))) return candidate
dir = resolve(dir, '..')
}
return null
}
const webIfcDir = findWebIfcDir(import.meta.dirname)
if (!webIfcDir) {
console.warn('[ifc-converter] web-ifc package not found — wasm copy skipped.')
process.exit(0)
}
const publicDir = join(import.meta.dirname, '..', 'public')
mkdirSync(publicDir, { recursive: true })
const files = ['web-ifc.wasm', 'web-ifc-mt.wasm', 'web-ifc-node.wasm']
for (const name of files) {
const src = join(webIfcDir, name)
const dst = join(publicDir, name)
try {
const srcSize = statSync(src).size
let dstSize = 0
try {
dstSize = statSync(dst).size
} catch {
/* not present yet */
}
if (srcSize === dstSize) {
continue
}
copyFileSync(src, dst)
console.log(`[ifc-converter] copied ${name} (${(srcSize / 1024).toFixed(0)} KB)`)
} catch (err) {
console.warn(`[ifc-converter] could not copy ${name}:`, err.message)
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"extends": "@pascal/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
"next.config.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"],
"references": [
{ "path": "../../packages/core" },
{ "path": "../../packages/ifc-converter" }
]
}
+56
View File
@@ -62,6 +62,38 @@
"typescript": "6.0.2", "typescript": "6.0.2",
}, },
}, },
"apps/ifc-converter": {
"name": "ifc-converter-app",
"version": "0.1.0",
"dependencies": {
"@pascal-app/core": "*",
"@pascal-app/ifc-converter": "*",
"@pascal-app/nodes": "*",
"@pascal-app/viewer": "*",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/postcss": "^4.2.1",
"clsx": "^2.1.1",
"lucide-react": "^1.7.0",
"next": "16.2.1",
"postcss": "^8.5.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"three": "^0.184.0",
"web-ifc": "^0.0.77",
"zod": "^4.3.5",
},
"devDependencies": {
"@pascal/typescript-config": "*",
"@types/node": "^22.19.12",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.184.0",
"typescript": "6.0.2",
},
},
"packages/core": { "packages/core": {
"name": "@pascal-app/core", "name": "@pascal-app/core",
"version": "0.8.0", "version": "0.8.0",
@@ -163,6 +195,20 @@
"typescript-eslint": "^8.50.0", "typescript-eslint": "^8.50.0",
}, },
}, },
"packages/ifc-converter": {
"name": "@pascal-app/ifc-converter",
"version": "0.1.0",
"dependencies": {
"@pascal-app/core": "*",
"nanoid": "^5.1.6",
"web-ifc": "^0.0.77",
},
"devDependencies": {
"@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"typescript": "6.0.2",
},
},
"packages/mcp": { "packages/mcp": {
"name": "@pascal-app/mcp", "name": "@pascal-app/mcp",
"version": "0.2.0", "version": "0.2.0",
@@ -476,6 +522,8 @@
"@pascal-app/editor": ["@pascal-app/editor@workspace:packages/editor"], "@pascal-app/editor": ["@pascal-app/editor@workspace:packages/editor"],
"@pascal-app/ifc-converter": ["@pascal-app/ifc-converter@workspace:packages/ifc-converter"],
"@pascal-app/mcp": ["@pascal-app/mcp@workspace:packages/mcp"], "@pascal-app/mcp": ["@pascal-app/mcp@workspace:packages/mcp"],
"@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"],
@@ -1032,6 +1080,8 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ifc-converter-app": ["ifc-converter-app@workspace:apps/ifc-converter"],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
@@ -1512,6 +1562,8 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"web-ifc": ["web-ifc@0.0.77", "", {}, "sha512-VzQ0W/Iiqbidxn1ECUvz6qJ6p2sXBVNcOsUOBCETzy77psAH6yFLKQm74aXabkx3JH4OvFVHe8k1qS6+Z2zl1w=="],
"webgl-constants": ["webgl-constants@1.1.1", "", {}, "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="], "webgl-constants": ["webgl-constants@1.1.1", "", {}, "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="],
"webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="], "webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="],
@@ -1624,6 +1676,8 @@
"glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"ifc-converter-app/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
@@ -1658,6 +1712,8 @@
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
"ifc-converter-app/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"next/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "next/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+7
View File
@@ -0,0 +1,7 @@
# @pascal-app/ifc-converter
Pure conversion logic for IFC → Pascal scene graphs. Takes a `Uint8Array` of
IFC bytes, returns `{ nodes, rootNodeIds, stats }` shaped against
`@pascal-app/core` schemas.
No DOM, no React. The UI lives in `apps/ifc-converter`.
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@pascal-app/ifc-converter",
"version": "0.1.0",
"description": "IFC → Pascal scene-graph conversion. Pure logic — no DOM, no React.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc --build",
"dev": "tsc --build --watch",
"test": "bun test",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@pascal-app/core": "*",
"nanoid": "^5.1.6",
"web-ifc": "^0.0.77"
},
"devDependencies": {
"@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"typescript": "6.0.2"
},
"repository": {
"type": "git",
"url": "https://github.com/pascalorg/editor.git",
"directory": "packages/ifc-converter"
},
"license": "MIT",
"homepage": "https://github.com/pascalorg/editor/tree/main/packages/ifc-converter#readme",
"bugs": "https://github.com/pascalorg/editor/issues"
}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@pascal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"noEmit": false,
"composite": true,
"incremental": true,
"types": ["bun"],
// Migrated from a repo without `noUncheckedIndexedAccess`. The
// existing index access patterns (Mat4 / IFC entity arrays) are
// surrounded by length checks the type system can't follow. Re-enable
// when the file is split into per-element modules.
"noUncheckedIndexedAccess": false
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}