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>
192 lines
6.2 KiB
TypeScript
192 lines
6.2 KiB
TypeScript
'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>
|
|
)
|
|
}
|