From 7d9c89c612a5939a658c09e5933db453bdf21598 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 21 May 2026 10:36:07 -0400 Subject: [PATCH] feat(ifc-converter): real IFCWALL dims + profile-typed columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/ifc-converter/src/index.ts | 170 ++++++++++++++++++++++++++-- 1 file changed, 158 insertions(+), 12 deletions(-) diff --git a/packages/ifc-converter/src/index.ts b/packages/ifc-converter/src/index.ts index 0b5984d9..c8e09c5c 100644 --- a/packages/ifc-converter/src/index.ts +++ b/packages/ifc-converter/src/index.ts @@ -352,6 +352,10 @@ type ExtrusionData = { 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( @@ -378,9 +382,18 @@ function extractFromExtrusionItem( 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 @@ -429,6 +442,8 @@ function getBodyExtrusionData(ifcApi: WebIFC.IfcAPI, modelID: number, element: a xDim: null, yDim: null, profilePoints: null, + profileShape: null, + radius: null, } try { if (!element.Representation?.value) return result @@ -479,6 +494,109 @@ function findExtrusionPosition(ifcApi: WebIFC.IfcAPI, modelID: number, item: any 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 @@ -917,12 +1035,32 @@ export async function convertIfcToPascal( // Plain IFCWALL frequently carries Brep / mapped geometry rather // than a clean IfcExtrudedAreaSolid, so getBodyExtrusionData can't // read its height/thickness (only IFCWALLSTANDARDCASE reliably - // works). 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. - // TODO(ifc-fix): derive exact dims from the element geometry AABB - // (web-ifc GetFlatMesh) for non-extruded walls. + // 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 @@ -1615,6 +1753,8 @@ export async function convertIfcToPascal( 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 @@ -1632,6 +1772,8 @@ export async function convertIfcToPascal( 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 */ } @@ -1641,12 +1783,16 @@ export async function convertIfcToPascal( // 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: rectangular when width≠depth, otherwise a round shaft - // whose radius comes from the profile. + // 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 = - width !== undefined && - depth !== undefined && - Math.abs(width - depth) > 0.15 * Math.max(width, depth) + 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, @@ -1659,7 +1805,7 @@ export async function convertIfcToPascal( depth, height, crossSection: isRect ? 'rectangular' : 'round', - radius: Math.max(width ?? 0.44, depth ?? 0.44) / 2, + radius: profileRadius ?? Math.max(width ?? 0.44, depth ?? 0.44) / 2, style: 'plain', shaftProfile: 'straight', shaftStartScale: 1,