feat: IFC → Pascal converter (package + app)

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-21 10:45:12 -04:00
co-authored by Claude Opus 4.7
parent 8505d6cdfa
commit 0df51219d0
27 changed files with 150847 additions and 0 deletions
@@ -0,0 +1,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>
)
}