Two converter accuracy refinements: - Recover plain IFCWALL height/thickness from geometry. These carry Brep/mapped geometry that getBodyExtrusionData can't read, so measure the mesh in the wall's own axis frame (along/across/vertical) — a rotation-invariant projection rather than a world-space AABB (which conflated a rotated wall's length and thickness). Gate on the measured length matching the known wall length, and free web-ifc geometry handles via try/finally on every path. - Type columns from their IFC swept profile: IfcCircleProfileDef → round + radius, IfcRectangleProfileDef → rectangular + width/depth, falling back to the width/depth ratio when the profile type is unknown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2080 lines
72 KiB
TypeScript
2080 lines
72 KiB
TypeScript
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
BuildingNode,
|
|
ColumnNode,
|
|
DEFAULT_WALL_HEIGHT,
|
|
DEFAULT_WALL_THICKNESS,
|
|
DoorNode,
|
|
LevelNode,
|
|
RoofNode,
|
|
SiteNode,
|
|
SlabNode,
|
|
StairNode,
|
|
WallNode,
|
|
WindowNode,
|
|
} from '@pascal-app/core'
|
|
import { customAlphabet } from 'nanoid'
|
|
import * as WebIFC from 'web-ifc'
|
|
|
|
export type PascalNode = AnyNode
|
|
|
|
export interface PascalSceneGraph {
|
|
nodes: Record<AnyNodeId, AnyNode>
|
|
rootNodeIds: AnyNodeId[]
|
|
collections?: Record<string, unknown>
|
|
}
|
|
|
|
// Pascal's BaseNode.metadata is typed as `JSONType` (z.json()) — a loose
|
|
// JSON value. The converter writes a fixed shape; this typed accessor
|
|
// keeps dot-access ergonomics without spraying `as any` through the
|
|
// post-processing loops. Read-side only — writes still inline literals.
|
|
type ConverterMetadata = {
|
|
ifcType?: string
|
|
expressID?: number
|
|
globalId?: string
|
|
levelId?: string
|
|
material?: string
|
|
materialLayers?: { name: string; thickness?: number }[]
|
|
typeName?: string
|
|
properties?: Record<string, Record<string, unknown>>
|
|
[key: string]: unknown
|
|
}
|
|
|
|
function meta(node: { metadata?: unknown } | null | undefined): ConverterMetadata {
|
|
return (node?.metadata ?? {}) as ConverterMetadata
|
|
}
|
|
|
|
// Pascal's `BaseNode.metadata` is `z.json()` — a recursive JSON value
|
|
// type that doesn't accept `undefined` (JSON has `null`, not undefined).
|
|
// The converter pulls many fields from optional IFC properties that
|
|
// often return `undefined`; stripping them at the boundary keeps the
|
|
// schemas happy without spraying `?? null` through every assignment.
|
|
function buildMetadata(input: Record<string, unknown>): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {}
|
|
for (const [key, value] of Object.entries(input)) {
|
|
if (value === undefined) continue
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Wraps a Zod `.parse()` call and surfaces a single-line readable error
|
|
// instead of the framework's JSON-stringified issue list. The first
|
|
// issue is usually the actionable one; we mention the count if there
|
|
// are more so the user knows there's a deeper problem.
|
|
function tryParse<T>(schema: { parse: (input: unknown) => T }, kind: string, input: unknown): T {
|
|
try {
|
|
return schema.parse(input)
|
|
} catch (err) {
|
|
const issues = (err as { issues?: { path?: (string | number)[]; message?: string }[] }).issues
|
|
if (Array.isArray(issues) && issues.length > 0) {
|
|
const first = issues[0]
|
|
const path =
|
|
first && Array.isArray(first.path) && first.path.length > 0
|
|
? ` at "${first.path.join('.')}"`
|
|
: ''
|
|
const more = issues.length > 1 ? ` (+${issues.length - 1} more)` : ''
|
|
throw new Error(
|
|
`Could not build ${kind} node${path}: ${first?.message ?? 'schema mismatch'}${more}`,
|
|
)
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16)
|
|
|
|
function generateId<T extends string>(prefix: T): `${T}_${string}` {
|
|
return `${prefix}_${nanoid()}` as `${T}_${string}`
|
|
}
|
|
|
|
// --- Unit detection ---
|
|
|
|
function getLengthUnitFactor(ifcApi: WebIFC.IfcAPI, modelID: number): number {
|
|
const prefixFactors: Record<string, number> = {
|
|
EXA: 1e18,
|
|
PETA: 1e15,
|
|
TERA: 1e12,
|
|
GIGA: 1e9,
|
|
MEGA: 1e6,
|
|
KILO: 1e3,
|
|
HECTO: 1e2,
|
|
DECA: 1e1,
|
|
DECI: 1e-1,
|
|
CENTI: 1e-2,
|
|
MILLI: 1e-3,
|
|
MICRO: 1e-6,
|
|
NANO: 1e-9,
|
|
PICO: 1e-12,
|
|
FEMTO: 1e-15,
|
|
ATTO: 1e-18,
|
|
}
|
|
|
|
try {
|
|
const projects = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCPROJECT)
|
|
if (projects.size() === 0) return 1
|
|
|
|
const proj = ifcApi.GetLine(modelID, projects.get(0))
|
|
if (!proj.UnitsInContext?.value) return 1
|
|
|
|
const unitAssign = ifcApi.GetLine(modelID, proj.UnitsInContext.value)
|
|
if (!unitAssign.Units) return 1
|
|
|
|
for (const unitRef of unitAssign.Units) {
|
|
const unit = ifcApi.GetLine(modelID, unitRef.value)
|
|
if (unit.UnitType?.value !== 'LENGTHUNIT') continue
|
|
|
|
if (unit.Name?.value === 'METRE' || unit.Name?.value === 'METER') {
|
|
const prefix = unit.Prefix?.value as string | undefined
|
|
return prefix ? (prefixFactors[prefix] ?? 1) : 1
|
|
}
|
|
|
|
if (unit.Name?.value === 'FOOT' || unit.Name?.value === 'FEET') {
|
|
return 0.3048
|
|
}
|
|
if (unit.Name?.value === 'INCH') {
|
|
return 0.0254
|
|
}
|
|
|
|
// ConversionBasedUnit — read the conversion factor
|
|
if (unit.ConversionFactor?.value) {
|
|
const factor = ifcApi.GetLine(modelID, unit.ConversionFactor.value)
|
|
if (factor.ValueComponent?.value) {
|
|
return factor.ValueComponent.value
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
|
|
return 1
|
|
}
|
|
|
|
// --- 4x4 matrix math ---
|
|
|
|
type Mat4 = number[]
|
|
|
|
function identity(): Mat4 {
|
|
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
|
|
}
|
|
|
|
function multiply(a: Mat4, b: Mat4): Mat4 {
|
|
const r = new Array(16).fill(0)
|
|
for (let row = 0; row < 4; row++) {
|
|
for (let col = 0; col < 4; col++) {
|
|
for (let k = 0; k < 4; k++) {
|
|
r[row * 4 + col] += a[row * 4 + k] * b[k * 4 + col]
|
|
}
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
function transformPoint3(m: Mat4, p: number[]): number[] {
|
|
return [
|
|
m[0] * p[0] + m[1] * p[1] + m[2] * p[2] + m[3],
|
|
m[4] * p[0] + m[5] * p[1] + m[6] * p[2] + m[7],
|
|
m[8] * p[0] + m[9] * p[1] + m[10] * p[2] + m[11],
|
|
]
|
|
}
|
|
|
|
function buildAxis2Placement3DMatrix(
|
|
ifcApi: WebIFC.IfcAPI,
|
|
modelID: number,
|
|
axis2Id: number,
|
|
): Mat4 {
|
|
const axis2 = ifcApi.GetLine(modelID, axis2Id)
|
|
|
|
const num = (v: any): number | undefined => {
|
|
if (v == null) return undefined
|
|
if (typeof v === 'number') return v
|
|
if (v.value != null) return v.value
|
|
return undefined
|
|
}
|
|
|
|
let ox = 0,
|
|
oy = 0,
|
|
oz = 0
|
|
if (axis2.Location?.value) {
|
|
const loc = ifcApi.GetLine(modelID, axis2.Location.value)
|
|
const coords = loc.Coordinates.map((c: any) => num(c))
|
|
ox = coords[0] ?? 0
|
|
oy = coords[1] ?? 0
|
|
oz = coords[2] ?? 0
|
|
}
|
|
|
|
// Z-axis (Axis direction) — default (0,0,1)
|
|
let zx = 0,
|
|
zy = 0,
|
|
zz = 1
|
|
if (axis2.Axis?.value) {
|
|
const ax = ifcApi.GetLine(modelID, axis2.Axis.value)
|
|
const d = ax.DirectionRatios.map((c: any) => num(c))
|
|
if (d[0] != null) {
|
|
zx = d[0]
|
|
zy = d[1] ?? 0
|
|
zz = d[2] ?? 0
|
|
}
|
|
}
|
|
|
|
// X-axis (RefDirection) — default (1,0,0)
|
|
let xx = 1,
|
|
xy = 0,
|
|
xz = 0
|
|
if (axis2.RefDirection?.value) {
|
|
const rd = ifcApi.GetLine(modelID, axis2.RefDirection.value)
|
|
const d = rd.DirectionRatios.map((c: any) => num(c))
|
|
if (d[0] != null) {
|
|
xx = d[0]
|
|
xy = d[1] ?? 0
|
|
xz = d[2] ?? 0
|
|
}
|
|
}
|
|
|
|
// Normalize Z
|
|
const zLen = Math.sqrt(zx * zx + zy * zy + zz * zz) || 1
|
|
zx /= zLen
|
|
zy /= zLen
|
|
zz /= zLen
|
|
|
|
// Y = Z cross X, then normalize
|
|
let yx = zy * xz - zz * xy
|
|
let yy = zz * xx - zx * xz
|
|
let yz = zx * xy - zy * xx
|
|
const yLen = Math.sqrt(yx * yx + yy * yy + yz * yz) || 1
|
|
yx /= yLen
|
|
yy /= yLen
|
|
yz /= yLen
|
|
|
|
// Recompute X = Y cross Z to ensure orthonormal
|
|
xx = yy * zz - yz * zy
|
|
xy = yz * zx - yx * zz
|
|
xz = yx * zy - yy * zx
|
|
|
|
// Row-major 4x4
|
|
return [xx, yx, zx, ox, xy, yy, zy, oy, xz, yz, zz, oz, 0, 0, 0, 1]
|
|
}
|
|
|
|
// --- Placement chain resolver ---
|
|
|
|
function resolveWorldTransform(ifcApi: WebIFC.IfcAPI, modelID: number, placementId: number): Mat4 {
|
|
const chain: number[] = []
|
|
let current: number | null = placementId
|
|
|
|
while (current) {
|
|
const placement = ifcApi.GetLine(modelID, current)
|
|
if (placement.RelativePlacement?.value) {
|
|
chain.push(placement.RelativePlacement.value)
|
|
}
|
|
current = placement.PlacementRelTo?.value ?? null
|
|
}
|
|
|
|
// Multiply from root to leaf
|
|
let result = identity()
|
|
for (let i = chain.length - 1; i >= 0; i--) {
|
|
const mat = buildAxis2Placement3DMatrix(ifcApi, modelID, chain[i])
|
|
result = multiply(result, mat)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// --- Geometry extraction helpers ---
|
|
|
|
function getAxisPolyline(ifcApi: WebIFC.IfcAPI, modelID: number, element: any): number[][] | null {
|
|
try {
|
|
if (!element.Representation?.value) return null
|
|
const prodRep = ifcApi.GetLine(modelID, element.Representation.value)
|
|
for (const repRef of prodRep.Representations) {
|
|
const rep = ifcApi.GetLine(modelID, repRef.value)
|
|
if (rep.RepresentationIdentifier?.value !== 'Axis') continue
|
|
for (const itemRef of rep.Items) {
|
|
const item = ifcApi.GetLine(modelID, itemRef.value)
|
|
|
|
// IFCPOLYLINE — Points is an array of CartesianPoint references
|
|
if (Array.isArray(item.Points)) {
|
|
const points: number[][] = []
|
|
for (const ptRef of item.Points) {
|
|
const pt = ifcApi.GetLine(modelID, ptRef.value)
|
|
const coords = pt.Coordinates.map((c: any) =>
|
|
typeof c === 'number' ? c : (c?.value ?? 0),
|
|
)
|
|
points.push([coords[0] ?? 0, coords[1] ?? 0, coords[2] ?? 0])
|
|
}
|
|
if (points.length >= 2) return points
|
|
}
|
|
|
|
// IFCINDEXEDPOLYCURVE — Points is a reference to a point list entity
|
|
if (item.Points?.value && !Array.isArray(item.Points)) {
|
|
const ptList = ifcApi.GetLine(modelID, item.Points.value)
|
|
if (ptList.CoordList) {
|
|
const points: number[][] = []
|
|
for (const coords of ptList.CoordList) {
|
|
const c = Array.isArray(coords)
|
|
? coords.map((v: any) => (typeof v === 'number' ? v : (v?.value ?? 0)))
|
|
: []
|
|
points.push([c[0] ?? 0, c[1] ?? 0, c[2] ?? 0])
|
|
}
|
|
if (points.length >= 2) return points
|
|
}
|
|
}
|
|
|
|
// IFCGEOMETRICSET — unwrap to find an inner curve
|
|
if (item.Elements) {
|
|
for (const elemRef of item.Elements) {
|
|
const inner = ifcApi.GetLine(modelID, elemRef.value)
|
|
if (inner.Points?.value) {
|
|
const ptList = ifcApi.GetLine(modelID, inner.Points.value)
|
|
if (ptList.CoordList) {
|
|
const points: number[][] = []
|
|
for (const coords of ptList.CoordList) {
|
|
const c = Array.isArray(coords)
|
|
? coords.map((v: any) => (typeof v === 'number' ? v : (v?.value ?? 0)))
|
|
: []
|
|
points.push([c[0] ?? 0, c[1] ?? 0, c[2] ?? 0])
|
|
}
|
|
if (points.length >= 2) return points
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return null
|
|
}
|
|
|
|
type ExtrusionData = {
|
|
depth: number | null
|
|
xDim: number | null
|
|
yDim: number | null
|
|
profilePoints: number[][] | null
|
|
// Detected swept-area profile shape (model units, pre-unitFactor).
|
|
// 'round' carries `radius`; 'rectangular' carries xDim/yDim.
|
|
profileShape: 'round' | 'rectangular' | null
|
|
radius: number | null
|
|
}
|
|
|
|
function extractFromExtrusionItem(
|
|
ifcApi: WebIFC.IfcAPI,
|
|
modelID: number,
|
|
item: any,
|
|
result: ExtrusionData,
|
|
): boolean {
|
|
// Unwrap BooleanClippingResult → follow FirstOperand chain to find extrusion
|
|
let current = item
|
|
for (let guard = 0; guard < 10; guard++) {
|
|
if (current.Depth?.value) break
|
|
if (current.FirstOperand?.value) {
|
|
current = ifcApi.GetLine(modelID, current.FirstOperand.value)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
if (current.Depth?.value) {
|
|
result.depth = current.Depth.value
|
|
}
|
|
|
|
if (current.SweptArea?.value) {
|
|
const profile = ifcApi.GetLine(modelID, current.SweptArea.value)
|
|
|
|
// Detect the profile shape by the fields present rather than the IFC
|
|
// type id — robust across web-ifc versions. IfcCircleProfileDef has a
|
|
// Radius; IfcRectangleProfileDef has XDim/YDim.
|
|
if (profile.Radius?.value != null) {
|
|
result.profileShape = 'round'
|
|
result.radius = profile.Radius.value
|
|
}
|
|
|
|
if (profile.XDim?.value) {
|
|
result.xDim = profile.XDim.value
|
|
result.yDim = profile.YDim?.value ?? null
|
|
if (result.profileShape === null) result.profileShape = 'rectangular'
|
|
}
|
|
|
|
// Extract profile points — OuterCurve for ArbitraryClosedProfileDef
|
|
const curveRef = profile.OuterCurve?.value
|
|
if (curveRef) {
|
|
const curve = ifcApi.GetLine(modelID, curveRef)
|
|
if (curve.Points) {
|
|
const pts: number[][] = []
|
|
// IFCPOLYLINE — Points is array of CartesianPoint refs
|
|
if (
|
|
Array.isArray(curve.Points) &&
|
|
curve.Points.length > 0 &&
|
|
curve.Points[0]?.value != null
|
|
) {
|
|
for (const ptRef of curve.Points) {
|
|
const pt = ifcApi.GetLine(modelID, ptRef.value)
|
|
const coords = pt.Coordinates.map((c: any) =>
|
|
typeof c === 'number' ? c : (c?.value ?? 0),
|
|
)
|
|
pts.push([coords[0] ?? 0, coords[1] ?? 0])
|
|
}
|
|
}
|
|
// IFCINDEXEDPOLYCURVE — Points is a reference to a point list
|
|
else if (curve.Points?.value) {
|
|
const ptList = ifcApi.GetLine(modelID, curve.Points.value)
|
|
if (ptList.CoordList) {
|
|
for (const coords of ptList.CoordList) {
|
|
const c = Array.isArray(coords)
|
|
? coords.map((v: any) => (typeof v === 'number' ? v : (v?.value ?? 0)))
|
|
: []
|
|
pts.push([c[0] ?? 0, c[1] ?? 0])
|
|
}
|
|
}
|
|
}
|
|
if (pts.length >= 3) result.profilePoints = pts
|
|
}
|
|
}
|
|
}
|
|
|
|
return result.depth !== null
|
|
}
|
|
|
|
function getBodyExtrusionData(ifcApi: WebIFC.IfcAPI, modelID: number, element: any): ExtrusionData {
|
|
const result: ExtrusionData = {
|
|
depth: null,
|
|
xDim: null,
|
|
yDim: null,
|
|
profilePoints: null,
|
|
profileShape: null,
|
|
radius: null,
|
|
}
|
|
try {
|
|
if (!element.Representation?.value) return result
|
|
const prodRep = ifcApi.GetLine(modelID, element.Representation.value)
|
|
for (const repRef of prodRep.Representations) {
|
|
const rep = ifcApi.GetLine(modelID, repRef.value)
|
|
if (rep.RepresentationIdentifier?.value !== 'Body') continue
|
|
|
|
for (const itemRef of rep.Items) {
|
|
const item = ifcApi.GetLine(modelID, itemRef.value)
|
|
|
|
// Direct extrusion or BooleanClippingResult
|
|
if (extractFromExtrusionItem(ifcApi, modelID, item, result)) return result
|
|
|
|
// MappedRepresentation → unwrap to inner items
|
|
if (item.MappingSource?.value) {
|
|
const src = ifcApi.GetLine(modelID, item.MappingSource.value)
|
|
if (src.MappedRepresentation?.value) {
|
|
const mapped = ifcApi.GetLine(modelID, src.MappedRepresentation.value)
|
|
if (mapped.Items) {
|
|
for (const mItemRef of mapped.Items) {
|
|
const mItem = ifcApi.GetLine(modelID, mItemRef.value)
|
|
if (extractFromExtrusionItem(ifcApi, modelID, mItem, result)) return result
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return result
|
|
}
|
|
|
|
function findExtrusionPosition(ifcApi: WebIFC.IfcAPI, modelID: number, item: any): Mat4 | null {
|
|
let current = item
|
|
for (let guard = 0; guard < 10; guard++) {
|
|
if (current.Position?.value) {
|
|
return buildAxis2Placement3DMatrix(ifcApi, modelID, current.Position.value)
|
|
}
|
|
if (current.FirstOperand?.value) {
|
|
current = ifcApi.GetLine(modelID, current.FirstOperand.value)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Mesh extents of an element measured in the WALL'S OWN frame: along the
|
|
// wall axis (length), horizontally perpendicular to it (thickness), and
|
|
// vertical (height). Used to recover height/thickness for plain IFCWALL
|
|
// whose Brep/mapped geometry getBodyExtrusionData can't read.
|
|
//
|
|
// Each vertex is transformed to world space (flatTransformation) and
|
|
// then projected onto the known wall-axis direction — NOT read as a raw
|
|
// world AABB. A world AABB conflates length and thickness for any wall
|
|
// the placement rotates (a 37°-rotated 0.2m wall would read ~1.9m
|
|
// thick); projecting onto the actual axis is rotation-invariant.
|
|
// (axisX, axisY) is the unit wall direction in the converter's
|
|
// horizontal frame, which is parallel to web-ifc world XY — both are IFC
|
|
// world coords, differing only by origin/scale, which cancel in extents.
|
|
// Returns extents in the geometry's native units (caller resolves
|
|
// scale), or null on any failure.
|
|
function measureWallLocalExtents(
|
|
ifcApi: WebIFC.IfcAPI,
|
|
modelID: number,
|
|
expressID: number,
|
|
axisX: number,
|
|
axisY: number,
|
|
): { along: number; across: number; vertical: number } | null {
|
|
const perpX = -axisY
|
|
const perpY = axisX
|
|
let mesh: { geometries: { size: () => number; get: (i: number) => unknown }; delete?: () => void }
|
|
try {
|
|
mesh = ifcApi.GetFlatMesh(modelID, expressID) as never
|
|
} catch {
|
|
return null
|
|
}
|
|
try {
|
|
const geoms = mesh.geometries
|
|
let minA = Number.POSITIVE_INFINITY
|
|
let maxA = Number.NEGATIVE_INFINITY
|
|
let minP = Number.POSITIVE_INFINITY
|
|
let maxP = Number.NEGATIVE_INFINITY
|
|
let minV = Number.POSITIVE_INFINITY
|
|
let maxV = Number.NEGATIVE_INFINITY
|
|
let any = false
|
|
for (let g = 0; g < geoms.size(); g++) {
|
|
const pg = geoms.get(g) as { flatTransformation: number[]; geometryExpressID: number }
|
|
const m = pg.flatTransformation
|
|
const geo = ifcApi.GetGeometry(modelID, pg.geometryExpressID)
|
|
try {
|
|
const verts = ifcApi.GetVertexArray(geo.GetVertexData(), geo.GetVertexDataSize())
|
|
// 6 floats per vertex: local position xyz + normal xyz.
|
|
for (let v = 0; v + 2 < verts.length; v += 6) {
|
|
const x = verts[v]
|
|
const y = verts[v + 1]
|
|
const z = verts[v + 2]
|
|
// flatTransformation is column-major 4x4 → world position.
|
|
const wx = m[0] * x + m[4] * y + m[8] * z + m[12]
|
|
const wy = m[1] * x + m[5] * y + m[9] * z + m[13]
|
|
const wz = m[2] * x + m[6] * y + m[10] * z + m[14]
|
|
const a = wx * axisX + wy * axisY
|
|
const p = wx * perpX + wy * perpY
|
|
if (a < minA) minA = a
|
|
if (a > maxA) maxA = a
|
|
if (p < minP) minP = p
|
|
if (p > maxP) maxP = p
|
|
if (wz < minV) minV = wz
|
|
if (wz > maxV) maxV = wz
|
|
any = true
|
|
}
|
|
} finally {
|
|
;(geo as unknown as { delete?: () => void }).delete?.()
|
|
}
|
|
}
|
|
if (!any) return null
|
|
return { along: maxA - minA, across: maxP - minP, vertical: maxV - minV }
|
|
} catch {
|
|
return null
|
|
} finally {
|
|
mesh.delete?.()
|
|
}
|
|
}
|
|
|
|
// Resolve wall height + thickness from the wall-frame extents. The along
|
|
// extent must match the wall's already-known length (within tolerance)
|
|
// for the measurement to be trusted — that gate confirms both the unit
|
|
// scale (tried raw and unit-scaled) and frame alignment. Height is the
|
|
// vertical extent (rotation-invariant); thickness is the perpendicular
|
|
// extent. Returns null (→ caller uses defaults) when the gate fails or
|
|
// the dims are implausible.
|
|
function wallHeightThicknessFromExtents(
|
|
extents: { along: number; across: number; vertical: number },
|
|
knownLengthMeters: number,
|
|
unitFactor: number,
|
|
): { height: number; thickness: number } | null {
|
|
if (knownLengthMeters <= 1e-6) return null
|
|
const tol = Math.max(0.3, 0.15 * knownLengthMeters)
|
|
for (const scale of [unitFactor, 1]) {
|
|
const along = extents.along * scale
|
|
if (Math.abs(along - knownLengthMeters) > tol) continue
|
|
const height = extents.vertical * scale
|
|
const thickness = extents.across * scale
|
|
if (height >= 0.2 && height <= 20 && thickness >= 0.02 && thickness <= 2) {
|
|
return { height, thickness }
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function getExtrusionPosition(ifcApi: WebIFC.IfcAPI, modelID: number, element: any): Mat4 | null {
|
|
try {
|
|
if (!element.Representation?.value) return null
|
|
const prodRep = ifcApi.GetLine(modelID, element.Representation.value)
|
|
for (const repRef of prodRep.Representations) {
|
|
const rep = ifcApi.GetLine(modelID, repRef.value)
|
|
if (rep.RepresentationIdentifier?.value !== 'Body') continue
|
|
for (const itemRef of rep.Items) {
|
|
const item = ifcApi.GetLine(modelID, itemRef.value)
|
|
const pos = findExtrusionPosition(ifcApi, modelID, item)
|
|
if (pos) return pos
|
|
|
|
if (item.MappingSource?.value) {
|
|
const src = ifcApi.GetLine(modelID, item.MappingSource.value)
|
|
if (src.MappedRepresentation?.value) {
|
|
const mapped = ifcApi.GetLine(modelID, src.MappedRepresentation.value)
|
|
if (mapped.Items) {
|
|
for (const mItemRef of mapped.Items) {
|
|
const mItem = ifcApi.GetLine(modelID, mItemRef.value)
|
|
const mPos = findExtrusionPosition(ifcApi, modelID, mItem)
|
|
if (mPos) return mPos
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return null
|
|
}
|
|
|
|
// --- Main converter ---
|
|
|
|
export interface ConversionOptions {
|
|
swapYZ?: boolean
|
|
extrusionDepthIsHeight?: boolean
|
|
swapProfileDimensions?: boolean
|
|
label?: string
|
|
}
|
|
|
|
export const VARIANT_PRESETS: Record<string, ConversionOptions> = {
|
|
A: {
|
|
swapYZ: true,
|
|
extrusionDepthIsHeight: true,
|
|
swapProfileDimensions: false,
|
|
label: 'Default (Y-up, depth=height)',
|
|
},
|
|
B: {
|
|
swapYZ: false,
|
|
extrusionDepthIsHeight: true,
|
|
swapProfileDimensions: false,
|
|
label: 'Z-Up (no axis swap)',
|
|
},
|
|
}
|
|
|
|
export async function convertIfcToPascal(
|
|
ifcData: Uint8Array,
|
|
onProgress?: (message: string, percent: number) => void,
|
|
options?: ConversionOptions,
|
|
): Promise<PascalSceneGraph> {
|
|
const opts = {
|
|
swapYZ: options?.swapYZ ?? true,
|
|
extrusionDepthIsHeight: options?.extrusionDepthIsHeight ?? true,
|
|
swapProfileDimensions: options?.swapProfileDimensions ?? false,
|
|
}
|
|
|
|
const progress = (msg: string, pct: number) => {
|
|
console.log(`[IFC→Pascal] ${msg} (${pct}%)`)
|
|
onProgress?.(msg, pct)
|
|
}
|
|
|
|
progress('Initializing IFC parser...', 0)
|
|
const ifcApi = new WebIFC.IfcAPI()
|
|
ifcApi.SetWasmPath('/', true)
|
|
|
|
await ifcApi.Init()
|
|
progress('Opening IFC model...', 10)
|
|
const modelID = ifcApi.OpenModel(ifcData)
|
|
|
|
console.log(
|
|
`[IFC→Pascal] Model opened, ID: ${modelID}, File size: ${(ifcData.length / 1024).toFixed(1)} KB`,
|
|
)
|
|
const nodes: Record<string, PascalNode> = {}
|
|
const rootNodeIds: string[] = []
|
|
|
|
// Maps to track relationships
|
|
const parentMap = new Map<number, number>()
|
|
const childrenMap = new Map<number, number[]>()
|
|
const expressIdToNodeId = new Map<number, string>()
|
|
|
|
progress('Analyzing spatial relationships...', 20)
|
|
|
|
// Detect length unit → meters conversion factor
|
|
const unitFactor = getLengthUnitFactor(ifcApi, modelID)
|
|
|
|
// Compute scene origin offset to center georeferenced models near (0,0,0)
|
|
let originOffset: number[] = [0, 0, 0]
|
|
try {
|
|
const siteIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE)
|
|
const anchorId = siteIds.size() > 0 ? siteIds.get(0) : null
|
|
if (anchorId) {
|
|
const anchor = ifcApi.GetLine(modelID, anchorId)
|
|
if (anchor.ObjectPlacement?.value) {
|
|
const mat = resolveWorldTransform(ifcApi, modelID, anchor.ObjectPlacement.value)
|
|
originOffset = transformPoint3(mat, [0, 0, 0])
|
|
}
|
|
}
|
|
} catch {
|
|
/* keep zero offset */
|
|
}
|
|
|
|
function worldToScene(worldPt: number[]): number[] {
|
|
return [
|
|
(worldPt[0] - originOffset[0]) * unitFactor,
|
|
(worldPt[1] - originOffset[1]) * unitFactor,
|
|
(worldPt[2] - originOffset[2]) * unitFactor,
|
|
]
|
|
}
|
|
|
|
// Collect storey expressIDs for level mapping
|
|
const storeyExpressIds = new Set<number>()
|
|
const storeyIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDINGSTOREY)
|
|
for (let i = 0; i < storeyIds.size(); i++) {
|
|
storeyExpressIds.add(storeyIds.get(i))
|
|
}
|
|
|
|
// First pass: collect spatial hierarchy relationships
|
|
const relAggregates = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELAGGREGATES)
|
|
|
|
for (let i = 0; i < relAggregates.size(); i++) {
|
|
const relID = relAggregates.get(i)
|
|
const rel = ifcApi.GetLine(modelID, relID)
|
|
|
|
if (rel.RelatingObject && rel.RelatedObjects) {
|
|
const parentExpressID = rel.RelatingObject.value
|
|
const children = rel.RelatedObjects.map((obj: any) => obj.value)
|
|
|
|
children.forEach((childID: number) => {
|
|
parentMap.set(childID, parentExpressID)
|
|
})
|
|
|
|
if (!childrenMap.has(parentExpressID)) {
|
|
childrenMap.set(parentExpressID, [])
|
|
}
|
|
childrenMap.get(parentExpressID)?.push(...children)
|
|
}
|
|
}
|
|
|
|
// Second pass: collect spatial containment
|
|
const relContained = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELCONTAINEDINSPATIALSTRUCTURE)
|
|
|
|
for (let i = 0; i < relContained.size(); i++) {
|
|
const relID = relContained.get(i)
|
|
const rel = ifcApi.GetLine(modelID, relID)
|
|
|
|
if (rel.RelatingStructure && rel.RelatedElements) {
|
|
const parentExpressID = rel.RelatingStructure.value
|
|
const children = rel.RelatedElements.map((obj: any) => obj.value)
|
|
|
|
children.forEach((childID: number) => {
|
|
parentMap.set(childID, parentExpressID)
|
|
})
|
|
|
|
if (!childrenMap.has(parentExpressID)) {
|
|
childrenMap.set(parentExpressID, [])
|
|
}
|
|
childrenMap.get(parentExpressID)?.push(...children)
|
|
}
|
|
}
|
|
|
|
// Resolve containing storey for an element by walking the parent chain
|
|
function findStoreyForElement(expressId: number): number | null {
|
|
let current: number | undefined = expressId
|
|
for (let guard = 0; guard < 20 && current != null; guard++) {
|
|
if (storeyExpressIds.has(current)) return current
|
|
current = parentMap.get(current)
|
|
}
|
|
return null
|
|
}
|
|
|
|
progress('Processing sites...', 30)
|
|
// Process sites
|
|
const sites = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE)
|
|
console.log(`[IFC→Pascal] Found ${sites.size()} sites`)
|
|
for (let i = 0; i < sites.size(); i++) {
|
|
const siteExpressID = sites.get(i)
|
|
const site = ifcApi.GetLine(modelID, siteExpressID)
|
|
|
|
const nodeId = generateId('site')
|
|
expressIdToNodeId.set(siteExpressID, nodeId)
|
|
rootNodeIds.push(nodeId)
|
|
|
|
const siteNode = tryParse(SiteNode, 'site', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'site',
|
|
name: site.Name?.value || site.LongName?.value || 'Site',
|
|
parentId: null,
|
|
visible: true,
|
|
polygon: {
|
|
// Pascal SiteNode requires a property-line polygon. The
|
|
// converter doesn't read IFC site geometry yet, so seed the
|
|
// editor's default 30x30 square here.
|
|
// TODO(ifc-fix): derive from IfcSite.SiteAddress or building footprints.
|
|
type: 'polygon',
|
|
points: [
|
|
[-15, -15],
|
|
[15, -15],
|
|
[15, 15],
|
|
[-15, 15],
|
|
],
|
|
},
|
|
children: [],
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCSITE',
|
|
expressID: siteExpressID,
|
|
globalId: site.GlobalId?.value,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = siteNode
|
|
}
|
|
|
|
progress('Processing buildings...', 40)
|
|
// Process buildings
|
|
const buildings = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDING)
|
|
console.log(`[IFC→Pascal] Found ${buildings.size()} buildings`)
|
|
for (let i = 0; i < buildings.size(); i++) {
|
|
const buildingExpressID = buildings.get(i)
|
|
const building = ifcApi.GetLine(modelID, buildingExpressID)
|
|
|
|
const nodeId = generateId('building')
|
|
expressIdToNodeId.set(buildingExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(buildingExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
const buildingNode = tryParse(BuildingNode, 'building', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'building',
|
|
name: building.Name?.value || building.LongName?.value || 'Building',
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
position: [0, 0, 0],
|
|
rotation: [0, 0, 0],
|
|
children: [],
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCBUILDING',
|
|
expressID: buildingExpressID,
|
|
globalId: building.GlobalId?.value,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = buildingNode
|
|
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
|
|
progress('Processing levels...', 50)
|
|
// Process building storeys (levels)
|
|
const storeys = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDINGSTOREY)
|
|
console.log(`[IFC→Pascal] Found ${storeys.size()} levels`)
|
|
for (let i = 0; i < storeys.size(); i++) {
|
|
const storeyExpressID = storeys.get(i)
|
|
const storey = ifcApi.GetLine(modelID, storeyExpressID)
|
|
|
|
const nodeId = generateId('level')
|
|
expressIdToNodeId.set(storeyExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(storeyExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
// Resolve storey elevation from placement chain
|
|
let elevation = storey.Elevation?.value ?? 0
|
|
if (storey.ObjectPlacement?.value) {
|
|
try {
|
|
const worldMat = resolveWorldTransform(ifcApi, modelID, storey.ObjectPlacement.value)
|
|
const worldOrigin = transformPoint3(worldMat, [0, 0, 0])
|
|
elevation = worldToScene(worldOrigin)[2]
|
|
} catch {
|
|
elevation = (storey.Elevation?.value ?? 0) * unitFactor
|
|
}
|
|
} else {
|
|
elevation *= unitFactor
|
|
}
|
|
|
|
const levelNode = tryParse(LevelNode, 'level', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'level',
|
|
name: storey.Name?.value || storey.LongName?.value || `Level ${i}`,
|
|
level: i,
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
children: [],
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCBUILDINGSTOREY',
|
|
expressID: storeyExpressID,
|
|
globalId: storey.GlobalId?.value,
|
|
elevation,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = levelNode
|
|
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
|
|
progress('Processing walls...', 60)
|
|
|
|
// Build void/fill relationship maps for doors and windows
|
|
// IFCRELVOIDSELEMENT: wall (RelatingBuildingElement) → opening (RelatedOpeningElement)
|
|
const wallToOpenings = new Map<number, number[]>()
|
|
const relVoids = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELVOIDSELEMENT)
|
|
for (let i = 0; i < relVoids.size(); i++) {
|
|
const rel = ifcApi.GetLine(modelID, relVoids.get(i))
|
|
const wallId = rel.RelatingBuildingElement?.value
|
|
const openingId = rel.RelatedOpeningElement?.value
|
|
if (wallId && openingId) {
|
|
if (!wallToOpenings.has(wallId)) wallToOpenings.set(wallId, [])
|
|
wallToOpenings.get(wallId)!.push(openingId)
|
|
}
|
|
}
|
|
|
|
// IFCRELFILLSELEMENT: opening (RelatingOpeningElement) → door/window (RelatedBuildingElement)
|
|
const openingToFill = new Map<number, number>()
|
|
const relFills = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELFILLSELEMENT)
|
|
for (let i = 0; i < relFills.size(); i++) {
|
|
const rel = ifcApi.GetLine(modelID, relFills.get(i))
|
|
const openingId = rel.RelatingOpeningElement?.value
|
|
const fillId = rel.RelatedBuildingElement?.value
|
|
if (openingId && fillId) {
|
|
openingToFill.set(openingId, fillId)
|
|
}
|
|
}
|
|
|
|
// Collect all door and window expressIDs for type checking
|
|
const doorExpressIds = new Set<number>()
|
|
const windowExpressIds = new Set<number>()
|
|
for (const doorType of [WebIFC.IFCDOOR, WebIFC.IFCDOORSTANDARDCASE]) {
|
|
const ids = ifcApi.GetLineIDsWithType(modelID, doorType)
|
|
for (let i = 0; i < ids.size(); i++) doorExpressIds.add(ids.get(i))
|
|
}
|
|
for (const winType of [WebIFC.IFCWINDOW, WebIFC.IFCWINDOWSTANDARDCASE]) {
|
|
const ids = ifcApi.GetLineIDsWithType(modelID, winType)
|
|
for (let i = 0; i < ids.size(); i++) windowExpressIds.add(ids.get(i))
|
|
}
|
|
|
|
// Process walls (both IFCWALL and IFCWALLSTANDARDCASE)
|
|
const wallTypes = [WebIFC.IFCWALL, WebIFC.IFCWALLSTANDARDCASE]
|
|
for (const wallType of wallTypes) {
|
|
const walls = ifcApi.GetLineIDsWithType(modelID, wallType)
|
|
for (let i = 0; i < walls.size(); i++) {
|
|
const wallExpressID = walls.get(i)
|
|
if (expressIdToNodeId.has(wallExpressID)) continue
|
|
|
|
const wall = ifcApi.GetLine(modelID, wallExpressID)
|
|
|
|
const nodeId = generateId('wall')
|
|
expressIdToNodeId.set(wallExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(wallExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
let start: [number, number] = [0, 0]
|
|
let end: [number, number] | null = null
|
|
let thickness: number | undefined
|
|
let height: number | undefined
|
|
|
|
try {
|
|
// Resolve world placement
|
|
const worldMat = wall.ObjectPlacement?.value
|
|
? resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value)
|
|
: identity()
|
|
|
|
// Try to get axis polyline (local 2D line along wall)
|
|
const axisPts = getAxisPolyline(ifcApi, modelID, wall)
|
|
|
|
if (axisPts && axisPts.length >= 2) {
|
|
const s0 = worldToScene(transformPoint3(worldMat, axisPts[0]))
|
|
const s1 = worldToScene(transformPoint3(worldMat, axisPts[axisPts.length - 1]))
|
|
start = [s0[0], s0[1]]
|
|
end = [s1[0], s1[1]]
|
|
} else {
|
|
// Fallback: use placement origin + body XDim for length
|
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]))
|
|
start = [s[0], s[1]]
|
|
// Will try to get length from body below
|
|
}
|
|
|
|
// Get body extrusion data for thickness/height
|
|
const body = getBodyExtrusionData(ifcApi, modelID, wall)
|
|
|
|
if (body.depth) {
|
|
if (opts.extrusionDepthIsHeight) {
|
|
height = body.depth * unitFactor
|
|
} else {
|
|
thickness = body.depth * unitFactor
|
|
}
|
|
}
|
|
|
|
const dimForThickness = opts.swapProfileDimensions ? body.xDim : body.yDim
|
|
if (dimForThickness) {
|
|
thickness = dimForThickness * unitFactor
|
|
} else if (body.profilePoints && body.profilePoints.length >= 3) {
|
|
const ys = body.profilePoints.map((p) => p[1])
|
|
thickness = (Math.max(...ys) - Math.min(...ys)) * unitFactor
|
|
}
|
|
|
|
// If no axis polyline, derive wall length from profile or XDim
|
|
if (!axisPts) {
|
|
let wallLength = body.xDim
|
|
if (!wallLength && body.profilePoints && body.profilePoints.length >= 3) {
|
|
const xs = body.profilePoints.map((p) => p[0])
|
|
wallLength = Math.max(...xs) - Math.min(...xs)
|
|
}
|
|
if (wallLength) {
|
|
const se = worldToScene(transformPoint3(worldMat, [wallLength, 0, 0]))
|
|
end = [se[0], se[1]]
|
|
}
|
|
}
|
|
} catch {
|
|
// keep defaults
|
|
}
|
|
|
|
// Skip walls where we couldn't determine geometry
|
|
if (!end) continue
|
|
|
|
// Plain IFCWALL frequently carries Brep / mapped geometry rather
|
|
// than a clean IfcExtrudedAreaSolid, so getBodyExtrusionData can't
|
|
// read its height/thickness (only IFCWALLSTANDARDCASE reliably
|
|
// works). First try to recover real dims from the element's mesh
|
|
// bounding box; only then fall back to the editor's wall defaults
|
|
// so the wall renders at a sensible size instead of collapsing to
|
|
// a zero-height sliver (which also breaks the door/window CSG
|
|
// cutouts punched into it).
|
|
if (height === undefined || thickness === undefined) {
|
|
const wallLenM = Math.hypot(end[0] - start[0], end[1] - start[1])
|
|
if (wallLenM > 1e-6) {
|
|
// Project the wall mesh onto its own axis so length, thickness
|
|
// and height are read in the wall frame (rotation-invariant) —
|
|
// a world-space AABB conflates length and thickness on rotated
|
|
// walls. Axis is the normalised start→end direction in the
|
|
// IFC ground plane, which is also the mapping used for the
|
|
// wall's start/end above.
|
|
const axisX = (end[0] - start[0]) / wallLenM
|
|
const axisY = (end[1] - start[1]) / wallLenM
|
|
const extents = measureWallLocalExtents(ifcApi, modelID, wallExpressID, axisX, axisY)
|
|
const geom = extents
|
|
? wallHeightThicknessFromExtents(extents, wallLenM, unitFactor)
|
|
: null
|
|
if (geom) {
|
|
if (height === undefined) height = geom.height
|
|
if (thickness === undefined) thickness = geom.thickness
|
|
}
|
|
}
|
|
}
|
|
if (height === undefined) height = DEFAULT_WALL_HEIGHT
|
|
if (thickness === undefined) thickness = DEFAULT_WALL_THICKNESS
|
|
|
|
const wallNode = tryParse(WallNode, 'wall', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'wall',
|
|
name: wall.Name?.value || `Wall ${i + 1}`,
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
start,
|
|
end,
|
|
thickness,
|
|
height,
|
|
frontSide: 'unknown',
|
|
backSide: 'unknown',
|
|
children: [],
|
|
metadata: buildMetadata({
|
|
ifcType: wallType === WebIFC.IFCWALL ? 'IFCWALL' : 'IFCWALLSTANDARDCASE',
|
|
expressID: wallExpressID,
|
|
globalId: wall.GlobalId?.value,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = wallNode
|
|
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process doors and windows via void/fill relationships
|
|
for (const [wallExpressID, openingIds] of wallToOpenings) {
|
|
const wallNodeId = expressIdToNodeId.get(wallExpressID)
|
|
if (!wallNodeId) continue
|
|
const wallNode = nodes[wallNodeId] as WallNode
|
|
if (!wallNode || wallNode.type !== 'wall') continue
|
|
|
|
const wallDx = wallNode.end[0] - wallNode.start[0]
|
|
const wallDy = wallNode.end[1] - wallNode.start[1]
|
|
const wallLength = Math.sqrt(wallDx * wallDx + wallDy * wallDy)
|
|
|
|
// Wall world matrix for projecting opening positions
|
|
let wallWorldMat: Mat4 | null = null
|
|
try {
|
|
const wall = ifcApi.GetLine(modelID, wallExpressID)
|
|
if (wall.ObjectPlacement?.value) {
|
|
wallWorldMat = resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value)
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
for (const openingId of openingIds) {
|
|
const fillId = openingToFill.get(openingId)
|
|
if (!fillId) continue
|
|
|
|
const isDoor = doorExpressIds.has(fillId)
|
|
const isWindow = windowExpressIds.has(fillId)
|
|
if (!isDoor && !isWindow) continue
|
|
|
|
try {
|
|
const element = ifcApi.GetLine(modelID, fillId)
|
|
|
|
// Get dimensions from OverallWidth/OverallHeight
|
|
let width: number | undefined
|
|
let height: number | undefined
|
|
if (element.OverallWidth?.value) width = element.OverallWidth.value * unitFactor
|
|
if (element.OverallHeight?.value) height = element.OverallHeight.value * unitFactor
|
|
|
|
// Compute position along wall from opening placement
|
|
let position: number | undefined
|
|
try {
|
|
const opening = ifcApi.GetLine(modelID, openingId)
|
|
if (opening.ObjectPlacement?.value) {
|
|
const openingWorldMat = resolveWorldTransform(
|
|
ifcApi,
|
|
modelID,
|
|
opening.ObjectPlacement.value,
|
|
)
|
|
const openingScene = worldToScene(transformPoint3(openingWorldMat, [0, 0, 0]))
|
|
const ox = openingScene[0] - wallNode.start[0]
|
|
const oy = openingScene[1] - wallNode.start[1]
|
|
if (wallLength > 1e-6) {
|
|
const dot = (ox * wallDx + oy * wallDy) / wallLength
|
|
// Clamp so the opening's [pos - w/2, pos + w/2] footprint
|
|
// stays inside the wall — an overflowing CSG cutout breaks
|
|
// the wall mesh.
|
|
const half = (width ?? 0) / 2
|
|
const lo = Math.min(half, wallLength / 2)
|
|
const hi = Math.max(wallLength - half, wallLength / 2)
|
|
position = Math.max(lo, Math.min(hi, dot))
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
// Get sill height for windows from opening placement Z relative to wall
|
|
let sillHeight: number | undefined
|
|
if (isWindow) {
|
|
try {
|
|
const opening = ifcApi.GetLine(modelID, openingId)
|
|
if (opening.ObjectPlacement?.value) {
|
|
const openingWorldMat = resolveWorldTransform(
|
|
ifcApi,
|
|
modelID,
|
|
opening.ObjectPlacement.value,
|
|
)
|
|
const openingScene = worldToScene(transformPoint3(openingWorldMat, [0, 0, 0]))
|
|
if (wallWorldMat) {
|
|
const wallScene = worldToScene(transformPoint3(wallWorldMat, [0, 0, 0]))
|
|
sillHeight = openingScene[2] - wallScene[2]
|
|
} else {
|
|
sillHeight = openingScene[2]
|
|
}
|
|
if (sillHeight !== undefined && sillHeight < 0.01) sillHeight = undefined
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
if (isDoor) {
|
|
const nodeId = generateId('door')
|
|
expressIdToNodeId.set(fillId, nodeId)
|
|
|
|
// Vertical centering is now handled: door center Y = height/2 so the
|
|
// opening sits at the correct position. Remaining caveat: door bottom
|
|
// is assumed at floor y=0 (i.e. the door starts at the wall's base).
|
|
// Defaults match @pascal-app/core door schema fallbacks.
|
|
const doorPosition: [number, number, number] = [position ?? 0, (height ?? 2.1) / 2, 0]
|
|
const doorNode = tryParse(DoorNode, 'door', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'door',
|
|
name: element.Name?.value || `Door`,
|
|
parentId: wallNodeId,
|
|
visible: true,
|
|
width: width ?? 0.9,
|
|
height: height ?? 2.1,
|
|
position: doorPosition,
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCDOOR',
|
|
expressID: fillId,
|
|
globalId: element.GlobalId?.value,
|
|
hostWallExpressID: wallExpressID,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = doorNode
|
|
wallNode.children.push(nodeId)
|
|
} else {
|
|
const nodeId = generateId('window')
|
|
expressIdToNodeId.set(fillId, nodeId)
|
|
|
|
// TODO(ifc-fix): same scalar-vs-tuple position issue as door above.
|
|
// sillHeight stays read-only metadata until we resolve the window
|
|
// schema (Pascal's WindowNode doesn't have sillHeight today —
|
|
// moved to metadata for now so we don't lose the value).
|
|
const windowPosition: [number, number, number] = [
|
|
position ?? 0,
|
|
(sillHeight ?? 0) + (height ?? 1.2) / 2,
|
|
0,
|
|
]
|
|
const windowNode = tryParse(WindowNode, 'window', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'window',
|
|
name: element.Name?.value || `Window`,
|
|
parentId: wallNodeId,
|
|
visible: true,
|
|
width: width ?? 1.0,
|
|
height: height ?? 1.2,
|
|
position: windowPosition,
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCWINDOW',
|
|
expressID: fillId,
|
|
globalId: element.GlobalId?.value,
|
|
hostWallExpressID: wallExpressID,
|
|
sillHeight,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = windowNode
|
|
wallNode.children.push(nodeId)
|
|
}
|
|
} catch {
|
|
// skip this opening
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process any doors/windows NOT linked via the void/fill chain. Some
|
|
// exporters (e.g. the Paris sample) carve openings with
|
|
// IFCRELVOIDSELEMENT but omit IFCRELFILLSELEMENT, so the
|
|
// door/window → wall link is only implicit in the element's world
|
|
// placement. We recover it by projecting the element's world position
|
|
// onto the nearest wall segment. Anything farther than
|
|
// HOST_WALL_MAX_DIST from every wall stays parented to its spatial
|
|
// container at the origin — we have no basis to place it on a wall.
|
|
const HOST_WALL_MAX_DIST = 1.0 // metres
|
|
|
|
type WallInfo = {
|
|
nodeId: string
|
|
start: [number, number]
|
|
end: [number, number]
|
|
length: number
|
|
baseY: number
|
|
}
|
|
const wallInfos: WallInfo[] = []
|
|
for (const [wallExpressId, wallNodeId] of expressIdToNodeId) {
|
|
const node = nodes[wallNodeId]
|
|
if (!node || node.type !== 'wall') continue
|
|
const w = node as WallNode
|
|
const length = Math.hypot(w.end[0] - w.start[0], w.end[1] - w.start[1])
|
|
if (length < 1e-6) continue
|
|
let baseY = 0
|
|
try {
|
|
const wall = ifcApi.GetLine(modelID, wallExpressId)
|
|
if (wall.ObjectPlacement?.value) {
|
|
const m = resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value)
|
|
baseY = worldToScene(transformPoint3(m, [0, 0, 0]))[2]
|
|
}
|
|
} catch {
|
|
/* keep baseY = 0 */
|
|
}
|
|
wallInfos.push({ nodeId: wallNodeId, start: w.start, end: w.end, length, baseY })
|
|
}
|
|
|
|
// Pick the host wall for an opening at ground-plane point [x, y] with
|
|
// the given width. Among walls within HOST_WALL_MAX_DIST
|
|
// perpendicular, prefer one long enough to contain the opening — the
|
|
// castle's IFCWALL decomposition leaves tiny stub walls near corners,
|
|
// and a plain nearest-by-distance match would snap (say) a 0.7m door
|
|
// onto a 0.4m stub, so its CSG cutout overflows and breaks the wall.
|
|
// Returns the along-wall position already clamped so the opening
|
|
// footprint stays inside the wall.
|
|
const findHostWall = (x: number, y: number, width: number) => {
|
|
let best: { info: WallInfo; along: number; dist: number; fits: boolean } | null = null
|
|
for (const info of wallInfos) {
|
|
const dx = info.end[0] - info.start[0]
|
|
const dy = info.end[1] - info.start[1]
|
|
const lenSq = dx * dx + dy * dy
|
|
if (lenSq < 1e-9) continue
|
|
const t = Math.max(
|
|
0,
|
|
Math.min(1, ((x - info.start[0]) * dx + (y - info.start[1]) * dy) / lenSq),
|
|
)
|
|
const footX = info.start[0] + t * dx
|
|
const footY = info.start[1] + t * dy
|
|
const dist = Math.hypot(x - footX, y - footY)
|
|
if (dist > HOST_WALL_MAX_DIST) continue
|
|
// Keep the [along - width/2, along + width/2] span inside the wall.
|
|
const half = width / 2
|
|
const lo = Math.min(half, info.length / 2)
|
|
const hi = Math.max(info.length - half, info.length / 2)
|
|
const along = Math.max(lo, Math.min(hi, t * info.length))
|
|
const fits = info.length + 1e-6 >= width
|
|
const cand = { info, along, dist, fits }
|
|
if (!best) {
|
|
best = cand
|
|
} else if (cand.fits !== best.fits) {
|
|
if (cand.fits) best = cand
|
|
} else if (cand.dist < best.dist) {
|
|
best = cand
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
for (const fillId of [...doorExpressIds, ...windowExpressIds]) {
|
|
if (expressIdToNodeId.has(fillId)) continue
|
|
try {
|
|
const element = ifcApi.GetLine(modelID, fillId)
|
|
const isDoor = doorExpressIds.has(fillId)
|
|
|
|
let width: number | undefined
|
|
let height: number | undefined
|
|
if (element.OverallWidth?.value) width = element.OverallWidth.value * unitFactor
|
|
if (element.OverallHeight?.value) height = element.OverallHeight.value * unitFactor
|
|
|
|
// Element world placement → scene point. Ground plane is [x, y]
|
|
// (IFC X/Y, matching wall start/end); vertical is component [2].
|
|
let scene: number[] | null = null
|
|
try {
|
|
if (element.ObjectPlacement?.value) {
|
|
const m = resolveWorldTransform(ifcApi, modelID, element.ObjectPlacement.value)
|
|
scene = worldToScene(transformPoint3(m, [0, 0, 0]))
|
|
}
|
|
} catch {
|
|
/* no placement */
|
|
}
|
|
|
|
const effWidth = width ?? (isDoor ? 0.9 : 1.0)
|
|
const hosted = scene ? findHostWall(scene[0], scene[1], effWidth) : null
|
|
|
|
// When hosted, parent to (and live inside) the wall — same as the
|
|
// void/fill path. Otherwise fall back to the spatial container.
|
|
const containerExpressID = parentMap.get(fillId)
|
|
const containerNodeId = containerExpressID
|
|
? (expressIdToNodeId.get(containerExpressID) ?? null)
|
|
: null
|
|
const parentNodeId = hosted ? hosted.info.nodeId : containerNodeId
|
|
|
|
if (isDoor) {
|
|
const h = height ?? 2.1
|
|
const nodeId = generateId('door')
|
|
expressIdToNodeId.set(fillId, nodeId)
|
|
const doorNode = tryParse(DoorNode, 'door', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'door',
|
|
name: element.Name?.value || `Door`,
|
|
parentId: parentNodeId,
|
|
visible: true,
|
|
width: width ?? 0.9,
|
|
height: h,
|
|
// Placed by nearest-wall projection; [0,0,0] only when no wall
|
|
// is within range (then it sits on its spatial container).
|
|
position: hosted ? [hosted.along, h / 2, 0] : [0, 0, 0],
|
|
...(hosted ? { wallId: hosted.info.nodeId } : {}),
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCDOOR',
|
|
expressID: fillId,
|
|
globalId: element.GlobalId?.value,
|
|
}),
|
|
})
|
|
nodes[nodeId] = doorNode
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as { children?: string[] }).children?.push(nodeId)
|
|
}
|
|
} else {
|
|
const h = height ?? 1.2
|
|
const sill = hosted && scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0
|
|
const nodeId = generateId('window')
|
|
expressIdToNodeId.set(fillId, nodeId)
|
|
const windowNode = tryParse(WindowNode, 'window', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'window',
|
|
name: element.Name?.value || `Window`,
|
|
parentId: parentNodeId,
|
|
visible: true,
|
|
width: width ?? 1.0,
|
|
height: h,
|
|
position: hosted ? [hosted.along, sill + h / 2, 0] : [0, 0, 0],
|
|
...(hosted ? { wallId: hosted.info.nodeId } : {}),
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCWINDOW',
|
|
expressID: fillId,
|
|
globalId: element.GlobalId?.value,
|
|
...(hosted ? { sillHeight: sill } : {}),
|
|
}),
|
|
})
|
|
nodes[nodeId] = windowNode
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as { children?: string[] }).children?.push(nodeId)
|
|
}
|
|
}
|
|
} catch {
|
|
// skip
|
|
}
|
|
}
|
|
|
|
// Process slabs
|
|
const slabs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSLAB)
|
|
console.log(`[IFC→Pascal] Found ${slabs.size()} slabs`)
|
|
for (let i = 0; i < slabs.size(); i++) {
|
|
const slabExpressID = slabs.get(i)
|
|
const slab = ifcApi.GetLine(modelID, slabExpressID)
|
|
|
|
const nodeId = generateId('slab')
|
|
expressIdToNodeId.set(slabExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(slabExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
let polygon: [number, number][] | null = null
|
|
let elevation = 0
|
|
let thickness: number | undefined
|
|
|
|
try {
|
|
// Resolve world placement for the slab element
|
|
const worldMat = slab.ObjectPlacement?.value
|
|
? resolveWorldTransform(ifcApi, modelID, slab.ObjectPlacement.value)
|
|
: identity()
|
|
|
|
// Get elevation from placement Z
|
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]))
|
|
elevation = s[2]
|
|
|
|
// Get body extrusion data
|
|
const body = getBodyExtrusionData(ifcApi, modelID, slab)
|
|
|
|
// Extrusion depth is slab thickness
|
|
if (body.depth) {
|
|
thickness = body.depth * unitFactor
|
|
}
|
|
|
|
// Extrusion Position provides an additional local offset for the profile
|
|
const extrusionMat = getExtrusionPosition(ifcApi, modelID, slab)
|
|
|
|
if (body.profilePoints && body.profilePoints.length >= 3) {
|
|
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat
|
|
polygon = body.profilePoints.map((pt) => {
|
|
const sc = worldToScene(transformPoint3(combinedMat, [pt[0], pt[1], 0]))
|
|
return [sc[0], sc[1]] as [number, number]
|
|
})
|
|
const first = polygon[0]
|
|
const last = polygon[polygon.length - 1]
|
|
if (
|
|
polygon.length > 3 &&
|
|
Math.abs(first[0] - last[0]) < 1e-6 &&
|
|
Math.abs(first[1] - last[1]) < 1e-6
|
|
) {
|
|
polygon.pop()
|
|
}
|
|
} else if (body.xDim && body.yDim) {
|
|
const hw = body.xDim / 2
|
|
const hh = body.yDim / 2
|
|
const corners: number[][] = [
|
|
[-hw, -hh, 0],
|
|
[hw, -hh, 0],
|
|
[hw, hh, 0],
|
|
[-hw, hh, 0],
|
|
]
|
|
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat
|
|
polygon = corners.map((c) => {
|
|
const sc = worldToScene(transformPoint3(combinedMat, c))
|
|
return [sc[0], sc[1]] as [number, number]
|
|
})
|
|
}
|
|
} catch {
|
|
// keep defaults
|
|
}
|
|
|
|
// Skip slabs where we couldn't extract a polygon
|
|
if (!polygon || polygon.length < 3) continue
|
|
|
|
const slabNode = tryParse(SlabNode, 'slab', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'slab',
|
|
name: slab.Name?.value || `Slab ${i + 1}`,
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
polygon,
|
|
holes: [],
|
|
elevation,
|
|
// TODO(ifc-fix): Pascal SlabNode has no `thickness` field — moved
|
|
// to metadata so the IFC value isn't lost.
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCSLAB',
|
|
expressID: slabExpressID,
|
|
globalId: slab.GlobalId?.value,
|
|
thickness,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = slabNode
|
|
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
|
|
// Process stairs
|
|
const stairs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSTAIR)
|
|
for (let i = 0; i < stairs.size(); i++) {
|
|
const stairExpressID = stairs.get(i)
|
|
if (expressIdToNodeId.has(stairExpressID)) continue
|
|
|
|
const stair = ifcApi.GetLine(modelID, stairExpressID)
|
|
const nodeId = generateId('stair')
|
|
expressIdToNodeId.set(stairExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(stairExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
let position: [number, number, number] = [0, 0, 0]
|
|
let boundingBox: [number, number, number] | undefined
|
|
|
|
try {
|
|
const worldMat = stair.ObjectPlacement?.value
|
|
? resolveWorldTransform(ifcApi, modelID, stair.ObjectPlacement.value)
|
|
: identity()
|
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]))
|
|
position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]]
|
|
|
|
// Try stair's own body first
|
|
const body = getBodyExtrusionData(ifcApi, modelID, stair)
|
|
if (body.xDim && body.yDim && body.depth) {
|
|
boundingBox = opts.swapYZ
|
|
? [body.xDim * unitFactor, body.depth * unitFactor, body.yDim * unitFactor]
|
|
: [body.xDim * unitFactor, body.yDim * unitFactor, body.depth * unitFactor]
|
|
}
|
|
|
|
// If no body, try to derive from stair flight children
|
|
if (!boundingBox) {
|
|
const stairChildren = childrenMap.get(stairExpressID) ?? []
|
|
for (const childId of stairChildren) {
|
|
try {
|
|
const child = ifcApi.GetLine(modelID, childId)
|
|
// Check for NumberOfRisers / RiserHeight / TreadLength
|
|
const nRisers = child.NumberOfRisers?.value ?? child.NumberOfRiser?.value
|
|
const riserHeight = child.RiserHeight?.value
|
|
const treadLength = child.TreadLength?.value
|
|
if (nRisers && riserHeight && treadLength) {
|
|
const totalHeight = nRisers * riserHeight * unitFactor
|
|
const totalRun = (nRisers - 1) * treadLength * unitFactor
|
|
const width = 1.0 // Default stair width
|
|
const flightBody = getBodyExtrusionData(ifcApi, modelID, child)
|
|
const stairWidth = flightBody.yDim ? flightBody.yDim * unitFactor : width
|
|
boundingBox = opts.swapYZ
|
|
? [totalRun || 1, totalHeight, stairWidth]
|
|
: [totalRun || 1, stairWidth, totalHeight]
|
|
break
|
|
}
|
|
// Fallback: try flight body extrusion
|
|
const flightBody = getBodyExtrusionData(ifcApi, modelID, child)
|
|
if (flightBody.xDim && flightBody.yDim && flightBody.depth) {
|
|
boundingBox = opts.swapYZ
|
|
? [
|
|
flightBody.xDim * unitFactor,
|
|
flightBody.depth * unitFactor,
|
|
flightBody.yDim * unitFactor,
|
|
]
|
|
: [
|
|
flightBody.xDim * unitFactor,
|
|
flightBody.yDim * unitFactor,
|
|
flightBody.depth * unitFactor,
|
|
]
|
|
break
|
|
}
|
|
} catch {
|
|
/* skip child */
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* keep defaults */
|
|
}
|
|
|
|
const stairNode = tryParse(StairNode, 'stair', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'stair',
|
|
name: stair.Name?.value || `Stair ${i + 1}`,
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
position,
|
|
children: [],
|
|
// TODO(ifc-fix): Pascal StairNode is parametric (segments / treads /
|
|
// risers). The converter only knows the bounding box right now;
|
|
// keep it in metadata until we map IFC stairs onto the parametric
|
|
// shape (or extend StairNode with a raw-geometry escape hatch).
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCSTAIR',
|
|
expressID: stairExpressID,
|
|
globalId: stair.GlobalId?.value,
|
|
predefinedType: stair.PredefinedType?.value,
|
|
boundingBox,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = stairNode
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
|
|
// Process roofs
|
|
const roofs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCROOF)
|
|
for (let i = 0; i < roofs.size(); i++) {
|
|
const roofExpressID = roofs.get(i)
|
|
if (expressIdToNodeId.has(roofExpressID)) continue
|
|
|
|
const roof = ifcApi.GetLine(modelID, roofExpressID)
|
|
const nodeId = generateId('roof')
|
|
expressIdToNodeId.set(roofExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(roofExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
let polygon: [number, number][] | undefined
|
|
let elevation: number | undefined
|
|
let height: number | undefined
|
|
|
|
try {
|
|
const worldMat = roof.ObjectPlacement?.value
|
|
? resolveWorldTransform(ifcApi, modelID, roof.ObjectPlacement.value)
|
|
: identity()
|
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]))
|
|
elevation = s[2]
|
|
|
|
const body = getBodyExtrusionData(ifcApi, modelID, roof)
|
|
if (body.depth) height = body.depth * unitFactor
|
|
|
|
const extrusionMat = getExtrusionPosition(ifcApi, modelID, roof)
|
|
|
|
if (body.profilePoints && body.profilePoints.length >= 3) {
|
|
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat
|
|
polygon = body.profilePoints.map((pt) => {
|
|
const sc = worldToScene(transformPoint3(combinedMat, [pt[0], pt[1], 0]))
|
|
return [sc[0], sc[1]] as [number, number]
|
|
})
|
|
const first = polygon[0]
|
|
const last = polygon[polygon.length - 1]
|
|
if (
|
|
polygon.length > 3 &&
|
|
Math.abs(first[0] - last[0]) < 1e-6 &&
|
|
Math.abs(first[1] - last[1]) < 1e-6
|
|
) {
|
|
polygon.pop()
|
|
}
|
|
} else if (body.xDim && body.yDim) {
|
|
const hw = body.xDim / 2
|
|
const hh = body.yDim / 2
|
|
const corners: number[][] = [
|
|
[-hw, -hh, 0],
|
|
[hw, -hh, 0],
|
|
[hw, hh, 0],
|
|
[-hw, hh, 0],
|
|
]
|
|
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat
|
|
polygon = corners.map((c) => {
|
|
const sc = worldToScene(transformPoint3(combinedMat, c))
|
|
return [sc[0], sc[1]] as [number, number]
|
|
})
|
|
}
|
|
} catch {
|
|
/* keep defaults */
|
|
}
|
|
|
|
const roofNode = tryParse(RoofNode, 'roof', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'roof',
|
|
name: roof.Name?.value || `Roof ${i + 1}`,
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
elevation,
|
|
// TODO(ifc-fix): Pascal RoofNode is composed of roof-segments. The
|
|
// converter only has the flat polygon + height; pass them through
|
|
// metadata until we map the IFC roof onto the segment-based shape.
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCROOF',
|
|
expressID: roofExpressID,
|
|
globalId: roof.GlobalId?.value,
|
|
predefinedType: roof.PredefinedType?.value,
|
|
polygon,
|
|
height,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = roofNode
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
|
|
// Process columns
|
|
const columnTypes = [WebIFC.IFCCOLUMN]
|
|
try {
|
|
columnTypes.push(WebIFC.IFCCOLUMNSTANDARDCASE)
|
|
} catch {
|
|
/* not in all versions */
|
|
}
|
|
for (const colType of columnTypes) {
|
|
let cols
|
|
try {
|
|
cols = ifcApi.GetLineIDsWithType(modelID, colType)
|
|
} catch {
|
|
continue
|
|
}
|
|
for (let i = 0; i < cols.size(); i++) {
|
|
const colExpressID = cols.get(i)
|
|
if (expressIdToNodeId.has(colExpressID)) continue
|
|
|
|
const col = ifcApi.GetLine(modelID, colExpressID)
|
|
const nodeId = generateId('column')
|
|
expressIdToNodeId.set(colExpressID, nodeId)
|
|
|
|
const parentExpressID = parentMap.get(colExpressID)
|
|
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null
|
|
|
|
let position: [number, number, number] = [0, 0, 0]
|
|
let width: number | undefined
|
|
let depth: number | undefined
|
|
let height: number | undefined
|
|
let profileShape: 'round' | 'rectangular' | null = null
|
|
let profileRadius: number | undefined
|
|
|
|
try {
|
|
const worldMat = col.ObjectPlacement?.value
|
|
? resolveWorldTransform(ifcApi, modelID, col.ObjectPlacement.value)
|
|
: identity()
|
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]))
|
|
position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]]
|
|
|
|
const body = getBodyExtrusionData(ifcApi, modelID, col)
|
|
if (body.depth) height = body.depth * unitFactor
|
|
if (opts.swapProfileDimensions) {
|
|
if (body.yDim) width = body.yDim * unitFactor
|
|
if (body.xDim) depth = body.xDim * unitFactor
|
|
} else {
|
|
if (body.xDim) width = body.xDim * unitFactor
|
|
if (body.yDim) depth = body.yDim * unitFactor
|
|
}
|
|
profileShape = body.profileShape
|
|
if (body.radius != null) profileRadius = body.radius * unitFactor
|
|
} catch {
|
|
/* keep defaults */
|
|
}
|
|
|
|
// Structural IFC columns are plain shafts. The ColumnNode defaults
|
|
// are decorative (round-rings base + simple capital + a necked
|
|
// shaft → a classical/Greek look), and the default round
|
|
// cross-section ignores width/depth in favour of `radius`. Strip
|
|
// the ornament, keep the shaft full-width, and size from the IFC
|
|
// profile: use the real swept-area profile type when known
|
|
// (IfcCircleProfileDef → round + radius, IfcRectangleProfileDef →
|
|
// rectangular + width/depth), falling back to the width/depth
|
|
// ratio when the profile type isn't recognised.
|
|
const isRect =
|
|
profileShape === 'rectangular' ||
|
|
(profileShape === null &&
|
|
width !== undefined &&
|
|
depth !== undefined &&
|
|
Math.abs(width - depth) > 0.15 * Math.max(width, depth))
|
|
const columnNode = tryParse(ColumnNode, 'column', {
|
|
object: 'node',
|
|
id: nodeId,
|
|
type: 'column',
|
|
name: col.Name?.value || `Column ${i + 1}`,
|
|
parentId: parentNodeId || null,
|
|
visible: true,
|
|
position,
|
|
width,
|
|
depth,
|
|
height,
|
|
crossSection: isRect ? 'rectangular' : 'round',
|
|
radius: profileRadius ?? Math.max(width ?? 0.44, depth ?? 0.44) / 2,
|
|
style: 'plain',
|
|
shaftProfile: 'straight',
|
|
shaftStartScale: 1,
|
|
shaftEndScale: 1,
|
|
shaftSegmentCount: 1,
|
|
baseStyle: 'none',
|
|
capitalStyle: 'none',
|
|
baseHeight: 0,
|
|
capitalHeight: 0,
|
|
metadata: buildMetadata({
|
|
ifcType: 'IFCCOLUMN',
|
|
expressID: colExpressID,
|
|
globalId: col.GlobalId?.value,
|
|
}),
|
|
})
|
|
|
|
nodes[nodeId] = columnNode
|
|
if (parentNodeId && nodes[parentNodeId]) {
|
|
;(nodes[parentNodeId] as any).children?.push(nodeId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Beams: skipped for now — Pascal has no `beam` node type yet. When it
|
|
// lands in @pascal-app/core, restore the IFCBEAM → BeamNode mapping
|
|
// (axis polyline → start/end [x,y,z], profile XDim/YDim → width/depth,
|
|
// extrusion depth → axis length). Reference implementation lives in
|
|
// git history of this file. We still walk the entities to log how
|
|
// many beams the IFC contained so the conversion summary is accurate.
|
|
let skippedBeamCount = 0
|
|
const beamTypes = [WebIFC.IFCBEAM]
|
|
try {
|
|
beamTypes.push(WebIFC.IFCBEAMSTANDARDCASE)
|
|
} catch {
|
|
/* not in all versions */
|
|
}
|
|
for (const beamType of beamTypes) {
|
|
try {
|
|
const beams = ifcApi.GetLineIDsWithType(modelID, beamType)
|
|
skippedBeamCount += beams.size()
|
|
} catch {
|
|
/* type not present in this file */
|
|
}
|
|
}
|
|
if (skippedBeamCount > 0) {
|
|
console.warn(
|
|
`[IFC→Pascal] Skipped ${skippedBeamCount} beam${skippedBeamCount === 1 ? '' : 's'} — Pascal has no beam node yet.`,
|
|
)
|
|
}
|
|
|
|
// Items: skipped for now — Pascal's ItemNode requires a full `asset`
|
|
// (catalog reference with id/src/dimensions/etc.) that the converter
|
|
// can't synthesise from raw IFC geometry. When the editor grows a
|
|
// raw-geometry escape hatch (or we add a placeholder-asset registry),
|
|
// restore the mapping from the pre-migration git history. We still
|
|
// walk the entities to log a count for diagnostics.
|
|
let skippedItemCount = 0
|
|
const itemTypeKeys = [
|
|
WebIFC.IFCFURNISHINGELEMENT,
|
|
WebIFC.IFCBUILDINGELEMENTPROXY,
|
|
WebIFC.IFCRAILING,
|
|
WebIFC.IFCCOVERING,
|
|
WebIFC.IFCCURTAINWALL,
|
|
WebIFC.IFCPLATE,
|
|
WebIFC.IFCMEMBER,
|
|
WebIFC.IFCFOOTING,
|
|
]
|
|
for (const itemType of itemTypeKeys) {
|
|
try {
|
|
const items = ifcApi.GetLineIDsWithType(modelID, itemType)
|
|
skippedItemCount += items.size()
|
|
} catch {
|
|
/* type not present in this file */
|
|
}
|
|
}
|
|
if (skippedItemCount > 0) {
|
|
console.warn(
|
|
`[IFC→Pascal] Skipped ${skippedItemCount} item${skippedItemCount === 1 ? '' : 's'} — Pascal items require a catalog asset the converter can't synthesise yet.`,
|
|
)
|
|
}
|
|
|
|
// Post-process: resolve levelId for all element nodes
|
|
for (const node of Object.values(nodes)) {
|
|
const m = meta(node)
|
|
if (!m.expressID) continue
|
|
const storeyExpId = findStoreyForElement(m.expressID)
|
|
if (storeyExpId != null) {
|
|
m.levelId = expressIdToNodeId.get(storeyExpId) ?? undefined
|
|
}
|
|
}
|
|
|
|
// Post-process: extract property sets and materials
|
|
const elementExpressIds = new Set<number>()
|
|
const expressIdToNode = new Map<number, PascalNode>()
|
|
for (const node of Object.values(nodes)) {
|
|
const m = meta(node)
|
|
if (m.expressID != null) {
|
|
elementExpressIds.add(m.expressID)
|
|
expressIdToNode.set(m.expressID, node)
|
|
}
|
|
}
|
|
|
|
// Property sets via IFCRELDEFINESBYPROPERTIES
|
|
try {
|
|
const relDefines = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELDEFINESBYPROPERTIES)
|
|
for (let i = 0; i < relDefines.size(); i++) {
|
|
try {
|
|
const rel = ifcApi.GetLine(modelID, relDefines.get(i))
|
|
if (!rel.RelatedObjects || !rel.RelatingPropertyDefinition?.value) continue
|
|
const psetId = rel.RelatingPropertyDefinition.value
|
|
const pset = ifcApi.GetLine(modelID, psetId)
|
|
const psetName = pset.Name?.value ?? 'Properties'
|
|
|
|
const props: Record<string, string | number | boolean> = {}
|
|
if (pset.HasProperties) {
|
|
for (const propRef of pset.HasProperties) {
|
|
try {
|
|
const prop = ifcApi.GetLine(modelID, propRef.value)
|
|
const name = prop.Name?.value
|
|
const val = prop.NominalValue?.value
|
|
if (name != null && val != null) props[name] = val
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
}
|
|
if (pset.Quantities) {
|
|
for (const qRef of pset.Quantities) {
|
|
try {
|
|
const q = ifcApi.GetLine(modelID, qRef.value)
|
|
const name = q.Name?.value
|
|
const val =
|
|
q.LengthValue?.value ??
|
|
q.AreaValue?.value ??
|
|
q.VolumeValue?.value ??
|
|
q.WeightValue?.value ??
|
|
q.CountValue?.value
|
|
if (name != null && val != null) props[name] = val
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(props).length === 0) continue
|
|
|
|
for (const objRef of rel.RelatedObjects) {
|
|
const node = expressIdToNode.get(objRef.value)
|
|
if (!node) continue
|
|
const m = meta(node)
|
|
if (!m.properties) m.properties = {}
|
|
m.properties[psetName] = props
|
|
}
|
|
} catch {
|
|
/* skip rel */
|
|
}
|
|
}
|
|
} catch {
|
|
/* no property rels */
|
|
}
|
|
|
|
// Materials via IFCRELASSOCIATESMATERIAL
|
|
try {
|
|
const relMat = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL)
|
|
for (let i = 0; i < relMat.size(); i++) {
|
|
try {
|
|
const rel = ifcApi.GetLine(modelID, relMat.get(i))
|
|
if (!rel.RelatedObjects || !rel.RelatingMaterial?.value) continue
|
|
const mat = ifcApi.GetLine(modelID, rel.RelatingMaterial.value)
|
|
|
|
let materialName: string | null = null
|
|
const layers: { name: string; thickness?: number }[] = []
|
|
|
|
const extractLayers = (layersArr: any[]) => {
|
|
for (const lRef of layersArr) {
|
|
try {
|
|
const layer = ifcApi.GetLine(modelID, lRef.value)
|
|
const layerMat = layer.Material?.value
|
|
? ifcApi.GetLine(modelID, layer.Material.value)
|
|
: null
|
|
layers.push({
|
|
name: layerMat?.Name?.value ?? 'Unknown',
|
|
thickness:
|
|
layer.LayerThickness?.value != null
|
|
? layer.LayerThickness.value * unitFactor
|
|
: undefined,
|
|
})
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mat.ForLayerSet?.value) {
|
|
const layerSet = ifcApi.GetLine(modelID, mat.ForLayerSet.value)
|
|
materialName = layerSet.LayerSetName?.value ?? null
|
|
if (layerSet.MaterialLayers) extractLayers(layerSet.MaterialLayers)
|
|
} else if (mat.MaterialLayers) {
|
|
materialName = mat.LayerSetName?.value ?? null
|
|
extractLayers(mat.MaterialLayers)
|
|
} else if (mat.Name?.value) {
|
|
materialName = mat.Name.value
|
|
}
|
|
|
|
if (!materialName && layers.length === 0) continue
|
|
|
|
for (const objRef of rel.RelatedObjects) {
|
|
const node = expressIdToNode.get(objRef.value)
|
|
if (!node) continue
|
|
const m = meta(node)
|
|
if (materialName) m.material = materialName
|
|
if (layers.length > 0) m.materialLayers = layers
|
|
}
|
|
} catch {
|
|
/* skip rel */
|
|
}
|
|
}
|
|
} catch {
|
|
/* no material rels */
|
|
}
|
|
|
|
// Type names via IFCRELDEFINESBYTYPE
|
|
try {
|
|
const relDefType = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELDEFINESBYTYPE)
|
|
for (let i = 0; i < relDefType.size(); i++) {
|
|
try {
|
|
const rel = ifcApi.GetLine(modelID, relDefType.get(i))
|
|
if (!rel.RelatedObjects || !rel.RelatingType?.value) continue
|
|
const type = ifcApi.GetLine(modelID, rel.RelatingType.value)
|
|
const typeName = type.Name?.value
|
|
if (!typeName) continue
|
|
|
|
for (const objRef of rel.RelatedObjects) {
|
|
const node = expressIdToNode.get(objRef.value)
|
|
if (node) meta(node).typeName = typeName
|
|
}
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
} catch {
|
|
/* no type rels */
|
|
}
|
|
|
|
ifcApi.CloseModel(modelID)
|
|
|
|
progress('Building scene graph...', 95)
|
|
|
|
const totalNodes = Object.keys(nodes).length
|
|
console.log(`[IFC→Pascal] Conversion complete! Generated ${totalNodes} nodes`)
|
|
console.log(`[IFC→Pascal] Node breakdown:`, {
|
|
sites: Object.values(nodes).filter((n) => n.type === 'site').length,
|
|
buildings: Object.values(nodes).filter((n) => n.type === 'building').length,
|
|
levels: Object.values(nodes).filter((n) => n.type === 'level').length,
|
|
walls: Object.values(nodes).filter((n) => n.type === 'wall').length,
|
|
slabs: Object.values(nodes).filter((n) => n.type === 'slab').length,
|
|
doors: Object.values(nodes).filter((n) => n.type === 'door').length,
|
|
windows: Object.values(nodes).filter((n) => n.type === 'window').length,
|
|
stairs: Object.values(nodes).filter((n) => n.type === 'stair').length,
|
|
roofs: Object.values(nodes).filter((n) => n.type === 'roof').length,
|
|
columns: Object.values(nodes).filter((n) => n.type === 'column').length,
|
|
skippedBeams: skippedBeamCount,
|
|
skippedItems: skippedItemCount,
|
|
})
|
|
|
|
progress('Complete!', 100)
|
|
|
|
return {
|
|
nodes,
|
|
rootNodeIds: rootNodeIds as AnyNodeId[],
|
|
}
|
|
}
|