Merge pull request #324 from pascalorg/feat/ifc-converter-app
feat: IFC → Pascal converter (package + app)
This commit is contained in:
commit
488e05da0e
51
apps/ifc-converter/README.md
Normal file
51
apps/ifc-converter/README.md
Normal 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).
|
||||
11
apps/ifc-converter/app/client-bootstrap.tsx
Normal file
11
apps/ifc-converter/app/client-bootstrap.tsx
Normal file
@ -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
apps/ifc-converter/app/globals.css
Normal file
6
apps/ifc-converter/app/globals.css
Normal file
@ -0,0 +1,6 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
18
apps/ifc-converter/app/layout.tsx
Normal file
18
apps/ifc-converter/app/layout.tsx
Normal 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
apps/ifc-converter/app/page.tsx
Normal file
31
apps/ifc-converter/app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
742
apps/ifc-converter/components/IfcConverter.tsx
Normal file
742
apps/ifc-converter/components/IfcConverter.tsx
Normal file
@ -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"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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">
|
||||
{ }
|
||||
</span>
|
||||
<span className="text-sm font-medium">JSON</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
apps/ifc-converter/components/PascalSceneViewer.tsx
Normal file
183
apps/ifc-converter/components/PascalSceneViewer.tsx
Normal file
@ -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>
|
||||
)
|
||||
}
|
||||
191
apps/ifc-converter/components/PreviewToolbar.tsx
Normal file
191
apps/ifc-converter/components/PreviewToolbar.tsx
Normal file
@ -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
apps/ifc-converter/lib/bootstrap.ts
Normal file
24
apps/ifc-converter/lib/bootstrap.ts
Normal 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
apps/ifc-converter/lib/test-files.ts
Normal file
114
apps/ifc-converter/lib/test-files.ts
Normal 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
apps/ifc-converter/next-env.d.ts
vendored
Normal file
6
apps/ifc-converter/next-env.d.ts
vendored
Normal 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
apps/ifc-converter/next.config.ts
Normal file
33
apps/ifc-converter/next.config.ts
Normal 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
apps/ifc-converter/package.json
Normal file
44
apps/ifc-converter/package.json
Normal 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
apps/ifc-converter/postcss.config.mjs
Normal file
5
apps/ifc-converter/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
5
apps/ifc-converter/public/.gitignore
vendored
Normal file
5
apps/ifc-converter/public/.gitignore
vendored
Normal 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
|
||||
22176
apps/ifc-converter/public/test-ifc-files/01-duplex.ifc
Normal file
22176
apps/ifc-converter/public/test-ifc-files/01-duplex.ifc
Normal file
File diff suppressed because it is too large
Load Diff
2894
apps/ifc-converter/public/test-ifc-files/04-ifc-open-house.ifc
Normal file
2894
apps/ifc-converter/public/test-ifc-files/04-ifc-open-house.ifc
Normal file
File diff suppressed because it is too large
Load Diff
74768
apps/ifc-converter/public/test-ifc-files/05-paris-ground-floor.ifc
Normal file
74768
apps/ifc-converter/public/test-ifc-files/05-paris-ground-floor.ifc
Normal file
File diff suppressed because one or more lines are too long
47318
apps/ifc-converter/public/test-ifc-files/10-sample-house.ifc
Normal file
47318
apps/ifc-converter/public/test-ifc-files/10-sample-house.ifc
Normal file
File diff suppressed because one or more lines are too long
89
apps/ifc-converter/public/test-ifc-files/README.md
Normal file
89
apps/ifc-converter/public/test-ifc-files/README.md
Normal file
@ -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
|
||||
56
apps/ifc-converter/scripts/copy-web-ifc-wasm.mjs
Normal file
56
apps/ifc-converter/scripts/copy-web-ifc-wasm.mjs
Normal file
@ -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
apps/ifc-converter/tsconfig.json
Normal file
25
apps/ifc-converter/tsconfig.json
Normal 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
bun.lock
56
bun.lock
@ -62,6 +62,38 @@
|
||||
"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": {
|
||||
"name": "@pascal-app/core",
|
||||
"version": "0.8.0",
|
||||
@ -163,6 +195,20 @@
|
||||
"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": {
|
||||
"name": "@pascal-app/mcp",
|
||||
"version": "0.2.0",
|
||||
@ -476,6 +522,8 @@
|
||||
|
||||
"@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/nodes": ["@pascal-app/nodes@workspace:packages/nodes"],
|
||||
@ -1032,6 +1080,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
@ -1512,6 +1562,8 @@
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
7
packages/ifc-converter/README.md
Normal file
7
packages/ifc-converter/README.md
Normal 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
packages/ifc-converter/package.json
Normal file
43
packages/ifc-converter/package.json
Normal 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"
|
||||
}
|
||||
2079
packages/ifc-converter/src/index.ts
Normal file
2079
packages/ifc-converter/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/ifc-converter/tsconfig.json
Normal file
18
packages/ifc-converter/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user